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
@@ -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
+ }