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.
- package/CHANGELOG.md +32 -0
- package/android/src/main/java/io/imagestitcher/rn/AROverlayRenderer.kt +406 -0
- package/android/src/main/java/io/imagestitcher/rn/AROverlayStore.kt +441 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +290 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +30 -5
- package/android/src/main/java/io/imagestitcher/rn/RNSARPluginRegistry.kt +68 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +99 -0
- package/dist/camera/ARCameraView.d.ts +33 -1
- package/dist/camera/ARCameraView.js +33 -2
- package/dist/camera/Camera.d.ts +45 -1
- package/dist/camera/Camera.js +24 -6
- package/dist/camera/arOverlayController.d.ts +52 -0
- package/dist/camera/arOverlayController.js +132 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +5 -2
- package/dist/stitching/AROverlay.d.ts +97 -0
- package/dist/stitching/AROverlay.js +4 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +15 -8
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +22 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.m +14 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +81 -0
- package/ios/Sources/RNImageStitcher/RNISARFramePlugin.swift +37 -0
- package/ios/Sources/RNImageStitcher/RNISAROverlay.swift +409 -0
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +281 -3
- package/package.json +1 -1
- package/src/camera/ARCameraView.tsx +73 -2
- package/src/camera/Camera.tsx +71 -2
- package/src/camera/arOverlayController.ts +184 -0
- package/src/index.ts +15 -0
- 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
|
+
}
|