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
package/CHANGELOG.md CHANGED
@@ -14,6 +14,68 @@ 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
+
49
+ ## [0.19.0] — 2026-06-19
50
+
51
+ ### Added — Native AR frame-processor plugins
52
+
53
+ AR-mode `<Camera>` can now run **native per-frame plugins** with zero-copy
54
+ access to the AR frame — the foundation for on-device CV (OCR, object
55
+ detection, reconstruction feeds) **without** baking that domain code into the
56
+ SDK. The SDK ships only the generic framework; plugins live in your app.
57
+
58
+ - **Plugin interface:** implement `RNISARFramePlugin` (iOS) / `ARFramePlugin`
59
+ (Android) — `name()` + `process(context)`.
60
+ - **`ARFrameContext`** hands the plugin the frame **zero-copy**: the camera
61
+ buffer, `pose`, `intrinsics`, tracking state, timestamp, and — when the
62
+ matching `enable*` prop is on — `depth` + `anchors`. The buffer is valid
63
+ **only during `process()`**; copy it before offloading to another thread.
64
+ - **Register at startup:** `RNISARPluginRegistry.shared.register(…)` (iOS) /
65
+ `RNSARPluginRegistry.register(…)` (Android). The SDK invokes registered
66
+ plugins per AR frame, gated on a **non-empty registry** — zero-plugin apps
67
+ pay nothing.
68
+ - **Two result channels:** light **synchronous** `process()` returns fold into
69
+ `onArFrame`'s `ARFrameMeta.plugins` (keyed by plugin name); heavy / **async**
70
+ results are pushed via `registry.emit(name, result)` → the new
71
+ **`onArPluginResult`** callback prop (delivered off the AR thread — for slow
72
+ work like OCR that must not block frame capture).
73
+ - The example ships a sample `FrameBrightnessPlugin` (both platforms),
74
+ surfaced live in the AR overlay.
75
+
76
+ Device-verified on iPhone 16 Pro. The SDK stays dependency-light — no OCR / ML
77
+ runtimes are added to core.
78
+
17
79
  ## [0.18.0] — 2026-06-18
18
80
 
19
81
  ### ⚠️ Breaking — `StitcherFrame` → `CameraFrame`
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn
3
+
4
+ /**
5
+ * 0.19.0 — zero-copy native view of one ARCore frame, handed to every
6
+ * registered [ARFramePlugin.process] (iOS twin: `RNISARFrameContext`).
7
+ *
8
+ * The SDK builds ONE of these per AR frame — only when [RNSARPluginRegistry]
9
+ * is non-empty — from the SAME `ARCore Frame` + pose the `onArFrame` meta
10
+ * path uses, then passes it to each plugin in turn. Zero-plugin apps never
11
+ * pay for the build.
12
+ *
13
+ * ## Camera image
14
+ *
15
+ * ARCore hands the SDK a `YUV_420_888` camera image which is already packed
16
+ * into a contiguous JVM-side NV21 byte array ([nv21]) before the ARCore
17
+ * `Image` is closed (the SDK does this once per frame for its own stitch /
18
+ * worklet paths — we reuse it here, no extra acquire). Layout:
19
+ * - bytes `[0 .. width*height)` = Y plane (luminance), dense,
20
+ * row stride = [width]
21
+ * - bytes `[width*height .. width*height*3/2)` = interleaved V-U chroma
22
+ * [yPlane] is a convenience read-only window onto just the Y plane.
23
+ *
24
+ * ## Lifetime — COPY BEFORE OFFLOADING
25
+ *
26
+ * [nv21] / [yPlane] / [depthBytes] are the SDK's own arrays, reused on the
27
+ * next frame. They are valid ONLY for the duration of the synchronous
28
+ * [ARFramePlugin.process] call. A plugin that hands bytes to another
29
+ * thread (async OCR, network upload, etc.) **MUST copy** them first
30
+ * (`bytes.copyOf()`), or it will read torn/overwritten data on the next AR
31
+ * frame.
32
+ *
33
+ * @property nv21 Full NV21 camera image (Y plane then interleaved VU).
34
+ * @property width Camera image width (px).
35
+ * @property height Camera image height (px).
36
+ * @property timestampNs ARCore frame timestamp (nanoseconds).
37
+ * @property fx Focal length x (px, at capture resolution).
38
+ * @property fy Focal length y (px).
39
+ * @property cx Principal point x (px).
40
+ * @property cy Principal point y (px).
41
+ * @property imageWidth Intrinsics reference image width (px).
42
+ * @property imageHeight Intrinsics reference image height (px).
43
+ * @property poseRotation Camera pose rotation quaternion `[x, y, z, w]`.
44
+ * @property poseTranslation Camera pose translation `[x, y, z]` (metres, world).
45
+ * @property trackingState Contract enum string: "normal" | "limited" | "notAvailable".
46
+ * @property depthBytes Row-packed DEPTH16 (uint16/px, w*h*2 bytes) or null
47
+ * (null unless `enableDepth` AND depth available this frame).
48
+ * @property depthWidth Depth map width (px), 0 when [depthBytes] is null.
49
+ * @property depthHeight Depth map height (px), 0 when [depthBytes] is null.
50
+ * @property anchors Anchor descriptor maps already collected for the
51
+ * `onArFrame` event (empty unless `enableAnchors`).
52
+ * Each map: { id, type, transform[16 row-major],
53
+ * alignment?, extent? } — same shape the JS
54
+ * `ARAnchor` contract uses.
55
+ */
56
+ class ARFrameContext(
57
+ @JvmField val nv21: ByteArray,
58
+ @JvmField val width: Int,
59
+ @JvmField val height: Int,
60
+ @JvmField val timestampNs: Double,
61
+ @JvmField val fx: Double,
62
+ @JvmField val fy: Double,
63
+ @JvmField val cx: Double,
64
+ @JvmField val cy: Double,
65
+ @JvmField val imageWidth: Int,
66
+ @JvmField val imageHeight: Int,
67
+ @JvmField val poseRotation: DoubleArray,
68
+ @JvmField val poseTranslation: DoubleArray,
69
+ @JvmField val trackingState: String,
70
+ @JvmField val depthBytes: ByteArray? = null,
71
+ @JvmField val depthWidth: Int = 0,
72
+ @JvmField val depthHeight: Int = 0,
73
+ @JvmField val anchors: List<Map<String, Any?>> = emptyList(),
74
+ ) {
75
+ /**
76
+ * Read-only window onto JUST the Y (luminance) plane of [nv21] — the
77
+ * first `width * height` bytes. Cheap (no copy): a sliced, read-only
78
+ * [java.nio.ByteBuffer] over the same backing array. Convenient for
79
+ * plugins that only need luma (brightness, simple CV gates).
80
+ *
81
+ * Like [nv21], valid only during [ARFramePlugin.process]; copy before
82
+ * offloading.
83
+ */
84
+ val yPlane: java.nio.ByteBuffer
85
+ get() = java.nio.ByteBuffer
86
+ .wrap(nv21, 0, width * height)
87
+ .slice()
88
+ .asReadOnlyBuffer()
89
+ }
@@ -0,0 +1,57 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn
3
+
4
+ import com.facebook.react.bridge.WritableMap
5
+
6
+ /**
7
+ * 0.19.0 — Android AR frame-processor plugin SPI (Swift twin:
8
+ * `ios/Sources/RNImageStitcher/RNISARFramePlugin.swift`).
9
+ *
10
+ * Mirrors vision-camera's `FrameProcessorPlugin` registration ergonomics:
11
+ * the *host app* implements this interface, registers an instance with
12
+ * [RNSARPluginRegistry] at startup, and the SDK invokes [process] for every
13
+ * ARCore frame while the registry is non-empty. The SDK ships ONLY this
14
+ * generic framework — no OCR or any other concrete plugin (the host writes
15
+ * those against this contract).
16
+ *
17
+ * ## Threading & lifetime
18
+ *
19
+ * [process] runs on the **AR (GL render) thread**, synchronously, once per
20
+ * ARCore frame. The [ARFrameContext] handed in is a zero-copy view onto
21
+ * the live frame — its byte buffers (`yPlane` / `nv21` / depth `bytes`) are
22
+ * the SDK's own arrays and are reused on subsequent frames. A plugin that
23
+ * offloads heavy work to another thread **MUST copy** any bytes it needs
24
+ * before returning from [process] (see [ARFrameContext]).
25
+ *
26
+ * ## Sync vs async results
27
+ *
28
+ * - Return a non-null [WritableMap] for a *light, synchronous* result. The
29
+ * SDK folds it into the throttled `onArFrame` `ARFrameMeta` event under
30
+ * `plugins[name]`, so it rides the existing channel for free.
31
+ * - Return `null` and call [RNSARPluginRegistry.emit] later (from the
32
+ * plugin's own queue) to deliver an *async* result over the dedicated
33
+ * `RNImageStitcherARPluginResult` device event.
34
+ *
35
+ * Plugins are responsible for their own throttling / work-offloading — the
36
+ * SDK calls [process] on EVERY AR frame while the registry is non-empty.
37
+ */
38
+ interface ARFramePlugin {
39
+ /**
40
+ * Stable, unique name for this plugin. Used as the key under
41
+ * `ARFrameMeta.plugins` for sync results and as the `plugin` field of
42
+ * the `RNImageStitcherARPluginResult` event for async results. The JS
43
+ * side keys off this string, so keep it stable across app launches.
44
+ */
45
+ fun name(): String
46
+
47
+ /**
48
+ * Process one ARCore frame. Return a light synchronous result map
49
+ * (folded into the `onArFrame` event) or `null` (no sync result — emit
50
+ * later via [RNSARPluginRegistry.emit] if needed).
51
+ *
52
+ * Runs on the AR thread. Do NOT block: self-throttle and offload heavy
53
+ * work. Copy any [ARFrameContext] byte buffers you retain past the
54
+ * call.
55
+ */
56
+ fun process(context: ARFrameContext): WritableMap?
57
+ }
@@ -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
+ }