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.
Files changed (30) hide show
  1. package/CHANGELOG.md +32 -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 +281 -3
  25. package/package.json +1 -1
  26. package/src/camera/ARCameraView.tsx +73 -2
  27. package/src/camera/Camera.tsx +71 -2
  28. package/src/camera/arOverlayController.ts +184 -0
  29. package/src/index.ts +15 -0
  30. package/src/stitching/AROverlay.ts +105 -0
@@ -0,0 +1,441 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn
3
+
4
+ import android.graphics.Color
5
+ import com.facebook.react.bridge.ReadableArray
6
+ import com.facebook.react.bridge.ReadableMap
7
+ import com.facebook.react.bridge.ReadableType
8
+ import java.util.concurrent.atomic.AtomicReference
9
+
10
+ /**
11
+ * 0.20.0 — Android twin of the shared `AROverlay` data model
12
+ * (TS: `src/stitching/AROverlay.ts`; iOS: `AROverlay.swift`).
13
+ *
14
+ * One immutable description of a 2D annotation to render ON TOP of the AR
15
+ * camera preview, anchored to a WORLD position (or explicit world corners)
16
+ * and reprojected to screen every AR frame from the live camera pose +
17
+ * intrinsics (see [AROverlayRenderer]).
18
+ *
19
+ * Mirrors the TS contract EXACTLY:
20
+ *
21
+ * interface AROverlay {
22
+ * id: string;
23
+ * worldPosition?: [x, y, z]; // world metres (billboard marker/box)
24
+ * sizeMeters?: [w, h]; // box extent at worldPosition
25
+ * worldQuad?: Array<[x, y, z]>; // 3-4 explicit world corners
26
+ * shape?: 'box' | 'outline'; // default 'outline'
27
+ * label?: string;
28
+ * color?: string; // hex; default a theme colour
29
+ * mode?: '2d' | '3d'; // default '2d'; '3d' is SCAFFOLD ONLY
30
+ * }
31
+ *
32
+ * A valid overlay carries EITHER a [worldPosition] (with optional
33
+ * [sizeMeters]) OR a [worldQuad] of 3-4 corners. [worldQuad] wins when
34
+ * both are present (the renderer projects the explicit corners directly).
35
+ *
36
+ * PUBLIC because it is part of the native-plugin SPI: a host app constructs
37
+ * [AROverlayData] instances and hands them to [RNSARPluginRegistry.setOverlays]
38
+ * (native→native overlay placement). JS callers never touch this type — they
39
+ * pass plain maps via the bridge, parsed by [fromReadableMap].
40
+ */
41
+ data class AROverlayData(
42
+ val id: String,
43
+ /// Single world anchor point [x,y,z] in metres, or null when a
44
+ /// [worldQuad] is supplied instead.
45
+ val worldPosition: FloatArray?,
46
+ /// Box extent [width, height] in metres at [worldPosition]; defaults
47
+ /// to a small marker ([DEFAULT_MARKER_SIZE_M]) when omitted.
48
+ val sizeMeters: FloatArray,
49
+ /// Explicit world corners (3-4 points, each [x,y,z]), or null when a
50
+ /// [worldPosition] is supplied instead.
51
+ val worldQuad: Array<FloatArray>?,
52
+ /// 'box' (filled translucent + stroked) or 'outline' (stroked only).
53
+ val shape: String,
54
+ /// Optional text drawn near the overlay's screen centroid.
55
+ val label: String?,
56
+ /// ARGB int parsed from the hex `color` string (default theme cyan).
57
+ val colorArgb: Int,
58
+ /// '2d' (rendered) or '3d' (SCAFFOLD ONLY this release — treated as
59
+ /// '2d' with a one-time warning; see [AROverlayRenderer]).
60
+ val mode: String,
61
+ ) {
62
+ // data class with array fields — override equals/hashCode by `id` only.
63
+ // Identity for diffing the declarative `overlays` prop / imperative set
64
+ // is purely by `id` (the contract diffs "by id"); two overlays with the
65
+ // same id are considered the same slot regardless of field contents.
66
+ override fun equals(other: Any?): Boolean =
67
+ this === other || (other is AROverlayData && other.id == id)
68
+
69
+ override fun hashCode(): Int = id.hashCode()
70
+
71
+ companion object {
72
+ /// Default box extent (metres) for a [worldPosition]-only overlay
73
+ /// with no [sizeMeters] — a small ~6 cm marker.
74
+ const val DEFAULT_MARKER_SIZE_M = 0.06f
75
+
76
+ /// Default overlay colour when `color` is absent / unparseable —
77
+ /// the theme cyan used across the SDK's AR UI.
78
+ const val DEFAULT_COLOR_ARGB = 0xFF00E5FF.toInt()
79
+
80
+ /**
81
+ * Parse one [ReadableMap] (the JS `AROverlay` shape) into an
82
+ * [AROverlayData], or null when it carries neither a usable
83
+ * `worldPosition` nor a 3-4-point `worldQuad`, or has no `id`.
84
+ *
85
+ * Defensive on every field — a malformed entry is dropped (returns
86
+ * null) rather than crashing the bridge call.
87
+ */
88
+ fun fromReadableMap(map: ReadableMap?): AROverlayData? {
89
+ if (map == null) return null
90
+ val id = if (map.hasKey("id") && map.getType("id") == ReadableType.String)
91
+ map.getString("id") else null
92
+ if (id.isNullOrEmpty()) return null
93
+
94
+ val worldQuad = readQuad(map)
95
+ val worldPosition = if (worldQuad == null) readVec3(map, "worldPosition") else null
96
+ // Must anchor to SOMETHING — drop entries with neither.
97
+ if (worldQuad == null && worldPosition == null) return null
98
+
99
+ val sizeMeters = readVec2(map, "sizeMeters")
100
+ ?: floatArrayOf(DEFAULT_MARKER_SIZE_M, DEFAULT_MARKER_SIZE_M)
101
+
102
+ val shape = if (map.hasKey("shape") && map.getType("shape") == ReadableType.String) {
103
+ when (map.getString("shape")) {
104
+ "box" -> "box"
105
+ else -> "outline"
106
+ }
107
+ } else "outline"
108
+
109
+ val label = if (map.hasKey("label") && map.getType("label") == ReadableType.String)
110
+ map.getString("label") else null
111
+
112
+ val colorArgb = if (map.hasKey("color") && map.getType("color") == ReadableType.String)
113
+ parseColor(map.getString("color")) else DEFAULT_COLOR_ARGB
114
+
115
+ val mode = if (map.hasKey("mode") && map.getType("mode") == ReadableType.String) {
116
+ when (map.getString("mode")) {
117
+ "3d" -> "3d"
118
+ else -> "2d"
119
+ }
120
+ } else "2d"
121
+
122
+ return AROverlayData(
123
+ id = id,
124
+ worldPosition = worldPosition,
125
+ sizeMeters = sizeMeters,
126
+ worldQuad = worldQuad,
127
+ shape = shape,
128
+ label = label,
129
+ colorArgb = colorArgb,
130
+ mode = mode,
131
+ )
132
+ }
133
+
134
+ /// Parse a whole [ReadableArray] of overlay maps, dropping any
135
+ /// malformed entries. Returns an empty list for a null/empty array.
136
+ fun fromReadableArray(arr: ReadableArray?): List<AROverlayData> {
137
+ if (arr == null || arr.size() == 0) return emptyList()
138
+ val out = ArrayList<AROverlayData>(arr.size())
139
+ for (i in 0 until arr.size()) {
140
+ if (arr.getType(i) != ReadableType.Map) continue
141
+ fromReadableMap(arr.getMap(i))?.let { out.add(it) }
142
+ }
143
+ return out
144
+ }
145
+
146
+ /// Read a length-3 number array under [key] → FloatArray[3], or null.
147
+ private fun readVec3(map: ReadableMap, key: String): FloatArray? {
148
+ if (!map.hasKey(key) || map.getType(key) != ReadableType.Array) return null
149
+ val a = map.getArray(key) ?: return null
150
+ if (a.size() < 3) return null
151
+ return floatArrayOf(
152
+ a.getDouble(0).toFloat(),
153
+ a.getDouble(1).toFloat(),
154
+ a.getDouble(2).toFloat(),
155
+ )
156
+ }
157
+
158
+ /// Read a length-2 number array under [key] → FloatArray[2], or null.
159
+ private fun readVec2(map: ReadableMap, key: String): FloatArray? {
160
+ if (!map.hasKey(key) || map.getType(key) != ReadableType.Array) return null
161
+ val a = map.getArray(key) ?: return null
162
+ if (a.size() < 2) return null
163
+ return floatArrayOf(a.getDouble(0).toFloat(), a.getDouble(1).toFloat())
164
+ }
165
+
166
+ /// Read `worldQuad` → Array of 3-4 FloatArray[3] corners, or null
167
+ /// when absent / fewer than 3 valid corners.
168
+ private fun readQuad(map: ReadableMap): Array<FloatArray>? {
169
+ if (!map.hasKey("worldQuad") || map.getType("worldQuad") != ReadableType.Array) {
170
+ return null
171
+ }
172
+ val outer = map.getArray("worldQuad") ?: return null
173
+ if (outer.size() < 3) return null
174
+ val corners = ArrayList<FloatArray>(outer.size())
175
+ for (i in 0 until outer.size()) {
176
+ if (outer.getType(i) != ReadableType.Array) continue
177
+ val c = outer.getArray(i) ?: continue
178
+ if (c.size() < 3) continue
179
+ corners.add(
180
+ floatArrayOf(
181
+ c.getDouble(0).toFloat(),
182
+ c.getDouble(1).toFloat(),
183
+ c.getDouble(2).toFloat(),
184
+ ),
185
+ )
186
+ }
187
+ // Cap at 4 corners (the contract says 3-4); ignore extras.
188
+ return if (corners.size < 3) null
189
+ else corners.take(4).toTypedArray()
190
+ }
191
+
192
+ /**
193
+ * Parse a hex colour string (`#RGB`, `#RRGGBB`, `#AARRGGBB`, or any
194
+ * form [Color.parseColor] accepts) → ARGB int. Falls back to the
195
+ * theme default on any parse failure.
196
+ */
197
+ private fun parseColor(hex: String?): Int {
198
+ if (hex.isNullOrBlank()) return DEFAULT_COLOR_ARGB
199
+ return try {
200
+ Color.parseColor(hex.trim())
201
+ } catch (_: Throwable) {
202
+ DEFAULT_COLOR_ARGB
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * 0.20.0 — process-wide store of [AROverlayData] split into two namespaces:
210
+ *
211
+ * - **JS overlays** — set/added/updated/removed via the JS imperative API
212
+ * (`RNSARSession.setOverlays(...)`) or the declarative `overlays` prop.
213
+ * - **Plugin overlays** — placed directly by native plugins via
214
+ * [RNSARPluginRegistry.setOverlays] (zero JS latency, native→native).
215
+ *
216
+ * The renderer draws the **UNION** of both sets, so a JS `setOverlays`
217
+ * never clobbers plugin-placed overlays and vice-versa (the spec's
218
+ * "namespace plugin overlays" requirement). Within a namespace, overlays
219
+ * are keyed by `id`; a later set/add with an existing id REPLACES it.
220
+ *
221
+ * ## Threading
222
+ *
223
+ * Both namespaces are held as immutable lists behind [AtomicReference]s;
224
+ * writes (JS bridge thread or a plugin's queue) swap in a fresh list,
225
+ * reads (the GL render thread, once per frame, via [snapshot]) take the
226
+ * current references. The union list is computed lazily on read. No
227
+ * locks; the AtomicReferences give a consistent per-namespace snapshot.
228
+ *
229
+ * A [version] counter bumps on every mutation so the renderer can cheaply
230
+ * detect "did the overlay SET change since I last rebuilt" without diffing
231
+ * lists (the per-frame reprojection always runs regardless; this is only
232
+ * used to skip the list rebuild when nothing changed).
233
+ *
234
+ * PUBLIC because [RNSARCameraView.overlayStore] (a public val on the public
235
+ * view) exposes it to the native-plugin path; host plugin code never
236
+ * constructs one directly (the SDK owns the single instance per view).
237
+ */
238
+ class AROverlayStore {
239
+
240
+ private val jsOverlays = AtomicReference<List<AROverlayData>>(emptyList())
241
+ private val pluginOverlays = AtomicReference<List<AROverlayData>>(emptyList())
242
+
243
+ @Volatile
244
+ var version: Long = 0L
245
+ private set
246
+
247
+ private fun bump() { version++ }
248
+
249
+ // ── JS namespace ────────────────────────────────────────────────────
250
+
251
+ /// Replace the ENTIRE JS overlay set (declarative prop / imperative
252
+ /// `setOverlays`). Plugin overlays are untouched.
253
+ fun setJsOverlays(overlays: List<AROverlayData>) {
254
+ jsOverlays.set(overlays.toList())
255
+ bump()
256
+ }
257
+
258
+ /// Add or replace one JS overlay by id.
259
+ fun addJsOverlay(overlay: AROverlayData) {
260
+ jsOverlays.updateAndGet { cur -> upsert(cur, overlay) }
261
+ bump()
262
+ }
263
+
264
+ /// Patch one JS overlay's fields by id (no-op if the id is unknown).
265
+ /// `patch` carries only the fields the caller wants to change; absent
266
+ /// fields keep their current value.
267
+ fun updateJsOverlay(id: String, patch: ReadableMap) {
268
+ jsOverlays.updateAndGet { cur ->
269
+ val idx = cur.indexOfFirst { it.id == id }
270
+ if (idx < 0) cur
271
+ else {
272
+ val merged = applyPatch(cur[idx], patch)
273
+ cur.toMutableList().also { it[idx] = merged }
274
+ }
275
+ }
276
+ bump()
277
+ }
278
+
279
+ /// Remove one JS overlay by id (no-op if unknown).
280
+ fun removeJsOverlay(id: String) {
281
+ jsOverlays.updateAndGet { cur -> cur.filterNot { it.id == id } }
282
+ bump()
283
+ }
284
+
285
+ /// Clear ALL JS overlays (plugin overlays untouched).
286
+ fun clearJsOverlays() {
287
+ jsOverlays.set(emptyList())
288
+ bump()
289
+ }
290
+
291
+ // ── Plugin namespace ────────────────────────────────────────────────
292
+
293
+ fun setPluginOverlays(overlays: List<AROverlayData>) {
294
+ pluginOverlays.set(overlays.toList())
295
+ bump()
296
+ }
297
+
298
+ fun addPluginOverlay(overlay: AROverlayData) {
299
+ pluginOverlays.updateAndGet { cur -> upsert(cur, overlay) }
300
+ bump()
301
+ }
302
+
303
+ fun removePluginOverlay(id: String) {
304
+ pluginOverlays.updateAndGet { cur -> cur.filterNot { it.id == id } }
305
+ bump()
306
+ }
307
+
308
+ fun clearPluginOverlays() {
309
+ pluginOverlays.set(emptyList())
310
+ bump()
311
+ }
312
+
313
+ // ── Read ────────────────────────────────────────────────────────────
314
+
315
+ /**
316
+ * The UNION of JS + plugin overlays for this frame's draw. JS overlays
317
+ * come first, then plugin overlays; if an id collides across namespaces
318
+ * BOTH are kept (different namespaces own independent slots — collisions
319
+ * are a host bug but we don't silently drop either).
320
+ */
321
+ fun snapshot(): List<AROverlayData> {
322
+ val js = jsOverlays.get()
323
+ val plugin = pluginOverlays.get()
324
+ if (js.isEmpty()) return plugin
325
+ if (plugin.isEmpty()) return js
326
+ val out = ArrayList<AROverlayData>(js.size + plugin.size)
327
+ out.addAll(js)
328
+ out.addAll(plugin)
329
+ return out
330
+ }
331
+
332
+ /// True when BOTH namespaces are empty — cheap fast-path for the
333
+ /// renderer / per-frame matrix snapshot to skip all work.
334
+ fun isEmpty(): Boolean = jsOverlays.get().isEmpty() && pluginOverlays.get().isEmpty()
335
+
336
+ companion object {
337
+ /// Upsert by id into an immutable list, returning a new list.
338
+ private fun upsert(
339
+ cur: List<AROverlayData>,
340
+ overlay: AROverlayData,
341
+ ): List<AROverlayData> {
342
+ val idx = cur.indexOfFirst { it.id == overlay.id }
343
+ return if (idx < 0) cur + overlay
344
+ else cur.toMutableList().also { it[idx] = overlay }
345
+ }
346
+
347
+ /// Merge a partial `patch` map onto an existing overlay, keeping
348
+ /// the prior value for every field the patch omits. `id` is never
349
+ /// changed (the patch targets an existing id).
350
+ private fun applyPatch(base: AROverlayData, patch: ReadableMap): AROverlayData {
351
+ // Re-parse the patch as a full overlay re-using the base id, but
352
+ // fall back to each base field when the patch omits it. This
353
+ // keeps the parsing rules (vec3/quad/colour) in ONE place
354
+ // (AROverlayData.fromReadableMap) without duplicating them here.
355
+ val hasQuad = patch.hasKey("worldQuad") &&
356
+ patch.getType("worldQuad") == ReadableType.Array
357
+ val hasPos = patch.hasKey("worldPosition") &&
358
+ patch.getType("worldPosition") == ReadableType.Array
359
+
360
+ // If the patch supplies a new anchor (quad or position), parse it
361
+ // through the full parser (which also re-reads size/shape/etc from
362
+ // the patch where present). Otherwise patch individual fields.
363
+ val parsed = if (hasQuad || hasPos) {
364
+ AROverlayData.fromReadableMap(withId(patch, base.id))
365
+ } else null
366
+
367
+ if (parsed != null) {
368
+ // Anchor changed; for fields the patch omitted, prefer base.
369
+ return parsed.copy(
370
+ sizeMeters = if (patchHasVec2(patch, "sizeMeters")) parsed.sizeMeters else base.sizeMeters,
371
+ shape = if (patch.hasKey("shape")) parsed.shape else base.shape,
372
+ label = if (patch.hasKey("label")) parsed.label else base.label,
373
+ colorArgb = if (patch.hasKey("color")) parsed.colorArgb else base.colorArgb,
374
+ mode = if (patch.hasKey("mode")) parsed.mode else base.mode,
375
+ )
376
+ }
377
+
378
+ // No anchor change — patch the scalar/array fields individually.
379
+ val size = if (patchHasVec2(patch, "sizeMeters"))
380
+ floatArrayOf(
381
+ patch.getArray("sizeMeters")!!.getDouble(0).toFloat(),
382
+ patch.getArray("sizeMeters")!!.getDouble(1).toFloat(),
383
+ )
384
+ else base.sizeMeters
385
+
386
+ val shape = if (patch.hasKey("shape") && patch.getType("shape") == ReadableType.String)
387
+ (if (patch.getString("shape") == "box") "box" else "outline")
388
+ else base.shape
389
+
390
+ val label = if (patch.hasKey("label")) {
391
+ if (patch.getType("label") == ReadableType.String) patch.getString("label") else null
392
+ } else base.label
393
+
394
+ val color = if (patch.hasKey("color") && patch.getType("color") == ReadableType.String)
395
+ AROverlayData.fromReadableMap(colorOnly(patch.getString("color")))?.colorArgb
396
+ ?: base.colorArgb
397
+ else base.colorArgb
398
+
399
+ val mode = if (patch.hasKey("mode") && patch.getType("mode") == ReadableType.String)
400
+ (if (patch.getString("mode") == "3d") "3d" else "2d")
401
+ else base.mode
402
+
403
+ return base.copy(
404
+ sizeMeters = size,
405
+ shape = shape,
406
+ label = label,
407
+ colorArgb = color,
408
+ mode = mode,
409
+ )
410
+ }
411
+
412
+ private fun patchHasVec2(patch: ReadableMap, key: String): Boolean {
413
+ if (!patch.hasKey(key) || patch.getType(key) != ReadableType.Array) return false
414
+ val a = patch.getArray(key) ?: return false
415
+ return a.size() >= 2
416
+ }
417
+
418
+ /// Wrap a patch map so the full parser sees the right `id`. RN's
419
+ /// ReadableMap is read-only, so we route through a tiny JavaOnlyMap
420
+ /// copy with the id forced in.
421
+ private fun withId(patch: ReadableMap, id: String): ReadableMap {
422
+ val m = com.facebook.react.bridge.JavaOnlyMap()
423
+ m.merge(patch)
424
+ m.putString("id", id)
425
+ return m
426
+ }
427
+
428
+ /// Build a minimal map carrying just `id` + `color` so we can reuse
429
+ /// the parser's colour logic for a colour-only patch.
430
+ private fun colorOnly(color: String?): ReadableMap {
431
+ val m = com.facebook.react.bridge.JavaOnlyMap()
432
+ m.putString("id", "_")
433
+ // Give it a dummy anchor so the parser accepts it.
434
+ val pos = com.facebook.react.bridge.JavaOnlyArray()
435
+ pos.pushDouble(0.0); pos.pushDouble(0.0); pos.pushDouble(0.0)
436
+ m.putArray("worldPosition", pos)
437
+ if (color != null) m.putString("color", color)
438
+ return m
439
+ }
440
+ }
441
+ }