react-native-image-stitcher 0.19.0 → 0.20.1
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 +52 -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 +301 -3
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +70 -65
- 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
|
@@ -31,17 +31,37 @@
|
|
|
31
31
|
|
|
32
32
|
import Foundation
|
|
33
33
|
import ARKit
|
|
34
|
+
import SceneKit
|
|
34
35
|
import UIKit
|
|
36
|
+
import simd
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
@objc(RNSARCameraView)
|
|
38
|
-
public final class RNSARCameraView: UIView {
|
|
40
|
+
public final class RNSARCameraView: UIView, ARSCNViewDelegate {
|
|
39
41
|
|
|
40
42
|
/// The ARSCNView that does the actual rendering. Bound to the
|
|
41
43
|
/// singleton's ARSession so all preview surfaces share the same
|
|
42
44
|
/// session (and the same pose log that the stitcher consumes).
|
|
43
45
|
private var arSCNView: ARSCNView!
|
|
44
46
|
|
|
47
|
+
/// v0.20.0 — world-anchored overlays backed by REAL `ARAnchor`s. Each
|
|
48
|
+
/// overlay becomes an `ARAnchor` added to the session; ARSCNView creates a
|
|
49
|
+
/// node per anchor (via `renderer(_:nodeFor:)`) and keeps its transform
|
|
50
|
+
/// synced to the anchor every frame — and crucially ARKit *refines* the
|
|
51
|
+
/// anchor as its world understanding improves (drift / re-localization),
|
|
52
|
+
/// so the marker stays glued to the real-world spot across a long session,
|
|
53
|
+
/// not just a short one (which a fixed world coordinate would not survive).
|
|
54
|
+
/// Diffed against the overlay store each render pass (`RNISAROverlay` is
|
|
55
|
+
/// `Equatable`): add new ids, rebuild changed ones, remove gone ones.
|
|
56
|
+
///
|
|
57
|
+
/// Two maps, guarded by `anchorLock` because `nodeFor` may run on a
|
|
58
|
+
/// different (render) thread than the `updateAtTime` diff:
|
|
59
|
+
/// - `overlayAnchors`: overlay id → (its anchor, the overlay it came from)
|
|
60
|
+
/// - `anchorOverlays`: anchor UUID → overlay (so `nodeFor` can build it)
|
|
61
|
+
private var overlayAnchors: [String: (anchor: ARAnchor, overlay: RNISAROverlay)] = [:]
|
|
62
|
+
private var anchorOverlays: [UUID: RNISAROverlay] = [:]
|
|
63
|
+
private let anchorLock = NSLock()
|
|
64
|
+
|
|
45
65
|
public override init(frame: CGRect) {
|
|
46
66
|
super.init(frame: frame)
|
|
47
67
|
setupView()
|
|
@@ -68,6 +88,14 @@ public final class RNSARCameraView: UIView {
|
|
|
68
88
|
// a renderer.
|
|
69
89
|
arSCNView.session = RNSARSession.shared.arSession
|
|
70
90
|
|
|
91
|
+
// v0.20.0 — become the ARSCNView's render delegate so
|
|
92
|
+
// `renderer(_:updateAtTime:)` fires once per render pass (display
|
|
93
|
+
// rate) on the MAIN thread. This is our per-frame overlay redraw
|
|
94
|
+
// hook: cheap (a handful of overlays), already on the main thread,
|
|
95
|
+
// and gives us smooth display-rate tracking without touching the
|
|
96
|
+
// ARSession delegate (which `RNSARSession` owns for pose logging).
|
|
97
|
+
arSCNView.delegate = self
|
|
98
|
+
|
|
71
99
|
// We don't draw any 3D content in Phase 4.4. Disable
|
|
72
100
|
// SceneKit's automatic statistics overlay and lighting model
|
|
73
101
|
// — we just want the camera feed.
|
|
@@ -78,6 +106,39 @@ public final class RNSARCameraView: UIView {
|
|
|
78
106
|
// this view outside ARSCNView's letterboxed sub-rect).
|
|
79
107
|
backgroundColor = .black
|
|
80
108
|
addSubview(arSCNView)
|
|
109
|
+
|
|
110
|
+
// v0.20.0 — overlays render as world-anchored SceneKit nodes inside
|
|
111
|
+
// `arSCNView.scene` (see `syncOverlayNodes`), so there is no separate
|
|
112
|
+
// 2D overlay UIView to add here.
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// MARK: - v0.20.0 — declarative `overlays` prop (KVC from RN)
|
|
116
|
+
|
|
117
|
+
/// Declarative `overlays` prop. RN sets this via KVC
|
|
118
|
+
/// (`RCT_EXPORT_VIEW_PROPERTY(overlays, NSArray)`) whenever the prop
|
|
119
|
+
/// changes; we replace the JS overlay namespace wholesale (declarative
|
|
120
|
+
/// = the full set each render). Forwarded to the global store rather
|
|
121
|
+
/// than held per-view because the overlay world is global to the single
|
|
122
|
+
/// AR session. `@objc` + `NSArray` so KVC can set it.
|
|
123
|
+
@objc public var overlays: NSArray = [] {
|
|
124
|
+
didSet {
|
|
125
|
+
Self.applyJSSetOverlays(overlays)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Parse a JS overlay-dictionary array and replace the JS overlay
|
|
130
|
+
/// namespace in the shared store. Shared by the declarative prop
|
|
131
|
+
/// setter (above) and the imperative `setOverlays` view command (in
|
|
132
|
+
/// `RNSARCameraViewManager`).
|
|
133
|
+
static func applyJSSetOverlays(_ overlays: NSArray) {
|
|
134
|
+
var parsed: [RNISAROverlay] = []
|
|
135
|
+
parsed.reserveCapacity(overlays.count)
|
|
136
|
+
for item in overlays {
|
|
137
|
+
guard let dict = item as? [String: Any],
|
|
138
|
+
let o = RNISAROverlay.from(dictionary: dict) else { continue }
|
|
139
|
+
parsed.append(o)
|
|
140
|
+
}
|
|
141
|
+
RNISAROverlayStore.shared.setJSOverlays(parsed)
|
|
81
142
|
}
|
|
82
143
|
|
|
83
144
|
public override func layoutSubviews() {
|
|
@@ -96,7 +157,12 @@ public final class RNSARCameraView: UIView {
|
|
|
96
157
|
// ARSCNView fills a same-AR sub-rect, there is nothing to crop
|
|
97
158
|
// and the user sees the full captured scene. The parent view's
|
|
98
159
|
// black background fills the remainder.
|
|
99
|
-
|
|
160
|
+
let lb = letterboxedFrame()
|
|
161
|
+
arSCNView.frame = lb
|
|
162
|
+
// Overlay nodes live in `arSCNView.scene` and are rendered by the
|
|
163
|
+
// same (letterboxed) ARSCNView against the live AR camera, so they
|
|
164
|
+
// need no separate framing — they align with the camera feed
|
|
165
|
+
// automatically.
|
|
100
166
|
}
|
|
101
167
|
|
|
102
168
|
/// Returns the largest `CGRect` inside `bounds` that matches the
|
|
@@ -184,6 +250,238 @@ public final class RNSARCameraView: UIView {
|
|
|
184
250
|
}
|
|
185
251
|
}
|
|
186
252
|
}
|
|
187
|
-
}
|
|
188
253
|
|
|
254
|
+
// MARK: - ARSCNViewDelegate (v0.20.0 overlay redraw hook)
|
|
255
|
+
|
|
256
|
+
/// Called once per ARSCNView render pass. Keeps the set of overlay
|
|
257
|
+
/// ARAnchors in sync with the overlay store (add / rebuild / remove).
|
|
258
|
+
/// ARKit then tracks each anchor and ARSCNView positions its node — we do
|
|
259
|
+
/// NOT project or position anything by hand. Cheap: an `==` diff over a
|
|
260
|
+
/// handful of overlays.
|
|
261
|
+
///
|
|
262
|
+
/// We deliberately do NOT touch `RNSARSession`'s pose log / plugin /
|
|
263
|
+
/// onArFrame paths — those ride the ARSession delegate which the singleton
|
|
264
|
+
/// owns. This delegate is purely the overlay-sync hook.
|
|
265
|
+
public func renderer(
|
|
266
|
+
_ renderer: SCNSceneRenderer,
|
|
267
|
+
updateAtTime time: TimeInterval
|
|
268
|
+
) {
|
|
269
|
+
syncOverlayAnchors()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/// ARSCNViewDelegate: vend the node for one of our overlay anchors. May
|
|
273
|
+
/// run on the render thread; ARSCNView keeps the returned node's transform
|
|
274
|
+
/// synced to the (ARKit-refined) anchor. We build the visual RELATIVE to
|
|
275
|
+
/// the anchor origin — the anchor carries the world position.
|
|
276
|
+
public func renderer(
|
|
277
|
+
_ renderer: SCNSceneRenderer,
|
|
278
|
+
nodeFor anchor: ARAnchor
|
|
279
|
+
) -> SCNNode? {
|
|
280
|
+
anchorLock.lock()
|
|
281
|
+
let overlay = anchorOverlays[anchor.identifier]
|
|
282
|
+
anchorLock.unlock()
|
|
283
|
+
guard let overlay = overlay else { return nil }
|
|
284
|
+
|
|
285
|
+
if let quad = overlay.worldQuad, quad.count >= 3 {
|
|
286
|
+
// Anchor sits at the centroid; draw the loop relative to it.
|
|
287
|
+
var c = simd_float3(0, 0, 0)
|
|
288
|
+
for v in quad { c += v }
|
|
289
|
+
c /= Float(quad.count)
|
|
290
|
+
return Self.makeQuadOutlineNode(
|
|
291
|
+
relCorners: quad.map { $0 - c },
|
|
292
|
+
color: overlay.color, label: overlay.label)
|
|
293
|
+
}
|
|
294
|
+
return Self.makeBillboardNode(
|
|
295
|
+
sizeMeters: overlay.sizeMeters, color: overlay.color,
|
|
296
|
+
label: overlay.label)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/// Diff the overlay store against the live anchors: add an ARAnchor for
|
|
300
|
+
/// each new/changed overlay, remove anchors whose overlay is gone.
|
|
301
|
+
private func syncOverlayAnchors() {
|
|
302
|
+
let session = arSCNView.session
|
|
303
|
+
let overlays = RNISAROverlayStore.shared.snapshot()
|
|
304
|
+
var liveIDs = Set<String>()
|
|
305
|
+
liveIDs.reserveCapacity(overlays.count)
|
|
306
|
+
|
|
307
|
+
for overlay in overlays {
|
|
308
|
+
liveIDs.insert(overlay.id)
|
|
309
|
+
anchorLock.lock()
|
|
310
|
+
let existing = overlayAnchors[overlay.id]
|
|
311
|
+
anchorLock.unlock()
|
|
312
|
+
if let existing = existing, existing.overlay == overlay { continue }
|
|
313
|
+
|
|
314
|
+
// New or changed → drop the old anchor, add a fresh one.
|
|
315
|
+
if let existing = existing {
|
|
316
|
+
session.remove(anchor: existing.anchor)
|
|
317
|
+
anchorLock.lock()
|
|
318
|
+
anchorOverlays[existing.anchor.identifier] = nil
|
|
319
|
+
overlayAnchors[overlay.id] = nil
|
|
320
|
+
anchorLock.unlock()
|
|
321
|
+
}
|
|
322
|
+
guard let pose = Self.anchorPose(for: overlay) else { continue }
|
|
323
|
+
let anchor = ARAnchor(transform: pose)
|
|
324
|
+
anchorLock.lock()
|
|
325
|
+
anchorOverlays[anchor.identifier] = overlay
|
|
326
|
+
overlayAnchors[overlay.id] = (anchor, overlay)
|
|
327
|
+
anchorLock.unlock()
|
|
328
|
+
session.add(anchor: anchor)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Remove anchors whose overlay id is gone (snapshot under the lock,
|
|
332
|
+
// call session.remove outside it).
|
|
333
|
+
anchorLock.lock()
|
|
334
|
+
let goneIDs = overlayAnchors.keys.filter { !liveIDs.contains($0) }
|
|
335
|
+
let goneAnchors = goneIDs.compactMap { overlayAnchors[$0]?.anchor }
|
|
336
|
+
for id in goneIDs {
|
|
337
|
+
if let a = overlayAnchors[id]?.anchor { anchorOverlays[a.identifier] = nil }
|
|
338
|
+
overlayAnchors[id] = nil
|
|
339
|
+
}
|
|
340
|
+
anchorLock.unlock()
|
|
341
|
+
for a in goneAnchors { session.remove(anchor: a) }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// MARK: - v0.20.0 overlay node builders (RELATIVE to the anchor origin)
|
|
345
|
+
|
|
346
|
+
/// World transform for an overlay's anchor: a translation-only pose at the
|
|
347
|
+
/// `worldPosition`, or at the centroid of `worldQuad`. nil if no geometry.
|
|
348
|
+
private static func anchorPose(for overlay: RNISAROverlay) -> simd_float4x4? {
|
|
349
|
+
let p: simd_float3
|
|
350
|
+
if let quad = overlay.worldQuad, quad.count >= 3 {
|
|
351
|
+
var c = simd_float3(0, 0, 0)
|
|
352
|
+
for v in quad { c += v }
|
|
353
|
+
p = c / Float(quad.count)
|
|
354
|
+
} else if let center = overlay.worldPosition {
|
|
355
|
+
p = center
|
|
356
|
+
} else {
|
|
357
|
+
return nil
|
|
358
|
+
}
|
|
359
|
+
var m = matrix_identity_float4x4
|
|
360
|
+
m.columns.3 = simd_float4(p.x, p.y, p.z, 1)
|
|
361
|
+
return m
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/// A camera-facing billboard plane (at the anchor origin), sized in metres,
|
|
365
|
+
/// textured with a stroked outline + optional centred label. Always drawn
|
|
366
|
+
/// on top (depth read/write off) so an annotation is never hidden.
|
|
367
|
+
private static func makeBillboardNode(
|
|
368
|
+
sizeMeters: CGSize?,
|
|
369
|
+
color: UIColor,
|
|
370
|
+
label: String?
|
|
371
|
+
) -> SCNNode {
|
|
372
|
+
let w = sizeMeters?.width ?? RNISAROverlay.defaultMarkerExtent
|
|
373
|
+
let h = sizeMeters?.height ?? RNISAROverlay.defaultMarkerExtent
|
|
374
|
+
let plane = SCNPlane(width: w, height: h)
|
|
375
|
+
let mat = SCNMaterial()
|
|
376
|
+
mat.diffuse.contents = overlayImage(color: color, label: label)
|
|
377
|
+
mat.isDoubleSided = true
|
|
378
|
+
mat.lightingModel = .constant // unlit — show the texture as-is
|
|
379
|
+
mat.writesToDepthBuffer = false
|
|
380
|
+
mat.readsFromDepthBuffer = false // never occluded by the scene
|
|
381
|
+
plane.firstMaterial = mat
|
|
382
|
+
|
|
383
|
+
let node = SCNNode(geometry: plane)
|
|
384
|
+
node.renderingOrder = 1000 // draw after the camera background
|
|
385
|
+
let billboard = SCNBillboardConstraint()
|
|
386
|
+
billboard.freeAxes = .all // always face the camera, flat
|
|
387
|
+
node.constraints = [billboard]
|
|
388
|
+
return node
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/// A THICK 3D outline through corners expressed RELATIVE to the anchor
|
|
392
|
+
/// origin (anchor at the quad centroid). Each edge is a thin cylinder —
|
|
393
|
+
/// SceneKit `.line` primitives are always 1px and unscalable, so a visible
|
|
394
|
+
/// outline must be real geometry. Optional camera-facing label at centre.
|
|
395
|
+
private static func makeQuadOutlineNode(
|
|
396
|
+
relCorners: [simd_float3],
|
|
397
|
+
color: UIColor,
|
|
398
|
+
label: String?
|
|
399
|
+
) -> SCNNode {
|
|
400
|
+
let node = SCNNode()
|
|
401
|
+
node.renderingOrder = 1000
|
|
402
|
+
let n = relCorners.count
|
|
403
|
+
for i in 0..<n {
|
|
404
|
+
if let edge = edgeCylinder(
|
|
405
|
+
from: relCorners[i], to: relCorners[(i + 1) % n], color: color) {
|
|
406
|
+
node.addChildNode(edge)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if let label = label, !label.isEmpty {
|
|
410
|
+
// Label at the centroid (≈ local origin in relative space).
|
|
411
|
+
let labelNode = makeBillboardNode(
|
|
412
|
+
sizeMeters: CGSize(width: 0.12, height: 0.12),
|
|
413
|
+
color: color, label: label)
|
|
414
|
+
node.addChildNode(labelNode)
|
|
415
|
+
}
|
|
416
|
+
return node
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/// A thin cylinder (≈4 mm) spanning two points — one edge of a quad
|
|
420
|
+
/// outline. SCNCylinder's axis is +Y, so we centre it at the midpoint and
|
|
421
|
+
/// rotate +Y onto the edge direction.
|
|
422
|
+
private static func edgeCylinder(
|
|
423
|
+
from a: simd_float3, to b: simd_float3, color: UIColor
|
|
424
|
+
) -> SCNNode? {
|
|
425
|
+
let d = b - a
|
|
426
|
+
let len = simd_length(d)
|
|
427
|
+
guard len > 1e-5 else { return nil }
|
|
428
|
+
let cyl = SCNCylinder(radius: 0.004, height: CGFloat(len))
|
|
429
|
+
let mat = SCNMaterial()
|
|
430
|
+
mat.diffuse.contents = color
|
|
431
|
+
mat.lightingModel = .constant
|
|
432
|
+
mat.writesToDepthBuffer = false
|
|
433
|
+
mat.readsFromDepthBuffer = false
|
|
434
|
+
cyl.firstMaterial = mat
|
|
435
|
+
let node = SCNNode(geometry: cyl)
|
|
436
|
+
node.renderingOrder = 1000
|
|
437
|
+
node.simdPosition = (a + b) * 0.5
|
|
438
|
+
let yAxis = simd_float3(0, 1, 0)
|
|
439
|
+
let dir = d / len
|
|
440
|
+
let dot = simd_dot(yAxis, dir)
|
|
441
|
+
if dot < -0.9999 {
|
|
442
|
+
node.simdOrientation = simd_quatf(angle: .pi, axis: simd_float3(1, 0, 0))
|
|
443
|
+
} else if dot < 0.9999 {
|
|
444
|
+
let axis = simd_normalize(simd_cross(yAxis, dir))
|
|
445
|
+
node.simdOrientation = simd_quatf(angle: acos(dot), axis: axis)
|
|
446
|
+
}
|
|
447
|
+
return node
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/// Render a stroked rounded-rect outline + optional centred label chip to
|
|
451
|
+
/// a square image, used as the billboard plane's texture. Transparent
|
|
452
|
+
/// background so only the outline + chip show over the camera feed.
|
|
453
|
+
private static func overlayImage(color: UIColor, label: String?) -> UIImage {
|
|
454
|
+
let px: CGFloat = 512
|
|
455
|
+
let renderer = UIGraphicsImageRenderer(size: CGSize(width: px, height: px))
|
|
456
|
+
return renderer.image { ctx in
|
|
457
|
+
let cg = ctx.cgContext
|
|
458
|
+
let inset = px * 0.07
|
|
459
|
+
let rect = CGRect(x: inset, y: inset,
|
|
460
|
+
width: px - 2 * inset, height: px - 2 * inset)
|
|
461
|
+
let path = UIBezierPath(roundedRect: rect, cornerRadius: px * 0.05)
|
|
462
|
+
cg.setStrokeColor(color.cgColor)
|
|
463
|
+
cg.setLineWidth(px * 0.03)
|
|
464
|
+
path.stroke()
|
|
465
|
+
|
|
466
|
+
guard let label = label, !label.isEmpty else { return }
|
|
467
|
+
let fontSize = px * 0.13
|
|
468
|
+
let font = UIFont.systemFont(ofSize: fontSize, weight: .bold)
|
|
469
|
+
let attrs: [NSAttributedString.Key: Any] = [
|
|
470
|
+
.font: font, .foregroundColor: UIColor.white,
|
|
471
|
+
]
|
|
472
|
+
let ts = (label as NSString).size(withAttributes: attrs)
|
|
473
|
+
let pad = fontSize * 0.35
|
|
474
|
+
let chip = CGRect(x: (px - ts.width) / 2 - pad,
|
|
475
|
+
y: (px - ts.height) / 2 - pad,
|
|
476
|
+
width: ts.width + 2 * pad,
|
|
477
|
+
height: ts.height + 2 * pad)
|
|
478
|
+
color.withAlphaComponent(0.9).setFill()
|
|
479
|
+
UIBezierPath(roundedRect: chip, cornerRadius: pad).fill()
|
|
480
|
+
(label as NSString).draw(
|
|
481
|
+
at: CGPoint(x: (px - ts.width) / 2, y: (px - ts.height) / 2),
|
|
482
|
+
withAttributes: attrs)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
}
|
|
189
487
|
|
|
@@ -1103,54 +1103,67 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
|
|
|
1103
1103
|
} else {
|
|
1104
1104
|
resolvedPath = rawPath
|
|
1105
1105
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1106
|
+
// Capture a HIGH-RESOLUTION still. ARKit's live `capturedImage` is the
|
|
1107
|
+
// small AR video format (and the SDK picks the SMALLEST 4:3 format), far
|
|
1108
|
+
// too low-res for document OCR / detail capture.
|
|
1109
|
+
// `captureHighResolutionFrame` (iOS 16+) grabs a full-resolution photo
|
|
1110
|
+
// WITHOUT leaving the AR session. Fall back to the live frame on older
|
|
1111
|
+
// OS or if the high-res grab fails.
|
|
1112
|
+
let encode: (CVPixelBuffer) -> Void = { [weak self] pixelBuffer in
|
|
1113
|
+
self?.encodeArPhoto(
|
|
1114
|
+
pixelBuffer: pixelBuffer,
|
|
1115
|
+
toPath: resolvedPath,
|
|
1116
|
+
quality: quality,
|
|
1117
|
+
orientation: orientation,
|
|
1118
|
+
completion: completion
|
|
1119
|
+
)
|
|
1114
1120
|
}
|
|
1115
|
-
|
|
1121
|
+
if #available(iOS 16.0, *) {
|
|
1122
|
+
arSession.captureHighResolutionFrame { [weak self] hiResFrame, error in
|
|
1123
|
+
if let hiResFrame = hiResFrame {
|
|
1124
|
+
encode(hiResFrame.capturedImage)
|
|
1125
|
+
} else if let live = self?.arSession.currentFrame {
|
|
1126
|
+
encode(live.capturedImage)
|
|
1127
|
+
} else {
|
|
1128
|
+
completion(nil, NSError(
|
|
1129
|
+
domain: "RNImageStitcherARCapture",
|
|
1130
|
+
code: 2001,
|
|
1131
|
+
userInfo: [NSLocalizedDescriptionKey:
|
|
1132
|
+
"AR high-res capture failed: \(error?.localizedDescription ?? "no current frame")."]
|
|
1133
|
+
))
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
} else {
|
|
1137
|
+
guard let frame = arSession.currentFrame else {
|
|
1138
|
+
completion(nil, NSError(
|
|
1139
|
+
domain: "RNImageStitcherARCapture",
|
|
1140
|
+
code: 2001,
|
|
1141
|
+
userInfo: [NSLocalizedDescriptionKey:
|
|
1142
|
+
"AR session has no current frame — start the session first."]
|
|
1143
|
+
))
|
|
1144
|
+
return
|
|
1145
|
+
}
|
|
1146
|
+
encode(frame.capturedImage)
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1116
1149
|
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
// `UIDeviceOrientation` convention (home indicator on user-
|
|
1135
|
-
// right) while iOS's sensor-native orientation matches that
|
|
1136
|
-
// tilt direction directly. Without this fix, AR-mode single
|
|
1137
|
-
// photos in landscape come out upside-down.
|
|
1138
|
-
// v0.12.0 — Pre-v0.12 this method hardcoded `.right` (90° CW)
|
|
1139
|
-
// to rotate-to-portrait, assuming the user always held the
|
|
1140
|
-
// phone in portrait. Under R2-lite the device can be in
|
|
1141
|
-
// any orientation, so we pick the CIImage orientation per
|
|
1142
|
-
// the JS-supplied `orientation` arg (from
|
|
1143
|
-
// `useDeviceOrientation()`).
|
|
1144
|
-
//
|
|
1145
|
-
// Empirical mapping (on-device test 2026-05-28):
|
|
1146
|
-
// portrait → .right (90° CW — preserved from pre-v0.12)
|
|
1147
|
-
// landscape-left → .up (sensor matches device tilt; no rotation)
|
|
1148
|
-
// landscape-right → .down (180° — sensor opposite of device tilt)
|
|
1149
|
-
// portrait-upside-down → .left (90° CCW)
|
|
1150
|
-
//
|
|
1151
|
-
// The landscape mapping (landscape-left → .up) was determined
|
|
1152
|
-
// empirically; the user reported AR landscape photos came out
|
|
1153
|
-
// upside-down with .down and correctly upright with .up.
|
|
1150
|
+
/// Encode an AR pixel buffer → an oriented, FULL-RESOLUTION JPEG at `path`.
|
|
1151
|
+
/// Unlike stitch keyframes, a user / document photo is NOT downscaled — OCR
|
|
1152
|
+
/// and detail capture need the full resolution that
|
|
1153
|
+
/// `captureHighResolutionFrame` provides.
|
|
1154
|
+
///
|
|
1155
|
+
/// Orientation maps the JS `useDeviceOrientation()` value → CIImage
|
|
1156
|
+
/// orientation (empirical, on-device 2026-05-28): portrait → .right,
|
|
1157
|
+
/// landscape-left → .up, landscape-right → .down,
|
|
1158
|
+
/// portrait-upside-down → .left. Without this, AR-mode landscape photos
|
|
1159
|
+
/// come out upside-down.
|
|
1160
|
+
private func encodeArPhoto(
|
|
1161
|
+
pixelBuffer: CVPixelBuffer,
|
|
1162
|
+
toPath resolvedPath: String,
|
|
1163
|
+
quality: Int,
|
|
1164
|
+
orientation: String,
|
|
1165
|
+
completion: @escaping ([String: Any]?, NSError?) -> Void
|
|
1166
|
+
) {
|
|
1154
1167
|
let exifOrientation: CGImagePropertyOrientation
|
|
1155
1168
|
switch orientation {
|
|
1156
1169
|
case "landscape-left": exifOrientation = .up
|
|
@@ -1158,21 +1171,9 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
|
|
|
1158
1171
|
case "portrait-upside-down": exifOrientation = .left
|
|
1159
1172
|
default: exifOrientation = .right // portrait + unknown
|
|
1160
1173
|
}
|
|
1161
|
-
|
|
1162
|
-
.oriented(exifOrientation)
|
|
1163
|
-
// AR keyframe downscale guard — normalise long edge to the budget so
|
|
1164
|
-
// every device produces a ~0.3 MP keyframe (cross-device-consistent
|
|
1165
|
-
// stitch memory). Mirrors Android's downscale in YuvImageConverter.
|
|
1166
|
-
let kfLongEdge = max(ciImage.extent.width, ciImage.extent.height)
|
|
1167
|
-
if kfLongEdge > Self.arKeyframeMaxLongEdge {
|
|
1168
|
-
let kfScale = Self.arKeyframeMaxLongEdge / kfLongEdge
|
|
1169
|
-
ciImage = ciImage.transformed(by: CGAffineTransform(scaleX: kfScale, y: kfScale))
|
|
1170
|
-
}
|
|
1174
|
+
let ciImage = CIImage(cvPixelBuffer: pixelBuffer).oriented(exifOrientation)
|
|
1171
1175
|
let context = CIContext(options: nil)
|
|
1172
|
-
guard let cgImage = context.createCGImage(
|
|
1173
|
-
ciImage,
|
|
1174
|
-
from: ciImage.extent
|
|
1175
|
-
) else {
|
|
1176
|
+
guard let cgImage = context.createCGImage(ciImage, from: ciImage.extent) else {
|
|
1176
1177
|
completion(nil, NSError(
|
|
1177
1178
|
domain: "RNImageStitcherARCapture",
|
|
1178
1179
|
code: 2002,
|
|
@@ -1194,7 +1195,6 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
|
|
|
1194
1195
|
))
|
|
1195
1196
|
return
|
|
1196
1197
|
}
|
|
1197
|
-
|
|
1198
1198
|
let cleanedPath = Self.normalisePath(resolvedPath)
|
|
1199
1199
|
let url = URL(fileURLWithPath: cleanedPath)
|
|
1200
1200
|
// Best-effort delete an existing file at the same path —
|
|
@@ -1574,8 +1574,13 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
|
|
|
1574
1574
|
// simd_quatf from a 4x4 matrix uses the rotational part.
|
|
1575
1575
|
let q = simd_quatf(t)
|
|
1576
1576
|
|
|
1577
|
-
// Camera intrinsics.
|
|
1578
|
-
//
|
|
1577
|
+
// Camera intrinsics. `simd_float3x3` subscripts as k[column][row]
|
|
1578
|
+
// (COLUMN-MAJOR). ARKit's K is:
|
|
1579
|
+
// column 0 = (fx, 0, 0), column 1 = (0, fy, 0), column 2 = (cx, cy, 1)
|
|
1580
|
+
// so fx = k[0][0], fy = k[1][1], cx = k[2][0], cy = k[2][1].
|
|
1581
|
+
// (Pre-0.20.1 bug: read cx/cy as k[0][2]/k[1][2] = 0 — fx/fy survived
|
|
1582
|
+
// because they're on the diagonal, so the principal point came through
|
|
1583
|
+
// as 0,0 and broke any pixel↔world unprojection.)
|
|
1579
1584
|
let k = frame.camera.intrinsics
|
|
1580
1585
|
let imageRes = frame.camera.imageResolution
|
|
1581
1586
|
|
|
@@ -1596,8 +1601,8 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
|
|
|
1596
1601
|
qw: Double(q.real),
|
|
1597
1602
|
fx: Double(k[0][0]),
|
|
1598
1603
|
fy: Double(k[1][1]),
|
|
1599
|
-
cx: Double(k[
|
|
1600
|
-
cy: Double(k[
|
|
1604
|
+
cx: Double(k[2][0]),
|
|
1605
|
+
cy: Double(k[2][1]),
|
|
1601
1606
|
imageWidth: Int(imageRes.width),
|
|
1602
1607
|
imageHeight: Int(imageRes.height),
|
|
1603
1608
|
timestampMs: frame.timestamp * 1000.0,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-image-stitcher",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -45,6 +45,11 @@ import {
|
|
|
45
45
|
import { ensureStitcherProxyInstalled } from '../stitching/ensureStitcherProxyInstalled';
|
|
46
46
|
import type { CameraFrameProcessor } from '../stitching/CameraFrame';
|
|
47
47
|
import type { ARFrameMeta, ARPluginResult } from '../stitching/ARFrameMeta';
|
|
48
|
+
import type { AROverlay } from '../stitching/AROverlay';
|
|
49
|
+
import {
|
|
50
|
+
createAROverlayController,
|
|
51
|
+
type AROverlayMethods,
|
|
52
|
+
} from './arOverlayController';
|
|
48
53
|
|
|
49
54
|
|
|
50
55
|
// React Native looks up the component by its NATIVE name.
|
|
@@ -167,6 +172,32 @@ export interface ARCameraViewProps {
|
|
|
167
172
|
* present; cleanup on unmount / when the handler is removed).
|
|
168
173
|
*/
|
|
169
174
|
onArPluginResult?: (e: ARPluginResult) => void;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* v0.20.0 — AR OVERLAY / ANNOTATION renderer. A declarative array of 2D
|
|
178
|
+
* shapes the native overlay layer draws ON TOP of the AR camera preview,
|
|
179
|
+
* each anchored to WORLD positions and REPROJECTED to screen on every AR
|
|
180
|
+
* frame from the current camera pose + intrinsics (smooth, display-rate
|
|
181
|
+
* tracking; no 3D engine).
|
|
182
|
+
*
|
|
183
|
+
* State-driven: pass a React-state array and update it as your world points
|
|
184
|
+
* change. The set is diffed against the current overlays BY `id` (add /
|
|
185
|
+
* update / remove), so re-passing the same ids is cheap. Each render pushes
|
|
186
|
+
* the resolved array to native via `RNSARSession.setOverlays`.
|
|
187
|
+
*
|
|
188
|
+
* For zero-render-latency / fire-and-forget mutations use the imperative ref
|
|
189
|
+
* methods instead ({@link ARCameraViewHandle.setOverlays} etc.) — both paths
|
|
190
|
+
* funnel through the same native channel and stay consistent. JS-set
|
|
191
|
+
* overlays are merged on the native side with any overlays a registered AR
|
|
192
|
+
* plugin placed directly (`RNISARPluginRegistry.setOverlays` /
|
|
193
|
+
* `RNSARPluginRegistry.setOverlays`); the two sets are namespaced so neither
|
|
194
|
+
* clobbers the other.
|
|
195
|
+
*
|
|
196
|
+
* See {@link AROverlay} for the shape (single world point + size, or explicit
|
|
197
|
+
* world quad; `outline` / `box`; optional label + colour; `mode:'3d'` is a
|
|
198
|
+
* documented scaffold this release and renders as `'2d'`).
|
|
199
|
+
*/
|
|
200
|
+
overlays?: AROverlay[];
|
|
170
201
|
}
|
|
171
202
|
|
|
172
203
|
|
|
@@ -180,8 +211,13 @@ export interface ARCameraViewProps {
|
|
|
180
211
|
* Note we do NOT exhaustively mirror vision-camera's API surface —
|
|
181
212
|
* only the methods the panorama capture flow uses today. As the
|
|
182
213
|
* SDK grows AR-aware features, methods are added here.
|
|
214
|
+
*
|
|
215
|
+
* v0.20.0 — also exposes the imperative AR-overlay methods
|
|
216
|
+
* ({@link AROverlayMethods}: `setOverlays` / `addOverlay` / `updateOverlay` /
|
|
217
|
+
* `removeOverlay` / `clearOverlays`) so a host can drive overlays without a
|
|
218
|
+
* render (the declarative `overlays` prop is the React-state alternative).
|
|
183
219
|
*/
|
|
184
|
-
export interface ARCameraViewHandle {
|
|
220
|
+
export interface ARCameraViewHandle extends AROverlayMethods {
|
|
185
221
|
/**
|
|
186
222
|
* Capture the latest ARFrame as a JPEG. Resolves with a
|
|
187
223
|
* vision-camera-compatible PhotoFile (`{ path, width, height,
|
|
@@ -260,6 +296,7 @@ export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
|
|
|
260
296
|
onArFrame,
|
|
261
297
|
arFrameMetaInterval,
|
|
262
298
|
onArPluginResult,
|
|
299
|
+
overlays,
|
|
263
300
|
},
|
|
264
301
|
ref,
|
|
265
302
|
): React.JSX.Element {
|
|
@@ -268,6 +305,19 @@ export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
|
|
|
268
305
|
// pair vision-camera uses.
|
|
269
306
|
const recordingCallbacksRef = useRef<RecordingCallbacks | null>(null);
|
|
270
307
|
|
|
308
|
+
// v0.20.0 — AR overlay controller (shared logic with <Camera>). One
|
|
309
|
+
// instance per mount holds the JS-set overlay collection (keyed by id) and
|
|
310
|
+
// pushes the full array to native on every mutation. Both the declarative
|
|
311
|
+
// `overlays` prop (effect below) and the imperative ref methods drive it,
|
|
312
|
+
// so the two APIs can never diverge.
|
|
313
|
+
const overlayControllerRef = useRef<
|
|
314
|
+
ReturnType<typeof createAROverlayController> | null
|
|
315
|
+
>(null);
|
|
316
|
+
if (overlayControllerRef.current == null) {
|
|
317
|
+
overlayControllerRef.current = createAROverlayController();
|
|
318
|
+
}
|
|
319
|
+
const overlayController = overlayControllerRef.current;
|
|
320
|
+
|
|
271
321
|
// AR frame-processor registration. Installs the native
|
|
272
322
|
// `__stitcherProxy` (idempotent) and registers the host worklet so
|
|
273
323
|
// the AR session's per-frame fan-out invokes it; unregisters on
|
|
@@ -427,7 +477,28 @@ export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
|
|
|
427
477
|
};
|
|
428
478
|
}, [arPluginResultEnabled]);
|
|
429
479
|
|
|
480
|
+
// v0.20.0 — declarative `overlays` prop → native. Each render pushes the
|
|
481
|
+
// resolved array through the controller (which replaces the JS-set
|
|
482
|
+
// collection wholesale and dispatches to `RNSARSession.setOverlays`). The
|
|
483
|
+
// controller dedups identical native dispatches at the wire level is NOT
|
|
484
|
+
// attempted here — React only re-runs this when `overlays` identity
|
|
485
|
+
// changes, and native overlay set is cheap (a handful of shapes). When the
|
|
486
|
+
// prop is omitted we DON'T touch the controller, so a host driving overlays
|
|
487
|
+
// purely imperatively (via the ref) isn't clobbered by an undefined prop.
|
|
488
|
+
useEffect(() => {
|
|
489
|
+
if (overlays == null) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
overlayController.setOverlays(overlays);
|
|
493
|
+
}, [overlays, overlayController]);
|
|
494
|
+
|
|
430
495
|
useImperativeHandle(ref, () => ({
|
|
496
|
+
setOverlays: overlayController.setOverlays,
|
|
497
|
+
addOverlay: overlayController.addOverlay,
|
|
498
|
+
updateOverlay: overlayController.updateOverlay,
|
|
499
|
+
removeOverlay: overlayController.removeOverlay,
|
|
500
|
+
clearOverlays: overlayController.clearOverlays,
|
|
501
|
+
raycast: overlayController.raycast,
|
|
431
502
|
takePhoto: async (options = {}) => {
|
|
432
503
|
const native: any =
|
|
433
504
|
(NativeModules as Record<string, unknown>).RNSARSession;
|
|
@@ -479,7 +550,7 @@ export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
|
|
|
479
550
|
callbacks.onRecordingError?.(err as Error);
|
|
480
551
|
}
|
|
481
552
|
},
|
|
482
|
-
}), []);
|
|
553
|
+
}), [overlayController]);
|
|
483
554
|
|
|
484
555
|
if (!NativeARCameraView
|
|
485
556
|
|| (Platform.OS !== 'ios' && Platform.OS !== 'android')) {
|