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
package/CHANGELOG.md CHANGED
@@ -14,6 +14,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
14
  > during 0.x are bumped to a new MINOR (e.g., 0.1 → 0.2), and the
15
15
  > upgrade path is documented in this CHANGELOG.
16
16
 
17
+ ## [0.20.0] — 2026-06-20
18
+
19
+ ### Added — AR overlay / annotation renderer
20
+
21
+ AR-mode `<Camera>` can now draw **world-anchored 2D overlays** — outlines,
22
+ boxes, markers + labels pinned to a real-world point (or explicit quad) and
23
+ tracked as the device moves. Drive them from **JS** (declarative `overlays`
24
+ prop + imperative ref: `setOverlays` / `addOverlay` / `updateOverlay` /
25
+ `removeOverlay` / `clearOverlays`) or from **native plugins**
26
+ (`RNISARPluginRegistry` / `RNSARPluginRegistry` `setOverlays`); the two
27
+ namespaces render as a union.
28
+
29
+ - **`AROverlay`** shape: `{ id, worldPosition? | worldQuad?, sizeMeters?,
30
+ shape: 'box' | 'outline', label?, color?, mode: '2d' | '3d' }`.
31
+ - **Real anchoring, not hand-projection.** Each overlay is pinned to a true AR
32
+ anchor — an `ARAnchor` rendered as a SceneKit node on iOS, an ARCore
33
+ `Anchor` projected over the camera on Android — so the framework tracks and
34
+ *refines* the point against drift / re-localization. The marker stays glued
35
+ to the real-world spot instead of riding the screen.
36
+ - **`raycast()`** (Camera ref): casts from the screen-centre crosshair to the
37
+ nearest real surface and resolves its world point — so a marker can be
38
+ dropped **on** the aimed object at its true depth (ARKit raycast on iOS,
39
+ ARCore `hitTest` on Android). Resolves `null` when nothing is hit, so callers
40
+ can fall back to a fixed placement.
41
+ - The example demos a crosshair + "Pin marker" that raycasts, anchors, and
42
+ tracks a cyan marker on the aimed surface.
43
+
44
+ Device-verified on iPhone (LiDAR — precise raycast depth) and a Galaxy A35
45
+ (ARCore depth-from-motion — softer placement on depth-sensorless devices, as
46
+ expected). `mode:'3d'` renders as a world-anchored 2D billboard this release;
47
+ a future release can extend the SceneKit/Anchor path to richer 3D content.
48
+
17
49
  ## [0.19.0] — 2026-06-19
18
50
 
19
51
  ### Added — Native AR frame-processor plugins
@@ -0,0 +1,406 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn
3
+
4
+ import android.content.Context
5
+ import android.graphics.Canvas
6
+ import android.graphics.Color
7
+ import android.graphics.Paint
8
+ import android.graphics.Path
9
+ import android.opengl.Matrix
10
+ import android.util.Log
11
+ import android.view.View
12
+
13
+ /**
14
+ * 0.20.0 — transparent overlay [View] drawn ABOVE the [RNSARCameraView]'s
15
+ * GLSurfaceView. Each AR frame, [RNSARCameraView.onDrawFrame] snapshots the
16
+ * current camera **view** + **projection** matrices and the GL letterbox box,
17
+ * pushes them in via [updateCamera], then requests a redraw; [onDraw]
18
+ * reprojects every overlay's world point(s) → screen and strokes the
19
+ * outline / box + label with a [Canvas].
20
+ *
21
+ * This is the Android side of the shared 0.20.0 contract — the iOS twin uses
22
+ * `ARFrame.camera.projectPoint(...)` on a `CAShapeLayer`. Here we do the
23
+ * projection ourselves from the ARCore matrices:
24
+ *
25
+ * clip = projection · view · [x y z 1]ᵀ
26
+ * ndc = clip.xyz / clip.w (w ≤ 0 ⇒ behind camera ⇒ hidden)
27
+ * px = box.x + (ndc.x*0.5 + 0.5) * box.w
28
+ * py = box.y + (0.5 - ndc.y*0.5) * box.h (GL y-up → screen y-down)
29
+ *
30
+ * The view/projection matrices come from `frame.camera.getViewMatrix(...)`
31
+ * and `getProjectionMatrix(...)`, which already bake in the current display
32
+ * rotation (the session's `setDisplayGeometry`), so the projected pixels land
33
+ * in the SAME letterbox box the camera feed renders into — overlays track the
34
+ * scene at display rate.
35
+ *
36
+ * ## 3D scaffold (mode:'3d')
37
+ *
38
+ * v1 renders ONLY '2d'. An overlay with `mode:'3d'` is treated as '2d' with
39
+ * a one-time [Log] warning (see [warn3dOnce]). The clearly-marked
40
+ * [render3dScaffold] hook is where a future Android 3D renderer (SceneView /
41
+ * Filament) will plug in — it is intentionally empty this release.
42
+ *
43
+ * ## Threading
44
+ *
45
+ * [updateCamera] is called on the GL render thread; [onDraw] runs on the UI
46
+ * (main) thread. The matrices + box are published through `@Volatile`
47
+ * fields, and the overlay set is read from the shared [AROverlayStore]
48
+ * (its own AtomicReferences) — so no locks are needed. We snapshot the
49
+ * matrices into local copies in [onDraw] so a concurrent [updateCamera]
50
+ * mid-draw can't tear a single matrix.
51
+ */
52
+ internal class AROverlayRenderer(
53
+ context: Context,
54
+ /// Shared overlay source — the UNION of JS + plugin overlays.
55
+ private val store: AROverlayStore,
56
+ ) : View(context) {
57
+
58
+ // ── Camera state published per AR frame (GL thread → UI thread) ──────
59
+ //
60
+ // Two full 4x4 column-major matrices (OpenGL layout, as ARCore returns
61
+ // them) + the letterbox box [x,y,w,h] in this view's pixel space. Held
62
+ // behind a single @Volatile reference object so onDraw reads a coherent
63
+ // snapshot (no half-updated matrix).
64
+
65
+ private class CameraState(
66
+ val view: FloatArray, // 16, column-major
67
+ val projection: FloatArray, // 16, column-major
68
+ val boxX: Float,
69
+ val boxY: Float,
70
+ val boxW: Float,
71
+ val boxH: Float,
72
+ val tracking: Boolean,
73
+ )
74
+
75
+ @Volatile
76
+ private var camera: CameraState? = null
77
+
78
+ // v0.20.0 — per-overlay anchor positions (overlay id → live world [x,y,z]),
79
+ // published from the GL thread each frame after the view reconciles ARCore
80
+ // anchors. When present for an overlay, onDraw uses this DRIFT-CORRECTED
81
+ // position instead of the overlay's frozen worldPosition / worldQuad — so
82
+ // ARCore can keep the marker on the real spot across re-localization.
83
+ @Volatile
84
+ private var anchorPositions: Map<String, FloatArray> = emptyMap()
85
+
86
+ /// Publish drift-corrected anchor positions for the current frame (called
87
+ /// on the GL thread before [updateCamera]). Empty = no anchored overlays.
88
+ fun setAnchorPositions(positions: Map<String, FloatArray>) {
89
+ anchorPositions = positions
90
+ }
91
+
92
+ // Reusable paints (allocate once — onDraw runs at display rate).
93
+ private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
94
+ style = Paint.Style.STROKE
95
+ strokeWidth = STROKE_WIDTH_PX
96
+ strokeCap = Paint.Cap.ROUND
97
+ strokeJoin = Paint.Join.ROUND
98
+ }
99
+ private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
100
+ style = Paint.Style.FILL
101
+ }
102
+ private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
103
+ style = Paint.Style.FILL
104
+ textSize = LABEL_TEXT_SIZE_PX
105
+ textAlign = Paint.Align.CENTER
106
+ }
107
+ private val labelBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
108
+ style = Paint.Style.FILL
109
+ color = LABEL_BG_ARGB
110
+ }
111
+
112
+ // Scratch buffers reused across onDraw (avoid per-frame allocations).
113
+ private val viewProj = FloatArray(16)
114
+ private val clip = FloatArray(4)
115
+ private val homog = FloatArray(4)
116
+ private val path = Path()
117
+ private val labelBounds = android.graphics.Rect()
118
+
119
+ init {
120
+ // Fully transparent — only the camera feed shows through where we
121
+ // don't draw. Don't intercept touches: the overlay is display-only,
122
+ // gestures pass through to whatever the host stacks below/above.
123
+ setBackgroundColor(Color.TRANSPARENT)
124
+ // Hardware layer: Canvas stroking of a few shapes is cheap; keep the
125
+ // default (HW-accelerated) rendering — no setLayerType needed.
126
+ }
127
+
128
+ override fun onTouchEvent(event: android.view.MotionEvent?): Boolean = false
129
+
130
+ /**
131
+ * Publish this AR frame's camera matrices + letterbox box, then request
132
+ * a redraw. Called from the GL render thread once per frame (cheap when
133
+ * no overlays exist — caller can skip via [AROverlayStore.isEmpty]).
134
+ *
135
+ * @param viewMatrix column-major 4x4 from `camera.getViewMatrix`.
136
+ * @param projectionMatrix column-major 4x4 from `camera.getProjectionMatrix`.
137
+ * @param boxX,boxY,boxW,boxH letterbox box (pixels) the camera feed fills.
138
+ * @param tracking true when ARCore tracking == TRACKING (overlays
139
+ * are hidden while not tracking — their world
140
+ * positions aren't yet meaningful).
141
+ */
142
+ fun updateCamera(
143
+ viewMatrix: FloatArray,
144
+ projectionMatrix: FloatArray,
145
+ boxX: Float,
146
+ boxY: Float,
147
+ boxW: Float,
148
+ boxH: Float,
149
+ tracking: Boolean,
150
+ ) {
151
+ camera = CameraState(
152
+ view = viewMatrix.copyOf(16),
153
+ projection = projectionMatrix.copyOf(16),
154
+ boxX = boxX, boxY = boxY, boxW = boxW, boxH = boxH,
155
+ tracking = tracking,
156
+ )
157
+ // Request a redraw on the UI thread (postInvalidate is thread-safe).
158
+ postInvalidateOnAnimation()
159
+ }
160
+
161
+ /// Clear all drawing (e.g. when the session stops / view detaches).
162
+ fun clear() {
163
+ camera = null
164
+ postInvalidateOnAnimation()
165
+ }
166
+
167
+ override fun onDraw(canvas: Canvas) {
168
+ super.onDraw(canvas)
169
+ val cam = camera ?: return
170
+ if (!cam.tracking) return // hide overlays until tracking
171
+ val overlays = store.snapshot()
172
+ if (overlays.isEmpty()) return
173
+
174
+ // viewProj = projection · view (column-major multiply).
175
+ Matrix.multiplyMM(viewProj, 0, cam.projection, 0, cam.view, 0)
176
+
177
+ for (overlay in overlays) {
178
+ try {
179
+ drawOverlay(canvas, overlay, cam)
180
+ } catch (t: Throwable) {
181
+ // One bad overlay must never crash the whole draw pass.
182
+ Log.w(TAG, "drawOverlay('${overlay.id}') failed: ${t.message}")
183
+ }
184
+ }
185
+ }
186
+
187
+ private fun drawOverlay(canvas: Canvas, overlay: AROverlayData, cam: CameraState) {
188
+ // 3D scaffold: v1 renders '3d' as '2d' with a one-time warning.
189
+ if (overlay.mode == "3d") {
190
+ warn3dOnce()
191
+ render3dScaffold(overlay)
192
+ // fall through — draw it as a 2D overlay this release.
193
+ }
194
+
195
+ // Build the world corners to project:
196
+ // - worldQuad: the explicit 3-4 corners.
197
+ // - worldPosition + sizeMeters: 4 corners of a billboard quad
198
+ // facing the camera (so the box always presents flat to the
199
+ // viewer regardless of camera angle).
200
+ // v0.20.0 — prefer the drift-corrected ARCore anchor position when the
201
+ // view has published one for this overlay; else the frozen geometry.
202
+ val anchorPos = anchorPositions[overlay.id]
203
+ val worldCorners: Array<FloatArray> = when {
204
+ overlay.worldQuad != null -> {
205
+ val q = overlay.worldQuad
206
+ if (anchorPos != null) {
207
+ // Translate the quad so its centroid sits at the anchor.
208
+ var cx = 0f; var cy = 0f; var cz = 0f
209
+ for (v in q) { cx += v[0]; cy += v[1]; cz += v[2] }
210
+ val n = q.size.toFloat()
211
+ val dx = anchorPos[0] - cx / n
212
+ val dy = anchorPos[1] - cy / n
213
+ val dz = anchorPos[2] - cz / n
214
+ Array(q.size) { i ->
215
+ floatArrayOf(q[i][0] + dx, q[i][1] + dy, q[i][2] + dz)
216
+ }
217
+ } else {
218
+ q
219
+ }
220
+ }
221
+ overlay.worldPosition != null ->
222
+ billboardCorners(anchorPos ?: overlay.worldPosition, overlay.sizeMeters, cam)
223
+ else -> return
224
+ }
225
+
226
+ // Project each corner to screen pixels; bail if ANY corner is behind
227
+ // the camera (w<=0) — a partially-behind quad would draw a garbage
228
+ // wrap-around polygon.
229
+ val screen = FloatArray(worldCorners.size * 2)
230
+ for (i in worldCorners.indices) {
231
+ val p = projectToScreen(worldCorners[i], cam) ?: return
232
+ screen[i * 2] = p[0]
233
+ screen[i * 2 + 1] = p[1]
234
+ }
235
+
236
+ // Off-screen cull: if the whole polygon is outside the view bounds,
237
+ // skip (cheap, and avoids drawing labels for unseen overlays).
238
+ if (isFullyOffscreen(screen)) return
239
+
240
+ strokePaint.color = overlay.colorArgb
241
+
242
+ // Build the closed polygon path.
243
+ path.reset()
244
+ path.moveTo(screen[0], screen[1])
245
+ for (i in 1 until worldCorners.size) {
246
+ path.lineTo(screen[i * 2], screen[i * 2 + 1])
247
+ }
248
+ path.close()
249
+
250
+ if (overlay.shape == "box") {
251
+ // Translucent fill (overlay colour @ ~22% alpha) + opaque stroke.
252
+ fillPaint.color = (overlay.colorArgb and 0x00FFFFFF) or (BOX_FILL_ALPHA shl 24)
253
+ canvas.drawPath(path, fillPaint)
254
+ }
255
+ canvas.drawPath(path, strokePaint)
256
+
257
+ // Label at the polygon centroid (screen space).
258
+ overlay.label?.let { drawLabel(canvas, it, screen, overlay.colorArgb) }
259
+ }
260
+
261
+ /**
262
+ * Project a world point [x,y,z] through viewProj → screen pixels inside
263
+ * the letterbox box. Returns null when the point is behind the camera
264
+ * (clip.w ≤ 0).
265
+ */
266
+ private fun projectToScreen(world: FloatArray, cam: CameraState): FloatArray? {
267
+ homog[0] = world[0]; homog[1] = world[1]; homog[2] = world[2]; homog[3] = 1f
268
+ Matrix.multiplyMV(clip, 0, viewProj, 0, homog, 0)
269
+ val w = clip[3]
270
+ if (w <= 1e-6f) return null // behind / on the camera plane
271
+ val ndcX = clip[0] / w
272
+ val ndcY = clip[1] / w
273
+ // NDC [-1,1] → box pixels. GL is y-up; screen is y-down → flip Y.
274
+ val px = cam.boxX + (ndcX * 0.5f + 0.5f) * cam.boxW
275
+ val py = cam.boxY + (0.5f - ndcY * 0.5f) * cam.boxH
276
+ return floatArrayOf(px, py)
277
+ }
278
+
279
+ /**
280
+ * Build 4 world corners of a camera-facing billboard quad centred at
281
+ * [center] with extent [size] (metres). The quad's right axis is the
282
+ * camera's right (row 0 of the view matrix) and its up axis is the
283
+ * camera's up (row 1) — so the box always faces the viewer.
284
+ *
285
+ * The view matrix is world→camera; its rows (in column-major storage:
286
+ * elements 0,4,8 = right; 1,5,9 = up) give the camera basis in world
287
+ * space.
288
+ */
289
+ private fun billboardCorners(
290
+ center: FloatArray,
291
+ size: FloatArray,
292
+ cam: CameraState,
293
+ ): Array<FloatArray> {
294
+ val v = cam.view
295
+ // Camera right (world space) = first ROW of the view matrix.
296
+ val rx = v[0]; val ry = v[4]; val rz = v[8]
297
+ // Camera up (world space) = second ROW of the view matrix.
298
+ val ux = v[1]; val uy = v[5]; val uz = v[9]
299
+ val hw = size[0] * 0.5f
300
+ val hh = size[1] * 0.5f
301
+ // Corner order: TL, TR, BR, BL (CW) so the stroked outline is a quad.
302
+ fun corner(sx: Float, sy: Float) = floatArrayOf(
303
+ center[0] + rx * sx * hw + ux * sy * hh,
304
+ center[1] + ry * sx * hw + uy * sy * hh,
305
+ center[2] + rz * sx * hw + uz * sy * hh,
306
+ )
307
+ return arrayOf(
308
+ corner(-1f, 1f), // top-left
309
+ corner(1f, 1f), // top-right
310
+ corner(1f, -1f), // bottom-right
311
+ corner(-1f, -1f), // bottom-left
312
+ )
313
+ }
314
+
315
+ /// Draw a label with a translucent rounded background at the polygon's
316
+ /// screen centroid. Colour matches the overlay's stroke colour.
317
+ private fun drawLabel(canvas: Canvas, text: String, screen: FloatArray, colorArgb: Int) {
318
+ if (text.isEmpty()) return
319
+ var cx = 0f
320
+ var cy = 0f
321
+ val n = screen.size / 2
322
+ for (i in 0 until n) { cx += screen[i * 2]; cy += screen[i * 2 + 1] }
323
+ cx /= n
324
+ cy /= n
325
+
326
+ labelPaint.color = colorArgb
327
+ labelPaint.getTextBounds(text, 0, text.length, labelBounds)
328
+ val padX = LABEL_PAD_PX
329
+ val padY = LABEL_PAD_PX * 0.6f
330
+ val bgW = labelBounds.width() + padX * 2
331
+ val bgH = labelBounds.height() + padY * 2
332
+ val left = cx - bgW / 2
333
+ val top = cy - bgH / 2
334
+ canvas.drawRoundRect(
335
+ left, top, left + bgW, top + bgH,
336
+ LABEL_CORNER_PX, LABEL_CORNER_PX, labelBgPaint,
337
+ )
338
+ // Baseline so the text is vertically centred in the bg box.
339
+ val baseline = cy - (labelPaint.descent() + labelPaint.ascent()) / 2
340
+ canvas.drawText(text, cx, baseline, labelPaint)
341
+ }
342
+
343
+ /// True when every projected vertex lies outside this view's bounds on
344
+ /// the SAME side (cheap conservative cull — a polygon straddling an edge
345
+ /// still draws).
346
+ private fun isFullyOffscreen(screen: FloatArray): Boolean {
347
+ val w = width.toFloat()
348
+ val h = height.toFloat()
349
+ if (w <= 0f || h <= 0f) return false
350
+ var allLeft = true; var allRight = true; var allAbove = true; var allBelow = true
351
+ var i = 0
352
+ while (i < screen.size) {
353
+ val x = screen[i]; val y = screen[i + 1]
354
+ if (x >= 0f) allLeft = false
355
+ if (x <= w) allRight = false
356
+ if (y >= 0f) allAbove = false
357
+ if (y <= h) allBelow = false
358
+ i += 2
359
+ }
360
+ return allLeft || allRight || allAbove || allBelow
361
+ }
362
+
363
+ // ── 3D scaffold (mode:'3d') — LIGHT, intentionally empty this release ──
364
+
365
+ @Volatile private var warned3d = false
366
+
367
+ /// One-time log warning when an overlay requests the not-yet-implemented
368
+ /// '3d' mode (v1 renders it as '2d'). Mirrors the contract's "one-time
369
+ /// console/log warning".
370
+ private fun warn3dOnce() {
371
+ if (warned3d) return
372
+ warned3d = true
373
+ Log.w(
374
+ TAG,
375
+ "AROverlay mode:'3d' is a SCAFFOLD this release — rendering it as " +
376
+ "'2d'. A 3D renderer (SceneView / Filament) is planned for a " +
377
+ "later release; see render3dScaffold().",
378
+ )
379
+ }
380
+
381
+ /**
382
+ * SCAFFOLD HOOK — where a future Android 3D overlay renderer will plug
383
+ * in (SceneView / Filament / a GL pass into the camera surface). v1
384
+ * does NOTHING here on purpose: the data-model field (`mode:'3d'`) is
385
+ * defined and the call site is wired, but no 3D engine is added this
386
+ * release. The overlay is still drawn as 2D by the caller.
387
+ *
388
+ * @param overlay the '3d'-mode overlay (currently unused).
389
+ */
390
+ @Suppress("UNUSED_PARAMETER")
391
+ private fun render3dScaffold(overlay: AROverlayData) {
392
+ // TODO(0.21+): place/update a 3D node for `overlay` here.
393
+ }
394
+
395
+ companion object {
396
+ private const val TAG = "AROverlayRenderer"
397
+ private const val STROKE_WIDTH_PX = 4f
398
+ private const val LABEL_TEXT_SIZE_PX = 36f
399
+ private const val LABEL_PAD_PX = 14f
400
+ private const val LABEL_CORNER_PX = 8f
401
+ /// Label background: ~70% black.
402
+ private const val LABEL_BG_ARGB = 0xB3000000.toInt()
403
+ /// Box-shape fill alpha (0..255) applied to the overlay colour.
404
+ private const val BOX_FILL_ALPHA = 0x38 // ~22%
405
+ }
406
+ }