react-native-image-stitcher 0.19.0 → 0.20.1

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 (31) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/android/src/main/java/io/imagestitcher/rn/AROverlayRenderer.kt +406 -0
  3. package/android/src/main/java/io/imagestitcher/rn/AROverlayStore.kt +441 -0
  4. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +290 -0
  5. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +30 -5
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARPluginRegistry.kt +68 -0
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +99 -0
  8. package/dist/camera/ARCameraView.d.ts +33 -1
  9. package/dist/camera/ARCameraView.js +33 -2
  10. package/dist/camera/Camera.d.ts +45 -1
  11. package/dist/camera/Camera.js +24 -6
  12. package/dist/camera/arOverlayController.d.ts +52 -0
  13. package/dist/camera/arOverlayController.js +132 -0
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.js +5 -2
  16. package/dist/stitching/AROverlay.d.ts +97 -0
  17. package/dist/stitching/AROverlay.js +4 -0
  18. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +15 -8
  19. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +22 -0
  20. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +14 -0
  21. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +81 -0
  22. package/ios/Sources/RNImageStitcher/RNISARFramePlugin.swift +37 -0
  23. package/ios/Sources/RNImageStitcher/RNISAROverlay.swift +409 -0
  24. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +301 -3
  25. package/ios/Sources/RNImageStitcher/RNSARSession.swift +70 -65
  26. package/package.json +1 -1
  27. package/src/camera/ARCameraView.tsx +73 -2
  28. package/src/camera/Camera.tsx +71 -2
  29. package/src/camera/arOverlayController.ts +184 -0
  30. package/src/index.ts +15 -0
  31. 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
+ }