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
|
@@ -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
|
|
@@ -238,6 +240,85 @@ public final class RNSARSessionBridge: RCTEventEmitter {
|
|
|
238
240
|
}
|
|
239
241
|
}
|
|
240
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
|
+
|
|
241
322
|
@objc(snapshotPoseLog:rejecter:)
|
|
242
323
|
public func snapshotPoseLog(
|
|
243
324
|
resolver: @escaping RCTPromiseResolveBlock,
|
|
@@ -244,4 +244,41 @@ public final class RNISARPluginRegistry: NSObject {
|
|
|
244
244
|
]
|
|
245
245
|
)
|
|
246
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
|
+
}
|
|
247
284
|
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// RNISAROverlay — v0.20.0 AR overlay / annotation data model + store.
|
|
4
|
+
//
|
|
5
|
+
// An "overlay" is a 2D annotation (outline, box, or marker + label)
|
|
6
|
+
// anchored to a WORLD position (or explicit world corners) and
|
|
7
|
+
// reprojected to screen EVERY AR frame from the current camera
|
|
8
|
+
// pose+intrinsics. This file owns ONLY the data model + a process-wide
|
|
9
|
+
// store; the actual drawing + per-frame reprojection lives in
|
|
10
|
+
// `RNSARCameraView` (`AROverlayDrawView`).
|
|
11
|
+
//
|
|
12
|
+
// Mirrors the shared TS contract (`src/stitching/AROverlay.ts`):
|
|
13
|
+
//
|
|
14
|
+
// interface AROverlay {
|
|
15
|
+
// id: string;
|
|
16
|
+
// worldPosition?: [number, number, number]; // world metres
|
|
17
|
+
// sizeMeters?: [number, number]; // box extent at worldPosition
|
|
18
|
+
// worldQuad?: Array<[number, number, number]>; // 3-4 explicit world corners
|
|
19
|
+
// shape?: 'box' | 'outline'; // default 'outline'
|
|
20
|
+
// label?: string;
|
|
21
|
+
// color?: string; // hex; default theme color
|
|
22
|
+
// mode?: '2d' | '3d'; // default '2d'; '3d' SCAFFOLD only
|
|
23
|
+
// }
|
|
24
|
+
//
|
|
25
|
+
// TWO overlay namespaces, rendered as a UNION (see `AROverlayStore`):
|
|
26
|
+
// 1. JS-set overlays — declarative `overlays` prop / imperative ref
|
|
27
|
+
// methods, forwarded through the `RNSARCameraViewManager` view
|
|
28
|
+
// commands.
|
|
29
|
+
// 2. Plugin-set overlays — native plugins place overlays directly via
|
|
30
|
+
// `RNISARPluginRegistry.shared.setOverlays(...)` (zero JS latency).
|
|
31
|
+
// JS `setOverlays` never clobbers plugin overlays and vice-versa; the
|
|
32
|
+
// draw view renders both sets every frame.
|
|
33
|
+
|
|
34
|
+
import Foundation
|
|
35
|
+
import UIKit
|
|
36
|
+
import simd
|
|
37
|
+
|
|
38
|
+
// MARK: - Overlay model
|
|
39
|
+
|
|
40
|
+
/// One AR overlay annotation. Value type (struct) so snapshots handed
|
|
41
|
+
/// to the draw view are cheap, immutable copies — no shared mutable
|
|
42
|
+
/// state across the AR thread / main thread boundary.
|
|
43
|
+
public struct RNISAROverlay: Equatable {
|
|
44
|
+
|
|
45
|
+
/// Shape rendering style.
|
|
46
|
+
public enum Shape: String {
|
|
47
|
+
/// Stroked outline only (default) — a polygon connecting the
|
|
48
|
+
/// projected corners.
|
|
49
|
+
case outline
|
|
50
|
+
/// Stroked box (same as outline for a 4-corner quad; for a
|
|
51
|
+
/// `worldPosition` marker, a small square billboard).
|
|
52
|
+
case box
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Render mode. `.twoD` (default) is the only mode implemented in
|
|
56
|
+
/// v1. `.threeD` is a SCAFFOLD this release — treated as `.twoD`
|
|
57
|
+
/// with a one-time log warning; the native 3D hook in
|
|
58
|
+
/// `AROverlayDrawView` is where a SceneKit renderer will plug in.
|
|
59
|
+
public enum Mode: String {
|
|
60
|
+
case twoD = "2d"
|
|
61
|
+
case threeD = "3d"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Stable identifier. Diffed by id against the current set so a
|
|
65
|
+
/// declarative `overlays` prop / imperative update can replace one
|
|
66
|
+
/// overlay without disturbing the rest.
|
|
67
|
+
public let id: String
|
|
68
|
+
|
|
69
|
+
/// A single world point (metres, ARKit world frame). When set (and
|
|
70
|
+
/// `worldQuad` is nil), the overlay is a billboard marker/box facing
|
|
71
|
+
/// the camera at this point, sized by `sizeMeters`.
|
|
72
|
+
public let worldPosition: simd_float3?
|
|
73
|
+
|
|
74
|
+
/// Box extent (width, height) in metres at `worldPosition`. Only
|
|
75
|
+
/// meaningful with `worldPosition`. Defaults to a small marker.
|
|
76
|
+
public let sizeMeters: CGSize?
|
|
77
|
+
|
|
78
|
+
/// Explicit world corners (3-4 points, metres). When set, the
|
|
79
|
+
/// overlay is the polygon through these corners (e.g. a detected
|
|
80
|
+
/// quad). Takes precedence over `worldPosition`.
|
|
81
|
+
public let worldQuad: [simd_float3]?
|
|
82
|
+
|
|
83
|
+
/// Shape style — `.outline` (default) or `.box`.
|
|
84
|
+
public let shape: Shape
|
|
85
|
+
|
|
86
|
+
/// Optional text label drawn near the overlay's centroid.
|
|
87
|
+
public let label: String?
|
|
88
|
+
|
|
89
|
+
/// Stroke / label color. Defaults to a theme color when the JS hex
|
|
90
|
+
/// is absent / unparseable.
|
|
91
|
+
public let color: UIColor
|
|
92
|
+
|
|
93
|
+
/// Render mode (`.twoD` default; `.threeD` is scaffold-only — see
|
|
94
|
+
/// `Mode`).
|
|
95
|
+
public let mode: Mode
|
|
96
|
+
|
|
97
|
+
public init(
|
|
98
|
+
id: String,
|
|
99
|
+
worldPosition: simd_float3?,
|
|
100
|
+
sizeMeters: CGSize?,
|
|
101
|
+
worldQuad: [simd_float3]?,
|
|
102
|
+
shape: Shape,
|
|
103
|
+
label: String?,
|
|
104
|
+
color: UIColor,
|
|
105
|
+
mode: Mode
|
|
106
|
+
) {
|
|
107
|
+
self.id = id
|
|
108
|
+
self.worldPosition = worldPosition
|
|
109
|
+
self.sizeMeters = sizeMeters
|
|
110
|
+
self.worldQuad = worldQuad
|
|
111
|
+
self.shape = shape
|
|
112
|
+
self.label = label
|
|
113
|
+
self.color = color
|
|
114
|
+
self.mode = mode
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// Default theme color when none / an unparseable hex is supplied.
|
|
118
|
+
/// Cyan matches the example overlay (`#00E5FF`).
|
|
119
|
+
public static let defaultColor = UIColor(
|
|
120
|
+
red: 0.0, green: 0.898, blue: 1.0, alpha: 1.0
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
/// Default marker extent (metres) for a bare `worldPosition` with no
|
|
124
|
+
/// `sizeMeters` — a ~6 cm square so a single anchor point is visible.
|
|
125
|
+
public static let defaultMarkerExtent: CGFloat = 0.06
|
|
126
|
+
|
|
127
|
+
/// Build from the JS bridge dictionary shape (the same keys the TS
|
|
128
|
+
/// `AROverlay` interface serialises to). Returns `nil` when there's
|
|
129
|
+
/// no `id` (the only required field) or no geometry at all
|
|
130
|
+
/// (`worldPosition`/`worldQuad` both missing) — a geometryless
|
|
131
|
+
/// overlay can never be drawn.
|
|
132
|
+
public static func from(dictionary dict: [String: Any]) -> RNISAROverlay? {
|
|
133
|
+
guard let id = dict["id"] as? String, !id.isEmpty else { return nil }
|
|
134
|
+
|
|
135
|
+
let worldPosition = parseVec3(dict["worldPosition"])
|
|
136
|
+
let worldQuad = parseQuad(dict["worldQuad"])
|
|
137
|
+
// Need at least one geometry source.
|
|
138
|
+
guard worldPosition != nil || (worldQuad?.isEmpty == false) else {
|
|
139
|
+
return nil
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let sizeMeters: CGSize? = {
|
|
143
|
+
guard let arr = dict["sizeMeters"] as? [Any], arr.count >= 2,
|
|
144
|
+
let w = numeric(arr[0]), let h = numeric(arr[1]) else {
|
|
145
|
+
return nil
|
|
146
|
+
}
|
|
147
|
+
return CGSize(width: w, height: h)
|
|
148
|
+
}()
|
|
149
|
+
|
|
150
|
+
let shape: Shape = {
|
|
151
|
+
if let s = dict["shape"] as? String, let parsed = Shape(rawValue: s) {
|
|
152
|
+
return parsed
|
|
153
|
+
}
|
|
154
|
+
return .outline
|
|
155
|
+
}()
|
|
156
|
+
|
|
157
|
+
let mode: Mode = {
|
|
158
|
+
if let m = dict["mode"] as? String, let parsed = Mode(rawValue: m) {
|
|
159
|
+
return parsed
|
|
160
|
+
}
|
|
161
|
+
return .twoD
|
|
162
|
+
}()
|
|
163
|
+
|
|
164
|
+
let color: UIColor = {
|
|
165
|
+
if let hex = dict["color"] as? String,
|
|
166
|
+
let parsed = UIColor(hexString: hex) {
|
|
167
|
+
return parsed
|
|
168
|
+
}
|
|
169
|
+
return defaultColor
|
|
170
|
+
}()
|
|
171
|
+
|
|
172
|
+
let label = dict["label"] as? String
|
|
173
|
+
|
|
174
|
+
return RNISAROverlay(
|
|
175
|
+
id: id,
|
|
176
|
+
worldPosition: worldPosition,
|
|
177
|
+
sizeMeters: sizeMeters,
|
|
178
|
+
worldQuad: (worldQuad?.isEmpty == false) ? worldQuad : nil,
|
|
179
|
+
shape: shape,
|
|
180
|
+
label: label,
|
|
181
|
+
color: color,
|
|
182
|
+
mode: mode
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// MARK: Dictionary parsing helpers
|
|
187
|
+
|
|
188
|
+
private static func numeric(_ v: Any?) -> CGFloat? {
|
|
189
|
+
if let n = v as? NSNumber { return CGFloat(n.doubleValue) }
|
|
190
|
+
if let d = v as? Double { return CGFloat(d) }
|
|
191
|
+
if let i = v as? Int { return CGFloat(i) }
|
|
192
|
+
return nil
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private static func parseVec3(_ v: Any?) -> simd_float3? {
|
|
196
|
+
guard let arr = v as? [Any], arr.count >= 3,
|
|
197
|
+
let x = numeric(arr[0]), let y = numeric(arr[1]),
|
|
198
|
+
let z = numeric(arr[2]) else {
|
|
199
|
+
return nil
|
|
200
|
+
}
|
|
201
|
+
return simd_float3(Float(x), Float(y), Float(z))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private static func parseQuad(_ v: Any?) -> [simd_float3]? {
|
|
205
|
+
guard let arr = v as? [Any] else { return nil }
|
|
206
|
+
var pts: [simd_float3] = []
|
|
207
|
+
pts.reserveCapacity(arr.count)
|
|
208
|
+
for item in arr {
|
|
209
|
+
guard let p = parseVec3(item) else { continue }
|
|
210
|
+
pts.append(p)
|
|
211
|
+
}
|
|
212
|
+
// A polygon needs at least 3 corners; cap at 4 (the contract's
|
|
213
|
+
// "3-4 points").
|
|
214
|
+
guard pts.count >= 3 else { return nil }
|
|
215
|
+
return Array(pts.prefix(4))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
// MARK: - UIColor hex parsing
|
|
221
|
+
|
|
222
|
+
extension UIColor {
|
|
223
|
+
/// Parse a CSS-style hex string: `#RGB`, `#RGBA`, `#RRGGBB`, or
|
|
224
|
+
/// `#RRGGBBAA` (the leading `#` is optional). Returns `nil` for
|
|
225
|
+
/// anything unparseable so the caller can fall back to a theme color.
|
|
226
|
+
public convenience init?(hexString raw: String) {
|
|
227
|
+
var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
228
|
+
if s.hasPrefix("#") { s.removeFirst() }
|
|
229
|
+
// Expand shorthand #RGB / #RGBA to #RRGGBB / #RRGGBBAA.
|
|
230
|
+
if s.count == 3 || s.count == 4 {
|
|
231
|
+
s = s.map { "\($0)\($0)" }.joined()
|
|
232
|
+
}
|
|
233
|
+
guard s.count == 6 || s.count == 8,
|
|
234
|
+
let value = UInt64(s, radix: 16) else {
|
|
235
|
+
return nil
|
|
236
|
+
}
|
|
237
|
+
let r, g, b, a: CGFloat
|
|
238
|
+
if s.count == 8 {
|
|
239
|
+
r = CGFloat((value & 0xFF00_0000) >> 24) / 255.0
|
|
240
|
+
g = CGFloat((value & 0x00FF_0000) >> 16) / 255.0
|
|
241
|
+
b = CGFloat((value & 0x0000_FF00) >> 8) / 255.0
|
|
242
|
+
a = CGFloat(value & 0x0000_00FF) / 255.0
|
|
243
|
+
} else {
|
|
244
|
+
r = CGFloat((value & 0xFF0000) >> 16) / 255.0
|
|
245
|
+
g = CGFloat((value & 0x00FF00) >> 8) / 255.0
|
|
246
|
+
b = CGFloat(value & 0x0000FF) / 255.0
|
|
247
|
+
a = 1.0
|
|
248
|
+
}
|
|
249
|
+
self.init(red: r, green: g, blue: b, alpha: a)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
// MARK: - Overlay store
|
|
255
|
+
|
|
256
|
+
/// Process-wide store of active overlays, split into two namespaces:
|
|
257
|
+
/// JS-set (declarative prop / imperative ref) and plugin-set (native
|
|
258
|
+
/// plugins via `RNISARPluginRegistry`). Mounted `RNSARCameraView`s
|
|
259
|
+
/// subscribe (via `addObserver`) and re-read `snapshot()` to redraw.
|
|
260
|
+
///
|
|
261
|
+
/// THREAD SAFETY: mutations come from the bridge / main thread (JS
|
|
262
|
+
/// commands) and arbitrary plugin queues (`setOverlays`); reads come from
|
|
263
|
+
/// the main thread (the draw view). All access is serialised by an
|
|
264
|
+
/// internal lock; `snapshot()` returns a value-type array copy so the
|
|
265
|
+
/// reader never holds the lock while drawing.
|
|
266
|
+
@objc(RNISAROverlayStore)
|
|
267
|
+
public final class RNISAROverlayStore: NSObject {
|
|
268
|
+
|
|
269
|
+
/// Shared instance — singleton because the overlay set is global to
|
|
270
|
+
/// the (single) AR session, matching `RNSARSession.shared`.
|
|
271
|
+
@objc public static let shared = RNISAROverlayStore()
|
|
272
|
+
|
|
273
|
+
/// Notification posted (on any thread) whenever either namespace
|
|
274
|
+
/// changes. Mounted draw views observe it to mark themselves dirty.
|
|
275
|
+
/// (Used for the imperative / plugin paths; the per-frame redraw in
|
|
276
|
+
/// `RNSARCameraView` already refreshes geometry every frame, so this
|
|
277
|
+
/// is mainly to pick up SET changes between frames.)
|
|
278
|
+
public static let overlaysChanged =
|
|
279
|
+
Notification.Name("RNISAROverlaysChanged")
|
|
280
|
+
|
|
281
|
+
/// JS-set overlays, keyed by id (last-write-wins per id). Ordered
|
|
282
|
+
/// list preserved separately for deterministic draw order.
|
|
283
|
+
private var jsById: [String: RNISAROverlay] = [:]
|
|
284
|
+
private var jsOrder: [String] = []
|
|
285
|
+
|
|
286
|
+
/// Plugin-set overlays, same structure, separate namespace.
|
|
287
|
+
private var pluginById: [String: RNISAROverlay] = [:]
|
|
288
|
+
private var pluginOrder: [String] = []
|
|
289
|
+
|
|
290
|
+
private let lock = NSLock()
|
|
291
|
+
|
|
292
|
+
private override init() { super.init() }
|
|
293
|
+
|
|
294
|
+
// MARK: JS namespace
|
|
295
|
+
|
|
296
|
+
/// Replace the ENTIRE JS overlay set (the declarative `overlays`
|
|
297
|
+
/// prop / imperative `setOverlays`). Plugin overlays are untouched.
|
|
298
|
+
public func setJSOverlays(_ overlays: [RNISAROverlay]) {
|
|
299
|
+
lock.lock()
|
|
300
|
+
jsById.removeAll(keepingCapacity: true)
|
|
301
|
+
jsOrder.removeAll(keepingCapacity: true)
|
|
302
|
+
for o in overlays {
|
|
303
|
+
if jsById[o.id] == nil { jsOrder.append(o.id) }
|
|
304
|
+
jsById[o.id] = o
|
|
305
|
+
}
|
|
306
|
+
lock.unlock()
|
|
307
|
+
postChanged()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// Add or replace a single JS overlay (imperative `addOverlay`).
|
|
311
|
+
public func addJSOverlay(_ overlay: RNISAROverlay) {
|
|
312
|
+
lock.lock()
|
|
313
|
+
if jsById[overlay.id] == nil { jsOrder.append(overlay.id) }
|
|
314
|
+
jsById[overlay.id] = overlay
|
|
315
|
+
lock.unlock()
|
|
316
|
+
postChanged()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/// Remove a single JS overlay by id (imperative `removeOverlay`).
|
|
320
|
+
/// No-op if absent.
|
|
321
|
+
public func removeJSOverlay(_ id: String) {
|
|
322
|
+
lock.lock()
|
|
323
|
+
jsById.removeValue(forKey: id)
|
|
324
|
+
jsOrder.removeAll { $0 == id }
|
|
325
|
+
lock.unlock()
|
|
326
|
+
postChanged()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/// Clear ALL JS overlays (imperative `clearOverlays`). Plugin
|
|
330
|
+
/// overlays are untouched.
|
|
331
|
+
public func clearJSOverlays() {
|
|
332
|
+
lock.lock()
|
|
333
|
+
jsById.removeAll(keepingCapacity: true)
|
|
334
|
+
jsOrder.removeAll(keepingCapacity: true)
|
|
335
|
+
lock.unlock()
|
|
336
|
+
postChanged()
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// MARK: Plugin namespace
|
|
340
|
+
|
|
341
|
+
/// Replace the ENTIRE plugin overlay set. JS overlays are untouched.
|
|
342
|
+
public func setPluginOverlays(_ overlays: [RNISAROverlay]) {
|
|
343
|
+
lock.lock()
|
|
344
|
+
pluginById.removeAll(keepingCapacity: true)
|
|
345
|
+
pluginOrder.removeAll(keepingCapacity: true)
|
|
346
|
+
for o in overlays {
|
|
347
|
+
if pluginById[o.id] == nil { pluginOrder.append(o.id) }
|
|
348
|
+
pluginById[o.id] = o
|
|
349
|
+
}
|
|
350
|
+
lock.unlock()
|
|
351
|
+
postChanged()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/// Add or replace a single plugin overlay.
|
|
355
|
+
public func addPluginOverlay(_ overlay: RNISAROverlay) {
|
|
356
|
+
lock.lock()
|
|
357
|
+
if pluginById[overlay.id] == nil { pluginOrder.append(overlay.id) }
|
|
358
|
+
pluginById[overlay.id] = overlay
|
|
359
|
+
lock.unlock()
|
|
360
|
+
postChanged()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/// Remove a single plugin overlay by id. No-op if absent.
|
|
364
|
+
public func removePluginOverlay(_ id: String) {
|
|
365
|
+
lock.lock()
|
|
366
|
+
pluginById.removeValue(forKey: id)
|
|
367
|
+
pluginOrder.removeAll { $0 == id }
|
|
368
|
+
lock.unlock()
|
|
369
|
+
postChanged()
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/// Clear ALL plugin overlays. JS overlays are untouched.
|
|
373
|
+
public func clearPluginOverlays() {
|
|
374
|
+
lock.lock()
|
|
375
|
+
pluginById.removeAll(keepingCapacity: true)
|
|
376
|
+
pluginOrder.removeAll(keepingCapacity: true)
|
|
377
|
+
lock.unlock()
|
|
378
|
+
postChanged()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// MARK: Read
|
|
382
|
+
|
|
383
|
+
/// Snapshot of the UNION of both namespaces, in draw order (JS first,
|
|
384
|
+
/// then plugin). Returns a value-type array copy so the caller can
|
|
385
|
+
/// iterate + draw without holding the lock.
|
|
386
|
+
public func snapshot() -> [RNISAROverlay] {
|
|
387
|
+
lock.lock()
|
|
388
|
+
defer { lock.unlock() }
|
|
389
|
+
var out: [RNISAROverlay] = []
|
|
390
|
+
out.reserveCapacity(jsOrder.count + pluginOrder.count)
|
|
391
|
+
for id in jsOrder { if let o = jsById[id] { out.append(o) } }
|
|
392
|
+
for id in pluginOrder { if let o = pluginById[id] { out.append(o) } }
|
|
393
|
+
return out
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/// Whether there are no overlays in either namespace. Cheap gate the
|
|
397
|
+
/// draw view can check to skip work.
|
|
398
|
+
public var isEmpty: Bool {
|
|
399
|
+
lock.lock()
|
|
400
|
+
defer { lock.unlock() }
|
|
401
|
+
return jsOrder.isEmpty && pluginOrder.isEmpty
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private func postChanged() {
|
|
405
|
+
NotificationCenter.default.post(
|
|
406
|
+
name: Self.overlaysChanged, object: nil
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
}
|