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.
Files changed (31) hide show
  1. package/CHANGELOG.md +52 -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 +301 -3
  25. package/ios/Sources/RNImageStitcher/RNSARSession.swift +70 -65
  26. package/package.json +1 -1
  27. package/src/camera/ARCameraView.tsx +73 -2
  28. package/src/camera/Camera.tsx +71 -2
  29. package/src/camera/arOverlayController.ts +184 -0
  30. package/src/index.ts +15 -0
  31. 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
- arSCNView.frame = letterboxedFrame()
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
- guard let frame = arSession.currentFrame else {
1107
- completion(nil, NSError(
1108
- domain: "RNImageStitcherARCapture",
1109
- code: 2001,
1110
- userInfo: [NSLocalizedDescriptionKey:
1111
- "AR session has no current frame start the session first."]
1112
- ))
1113
- return
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
- let pixelBuffer = frame.capturedImage
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
- // v0.12.0 Pre-v0.12 this method hardcoded `.right` (90° CW)
1118
- // to rotate-to-portrait, assuming the user always held the
1119
- // phone in portrait. Under R2-lite the device can be in
1120
- // any orientation, so we pick the CIImage orientation per
1121
- // the JS-supplied `orientation` arg (from
1122
- // `useDeviceOrientation()`).
1123
- //
1124
- // Empirical mapping (on-device test 2026-05-28):
1125
- // portrait → .right (90° CW preserved from pre-v0.12)
1126
- // landscape-left → .up (sensor matches device tilt; no rotation)
1127
- // landscape-right → .down (180° — sensor opposite of device tilt)
1128
- // portrait-upside-down → .left (90° CCW)
1129
- //
1130
- // The landscape mapping (landscape-left → .up) was determined
1131
- // empirically and is the opposite of what Apple's ARKit
1132
- // pixel-buffer-orientation docs would imply. Likely because
1133
- // `useDeviceOrientation()` reports `landscape-left` via the
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
- var ciImage = CIImage(cvPixelBuffer: pixelBuffer)
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. Apple gives us a 3x3 matrix where
1578
- // [0][0] = fx, [1][1] = fy, [0][2] = cx, [1][2] = cy.
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[0][2]),
1600
- cy: Double(k[1][2]),
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.19.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')) {