react-native-image-stitcher 0.17.0 → 0.19.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 +151 -0
- package/RNImageStitcher.podspec +1 -1
- package/android/src/main/cpp/CMakeLists.txt +4 -4
- package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +216 -7
- package/android/src/main/java/io/imagestitcher/rn/ARFrameContext.kt +89 -0
- package/android/src/main/java/io/imagestitcher/rn/ARFramePlugin.kt +57 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +831 -6
- package/android/src/main/java/io/imagestitcher/rn/RNSARPluginRegistry.kt +109 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +184 -0
- package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +84 -2
- package/cpp/{stitcher_frame_data.hpp → camera_frame_data.hpp} +96 -13
- package/cpp/{stitcher_frame_jsi.cpp → camera_frame_jsi.cpp} +154 -11
- package/cpp/{stitcher_frame_jsi.hpp → camera_frame_jsi.hpp} +12 -12
- package/cpp/stitcher_proxy_jsi.cpp +31 -0
- package/cpp/stitcher_proxy_jsi.hpp +16 -0
- package/cpp/stitcher_worklet_dispatch.cpp +5 -5
- package/cpp/stitcher_worklet_dispatch.hpp +5 -5
- package/dist/camera/ARCameraView.d.ts +81 -3
- package/dist/camera/ARCameraView.js +103 -1
- package/dist/camera/Camera.d.ts +73 -7
- package/dist/camera/Camera.js +2 -2
- package/dist/index.d.ts +3 -1
- package/dist/stitching/ARFrameMeta.d.ts +149 -0
- package/dist/stitching/{StitcherFrame.js → ARFrameMeta.js} +1 -1
- package/dist/stitching/{StitcherFrame.d.ts → CameraFrame.d.ts} +70 -11
- package/dist/stitching/CameraFrame.js +4 -0
- package/dist/stitching/useStitcherWorklet.d.ts +4 -4
- package/dist/stitching/useStitcherWorklet.js +4 -4
- package/ios/Sources/RNImageStitcher/ARSessionBridge.m +23 -1
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +172 -2
- package/ios/Sources/RNImageStitcher/CameraFrameHostObject.h +108 -0
- package/ios/Sources/RNImageStitcher/CameraFrameHostObject.mm +772 -0
- package/ios/Sources/RNImageStitcher/RNISARFramePlugin.swift +247 -0
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +418 -34
- package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +2 -2
- package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +4 -4
- package/package.json +1 -1
- package/src/camera/ARCameraView.tsx +230 -5
- package/src/camera/Camera.tsx +91 -7
- package/src/index.ts +12 -3
- package/src/stitching/ARFrameMeta.ts +157 -0
- package/src/stitching/{StitcherFrame.ts → CameraFrame.ts} +79 -11
- package/src/stitching/useStitcherWorklet.ts +9 -9
- package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
- package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +0 -214
|
@@ -18,6 +18,8 @@ import com.google.ar.core.exceptions.CameraNotAvailableException
|
|
|
18
18
|
import com.google.ar.core.exceptions.SessionPausedException
|
|
19
19
|
import io.imagestitcher.rn.ar.BackgroundRenderer
|
|
20
20
|
import io.imagestitcher.rn.ar.YuvImageConverter
|
|
21
|
+
import java.nio.ByteBuffer
|
|
22
|
+
import java.nio.ByteOrder
|
|
21
23
|
import java.util.concurrent.atomic.AtomicReference
|
|
22
24
|
import javax.microedition.khronos.egl.EGLConfig
|
|
23
25
|
import javax.microedition.khronos.opengles.GL10
|
|
@@ -400,16 +402,42 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
400
402
|
// OR when an AR frame-processor host worklet is registered (the
|
|
401
403
|
// v0.8.0 Phase 4b.iii fan-out forwards preview frames whenever
|
|
402
404
|
// host worklets exist, even with capture off — the host worklet
|
|
403
|
-
// observes the live AR stream)
|
|
405
|
+
// observes the live AR stream), OR when a native AR plugin is
|
|
406
|
+
// registered (0.19.0 — `forwardToIncremental` builds the
|
|
407
|
+
// ARFrameContext + runs the plugins; their SYNC results are stashed
|
|
408
|
+
// for the onArFrame meta below). `forwardToIncremental` does the
|
|
404
409
|
// NV21 pack once and gates the first-party ingest internally on
|
|
405
|
-
// `ingestActive`; the host-worklet dispatch is gated on the
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
// preview path stays
|
|
409
|
-
|
|
410
|
+
// `ingestActive`; the host-worklet dispatch is gated on the native
|
|
411
|
+
// worklet registry count; the plugin invocation on the plugin
|
|
412
|
+
// registry. All three checks are cheap atomic reads so the common
|
|
413
|
+
// idle preview path (no capture, no worklet, no plugin) stays
|
|
414
|
+
// near-free.
|
|
415
|
+
//
|
|
416
|
+
// ORDER (0.19.0): run forwardToIncremental BEFORE maybeEmitArFrameMeta
|
|
417
|
+
// so the native-plugin SYNC results computed in the former are
|
|
418
|
+
// available to fold into the onArFrame `plugins` field built in the
|
|
419
|
+
// latter (same render frame).
|
|
420
|
+
if (ingestActive ||
|
|
421
|
+
StitcherWorkletRuntime.hasHostWorklets() ||
|
|
422
|
+
!RNSARPluginRegistry.isEmpty()
|
|
423
|
+
) {
|
|
410
424
|
forwardToIncremental(frame, camera)
|
|
425
|
+
} else {
|
|
426
|
+
// No consumer this frame — make sure last frame's stashed plugin
|
|
427
|
+
// sync results don't leak into a later onArFrame meta.
|
|
428
|
+
lastPluginSyncResults = null
|
|
411
429
|
}
|
|
412
430
|
|
|
431
|
+
// onArFrame (v0.18.0) — LIGHT AR-metadata event channel. Built
|
|
432
|
+
// + emitted INDEPENDENTLY of the stitcher ingest / host-worklet
|
|
433
|
+
// fan-out above: a host that only wants per-frame AR metadata
|
|
434
|
+
// (no capture, no worklet) still gets it. Gated + throttled
|
|
435
|
+
// internally; near-free (one volatile read + one nanoTime
|
|
436
|
+
// compare) when disabled or inside the throttle window. Native-
|
|
437
|
+
// plugin SYNC results (0.19.0) stashed by forwardToIncremental
|
|
438
|
+
// above ride along under the meta's `plugins` field.
|
|
439
|
+
maybeEmitArFrameMeta(frame, camera)
|
|
440
|
+
|
|
413
441
|
// takePhoto consumer — runs on EVERY render tick (not just
|
|
414
442
|
// when ingest is active), since the host calls takePhoto in
|
|
415
443
|
// photo mode where ingest is off. No-op when no request is
|
|
@@ -694,6 +722,83 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
694
722
|
TrackingState.STOPPED -> "notAvailable"
|
|
695
723
|
else -> "notAvailable"
|
|
696
724
|
}
|
|
725
|
+
|
|
726
|
+
// ── Opt-in AR-metadata extraction gate ──────────────────────────
|
|
727
|
+
//
|
|
728
|
+
// depth/anchors/mesh are all OFF by default (the JS-driven
|
|
729
|
+
// enableDepth/enableAnchors/enableMesh `<Camera>` props, read via
|
|
730
|
+
// the shared `retailens::getExtractionConfig()` snapshot). Skip
|
|
731
|
+
// the costly ARCore depth-acquire / anchor-collect / mesh-build
|
|
732
|
+
// work for every toggle a host hasn't opted into. A mesh anchor
|
|
733
|
+
// is reconstructed FROM the depth map, so mesh implies acquiring
|
|
734
|
+
// depth even when `depth` (the raw arDepth emission) is off.
|
|
735
|
+
val flags = StitcherWorkletRuntime.extractionFlags()
|
|
736
|
+
|
|
737
|
+
// ── AR depth (ARCore Depth API, DEPTH16) ────────────────────────
|
|
738
|
+
//
|
|
739
|
+
// Acquire the 16-bit depth image for this frame and ROW-PACK it
|
|
740
|
+
// into a contiguous w*h*2 byte array (uint16/pixel, low 13 bits =
|
|
741
|
+
// millimetres, high 3 bits = confidence 0..7). The shared JSI
|
|
742
|
+
// layer (`cpp/camera_frame_jsi.cpp`) unpacks mm->metres and
|
|
743
|
+
// confidence 0..7 -> 0..2, so we emit the RAW packed bytes with
|
|
744
|
+
// format "u16packed" and leave the confidence array empty.
|
|
745
|
+
//
|
|
746
|
+
// ARCore's plane[0].rowStride may EXCEED w*2 (alignment padding);
|
|
747
|
+
// we copy exactly w*2 bytes per row so the JS-side reader sees a
|
|
748
|
+
// dense, no-padding buffer. Older devices / un-supported sessions
|
|
749
|
+
// throw NotYetAvailableException (or depth disabled) — caught and
|
|
750
|
+
// treated as "no depth this frame" (null). `use {}` closes the
|
|
751
|
+
// ARCore Image deterministically in all paths.
|
|
752
|
+
//
|
|
753
|
+
// Acquired when EITHER depth (raw emission) OR mesh
|
|
754
|
+
// (reconstruction) is requested.
|
|
755
|
+
val depth: ArDepthData? =
|
|
756
|
+
if (flags.depth || flags.mesh) acquireDepth16Packed(frame) else null
|
|
757
|
+
|
|
758
|
+
// ── AR anchors ──────────────────────────────────────────────────
|
|
759
|
+
//
|
|
760
|
+
// Emit every TRACKING anchor as { id, type, transform(row-major) }.
|
|
761
|
+
// The app does NOT call session.createAnchor() anywhere today, so
|
|
762
|
+
// getAllAnchors() is empty in practice — an empty list is the
|
|
763
|
+
// CORRECT contract for "AR frame, no anchors" (the JSI layer still
|
|
764
|
+
// returns a [] for source=="ar"). The extraction below is fully
|
|
765
|
+
// wired so it lights up automatically if anchor creation lands.
|
|
766
|
+
// Gated on the anchors toggle.
|
|
767
|
+
val anchors: List<ArAnchorData> =
|
|
768
|
+
if (flags.anchors)
|
|
769
|
+
sessionRef.get()?.let { collectTrackingAnchors(it) } ?: emptyList()
|
|
770
|
+
else emptyList()
|
|
771
|
+
|
|
772
|
+
// ── AR scene mesh (reconstructed from the depth map) ─────────────
|
|
773
|
+
//
|
|
774
|
+
// ARCore has no native scene mesh (unlike ARKit's ARMeshAnchor), so
|
|
775
|
+
// when `mesh` is requested we unproject the DEPTH16 map into a
|
|
776
|
+
// camera-local point grid and triangulate it. Emitted as ONE extra
|
|
777
|
+
// anchor (type="mesh", id="mesh-depth", identity transform — the
|
|
778
|
+
// vertices are camera-local, NOT world). Built only when mesh is
|
|
779
|
+
// on AND a depth map was available this frame.
|
|
780
|
+
val meshAnchor: ArAnchorData? =
|
|
781
|
+
if (flags.mesh && depth != null) buildDepthMesh(depth, intrinsics)
|
|
782
|
+
else null
|
|
783
|
+
|
|
784
|
+
// Combine real anchors + the optional depth mesh into the parallel
|
|
785
|
+
// marshal arrays. meshVertices/meshFaces are null for every
|
|
786
|
+
// non-mesh anchor; the mesh anchor carries its Float32/Uint32 byte
|
|
787
|
+
// buffers (the JNI sets ArAnchor.hasMesh from them).
|
|
788
|
+
val allAnchors: List<ArAnchorData> =
|
|
789
|
+
if (meshAnchor != null) anchors + meshAnchor else anchors
|
|
790
|
+
val anchorIds = Array(allAnchors.size) { allAnchors[it].id }
|
|
791
|
+
val anchorTypes = Array(allAnchors.size) { allAnchors[it].type }
|
|
792
|
+
val anchorTransforms = Array(allAnchors.size) { allAnchors[it].transform }
|
|
793
|
+
val anchorMeshVertices =
|
|
794
|
+
Array<ByteArray?>(allAnchors.size) { allAnchors[it].meshVertices }
|
|
795
|
+
val anchorMeshFaces =
|
|
796
|
+
Array<ByteArray?>(allAnchors.size) { allAnchors[it].meshFaces }
|
|
797
|
+
// Per-anchor plane alignment ("" for image/mesh) + extent
|
|
798
|
+
// ([extentX, extentZ] metres, null for non-plane anchors).
|
|
799
|
+
val anchorAlignments = Array(allAnchors.size) { allAnchors[it].alignment }
|
|
800
|
+
val anchorExtents = Array<DoubleArray?>(allAnchors.size) { allAnchors[it].extent }
|
|
801
|
+
|
|
697
802
|
StitcherWorkletRuntime.dispatchToHostWorklets(
|
|
698
803
|
nv21Bytes = packed.nv21,
|
|
699
804
|
width = packed.width,
|
|
@@ -704,9 +809,729 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
704
809
|
tz = tArr[2].toDouble(),
|
|
705
810
|
timestampNs = frame.timestamp.toDouble(),
|
|
706
811
|
trackingState = arTracking,
|
|
812
|
+
// Emit raw arDepth ONLY when depth was explicitly requested —
|
|
813
|
+
// a mesh-only host gets the mesh anchor but no arDepth buffer.
|
|
814
|
+
depthBytes = if (flags.depth) depth?.bytes else null,
|
|
815
|
+
depthWidth = if (flags.depth) depth?.width ?: 0 else 0,
|
|
816
|
+
depthHeight = if (flags.depth) depth?.height ?: 0 else 0,
|
|
817
|
+
anchorIds = anchorIds,
|
|
818
|
+
anchorTypes = anchorTypes,
|
|
819
|
+
anchorTransforms = anchorTransforms,
|
|
820
|
+
anchorMeshVertices = anchorMeshVertices,
|
|
821
|
+
anchorMeshFaces = anchorMeshFaces,
|
|
822
|
+
// Per-frame camera intrinsics (fx,fy,cx,cy in pixels at the
|
|
823
|
+
// capture resolution). `intrinsics` = camera.imageIntrinsics,
|
|
824
|
+
// already in scope above (declared at the top of this fn).
|
|
825
|
+
fx = intrinsics.focalLength[0].toDouble(),
|
|
826
|
+
fy = intrinsics.focalLength[1].toDouble(),
|
|
827
|
+
cx = intrinsics.principalPoint[0].toDouble(),
|
|
828
|
+
cy = intrinsics.principalPoint[1].toDouble(),
|
|
829
|
+
intrinsicsImageWidth = intrinsics.imageDimensions[0],
|
|
830
|
+
intrinsicsImageHeight = intrinsics.imageDimensions[1],
|
|
831
|
+
anchorAlignments = anchorAlignments,
|
|
832
|
+
anchorExtents = anchorExtents,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
// ── 0.19.0 — native AR-plugin per-frame invocation ───────────────
|
|
836
|
+
//
|
|
837
|
+
// Mirror of iOS' RNSARSession.session(_:didUpdate:) plugin loop.
|
|
838
|
+
// Only build the ARFrameContext + call plugins when the registry is
|
|
839
|
+
// NON-EMPTY (the onDrawFrame gate already let us in via that check,
|
|
840
|
+
// but a worklet-only frame can reach here with an empty plugin
|
|
841
|
+
// registry — re-check so those frames pay nothing). Runs on the AR
|
|
842
|
+
// (GL render) thread, synchronously. Reuses the already-packed
|
|
843
|
+
// `packed.nv21`, the depth/anchors collected above, and the pose +
|
|
844
|
+
// intrinsics already read — no extra Image acquire, no second pack.
|
|
845
|
+
// Depth is passed ONLY when the host opted into enableDepth (a
|
|
846
|
+
// mesh-only host acquired depth for its mesh, but the contract says
|
|
847
|
+
// the context's depth is null unless enableDepth).
|
|
848
|
+
runArPlugins(
|
|
849
|
+
packed, qarr, tArr, arTracking, frame, intrinsics, anchors,
|
|
850
|
+
depth = if (flags.depth) depth else null,
|
|
851
|
+
)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/// 0.19.0 — last frame's native-plugin SYNC results, keyed by plugin
|
|
855
|
+
/// name. Written by [runArPlugins] on the GL render thread, read by
|
|
856
|
+
/// [maybeEmitArFrameMeta] on the same thread one step later in the same
|
|
857
|
+
/// onDrawFrame tick. Null = no plugins ran / no sync results this
|
|
858
|
+
/// frame. Single-threaded handoff (both on the GL thread) so no
|
|
859
|
+
/// synchronisation is needed, but @Volatile is cheap insurance against
|
|
860
|
+
/// any future cross-thread read.
|
|
861
|
+
@Volatile
|
|
862
|
+
private var lastPluginSyncResults: Map<String, Any?>? = null
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* 0.19.0 — build one [ARFrameContext] from the current frame and invoke
|
|
866
|
+
* every registered [ARFramePlugin].
|
|
867
|
+
*
|
|
868
|
+
* - Non-null SYNC results are collected into a `{ name -> result }` map
|
|
869
|
+
* and stashed in [lastPluginSyncResults] for [maybeEmitArFrameMeta]
|
|
870
|
+
* to fold into the onArFrame `plugins` field this same tick.
|
|
871
|
+
* - A plugin returning `null` defers to the ASYNC channel
|
|
872
|
+
* ([RNSARPluginRegistry.emit] → `RNImageStitcherARPluginResult`).
|
|
873
|
+
*
|
|
874
|
+
* A throwing plugin is isolated (logged, skipped) so one bad plugin
|
|
875
|
+
* can't take down the AR render loop.
|
|
876
|
+
*
|
|
877
|
+
* Reuses caller-collected data (no extra Image work):
|
|
878
|
+
* @param packed the already-packed NV21 camera image.
|
|
879
|
+
* @param qarr pose rotation quaternion [x,y,z,w].
|
|
880
|
+
* @param tArr pose translation [x,y,z] (world metres).
|
|
881
|
+
* @param tracking contract tracking string ("normal"|"limited"|"notAvailable").
|
|
882
|
+
* @param frame the ARCore frame (for the timestamp).
|
|
883
|
+
* @param intrinsics camera intrinsics (fx,fy,cx,cy + image dims).
|
|
884
|
+
* @param anchors anchor descriptors already collected for onArFrame
|
|
885
|
+
* (enableAnchors-gated; empty otherwise).
|
|
886
|
+
* @param depth row-packed DEPTH16 or null (enableDepth-gated).
|
|
887
|
+
*/
|
|
888
|
+
private fun runArPlugins(
|
|
889
|
+
packed: YuvImageConverter.PackedYuv,
|
|
890
|
+
qarr: FloatArray,
|
|
891
|
+
tArr: FloatArray,
|
|
892
|
+
tracking: String,
|
|
893
|
+
frame: com.google.ar.core.Frame,
|
|
894
|
+
intrinsics: com.google.ar.core.CameraIntrinsics,
|
|
895
|
+
anchors: List<ArAnchorData>,
|
|
896
|
+
depth: ArDepthData?,
|
|
897
|
+
) {
|
|
898
|
+
val plugins = RNSARPluginRegistry.plugins()
|
|
899
|
+
if (plugins.isEmpty()) {
|
|
900
|
+
lastPluginSyncResults = null
|
|
901
|
+
return
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Flatten the already-collected anchor descriptors into plain maps
|
|
905
|
+
// (id/type/transform + optional alignment/extent) so plugins get the
|
|
906
|
+
// same shape as the JS `ARAnchor` contract without a JSI dependency.
|
|
907
|
+
val anchorMaps: List<Map<String, Any?>> =
|
|
908
|
+
if (anchors.isEmpty()) emptyList()
|
|
909
|
+
else anchors.map { a ->
|
|
910
|
+
val m = HashMap<String, Any?>(5)
|
|
911
|
+
m["id"] = a.id
|
|
912
|
+
m["type"] = a.type
|
|
913
|
+
m["transform"] = a.transform
|
|
914
|
+
if (a.alignment.isNotEmpty()) m["alignment"] = a.alignment
|
|
915
|
+
a.extent?.let { m["extent"] = it }
|
|
916
|
+
m
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
val ctx = ARFrameContext(
|
|
920
|
+
nv21 = packed.nv21,
|
|
921
|
+
width = packed.width,
|
|
922
|
+
height = packed.height,
|
|
923
|
+
timestampNs = frame.timestamp.toDouble(),
|
|
924
|
+
fx = intrinsics.focalLength[0].toDouble(),
|
|
925
|
+
fy = intrinsics.focalLength[1].toDouble(),
|
|
926
|
+
cx = intrinsics.principalPoint[0].toDouble(),
|
|
927
|
+
cy = intrinsics.principalPoint[1].toDouble(),
|
|
928
|
+
imageWidth = intrinsics.imageDimensions[0],
|
|
929
|
+
imageHeight = intrinsics.imageDimensions[1],
|
|
930
|
+
poseRotation = doubleArrayOf(
|
|
931
|
+
qarr[0].toDouble(), qarr[1].toDouble(),
|
|
932
|
+
qarr[2].toDouble(), qarr[3].toDouble(),
|
|
933
|
+
),
|
|
934
|
+
poseTranslation = doubleArrayOf(
|
|
935
|
+
tArr[0].toDouble(), tArr[1].toDouble(), tArr[2].toDouble(),
|
|
936
|
+
),
|
|
937
|
+
trackingState = tracking,
|
|
938
|
+
depthBytes = depth?.bytes,
|
|
939
|
+
depthWidth = depth?.width ?: 0,
|
|
940
|
+
depthHeight = depth?.height ?: 0,
|
|
941
|
+
anchors = anchorMaps,
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
var sync: HashMap<String, Any?>? = null
|
|
945
|
+
for (plugin in plugins) {
|
|
946
|
+
val result = try {
|
|
947
|
+
plugin.process(ctx)
|
|
948
|
+
} catch (t: Throwable) {
|
|
949
|
+
if (forwardLogTick % 30 == 1) {
|
|
950
|
+
Log.w(TAG, "AR plugin '${plugin.name()}' threw in process(): ${t.message}")
|
|
951
|
+
}
|
|
952
|
+
null
|
|
953
|
+
}
|
|
954
|
+
if (result != null) {
|
|
955
|
+
if (sync == null) sync = HashMap()
|
|
956
|
+
sync[plugin.name()] = result
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
lastPluginSyncResults = sync
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/// Packed DEPTH16 result: dense (no row padding) uint16-per-pixel
|
|
963
|
+
/// bytes plus the depth-map dimensions. `bytes.size == width*height*2`.
|
|
964
|
+
private data class ArDepthData(
|
|
965
|
+
val bytes: ByteArray,
|
|
966
|
+
val width: Int,
|
|
967
|
+
val height: Int,
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
/// One anchor flattened for the JNI parallel-array marshal.
|
|
971
|
+
/// `transform` is a 16-element ROW-MAJOR (anchor->world) matrix.
|
|
972
|
+
///
|
|
973
|
+
/// For a depth-derived scene mesh (type="mesh") the geometry rides
|
|
974
|
+
/// along in `meshVertices` (Float32 xyz triplets, LITTLE-ENDIAN) and
|
|
975
|
+
/// `meshFaces` (Uint32 triangle indices, LITTLE-ENDIAN); both are
|
|
976
|
+
/// `null` for plane/image/point anchors. Mesh vertices are
|
|
977
|
+
/// CAMERA-LOCAL, so the mesh anchor's `transform` is identity.
|
|
978
|
+
private data class ArAnchorData(
|
|
979
|
+
val id: String,
|
|
980
|
+
val type: String,
|
|
981
|
+
val transform: DoubleArray,
|
|
982
|
+
val meshVertices: ByteArray? = null,
|
|
983
|
+
val meshFaces: ByteArray? = null,
|
|
984
|
+
/// Plane alignment: "" (n/a — image/mesh anchors) | "horizontal"
|
|
985
|
+
/// | "vertical". Set only on plane anchors; the JNI maps it to
|
|
986
|
+
/// `ArAnchor.alignment` (empty → JS `alignment === undefined`).
|
|
987
|
+
val alignment: String = "",
|
|
988
|
+
/// Plane extent [extentX, extentZ] in metres, or null (image/mesh
|
|
989
|
+
/// anchors). Non-null → the JNI sets `ArAnchor.hasExtent`.
|
|
990
|
+
val extent: DoubleArray? = null,
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Acquire this frame's ARCore depth image (DEPTH16) and copy it into a
|
|
995
|
+
* dense, row-packed `ByteArray` of `w*h*2` bytes (no stride padding).
|
|
996
|
+
*
|
|
997
|
+
* Returns null when depth is unavailable for this frame — older
|
|
998
|
+
* devices that don't support the Depth API, the first frames before
|
|
999
|
+
* ARCore produces a depth estimate (`NotYetAvailableException`), or a
|
|
1000
|
+
* session configured without `DepthMode.AUTOMATIC`. The ARCore Image
|
|
1001
|
+
* is always closed via `use {}`.
|
|
1002
|
+
*
|
|
1003
|
+
* Byte order is preserved verbatim from ARCore's little-endian
|
|
1004
|
+
* DEPTH16 buffer — the shared C++ JSI layer reinterprets the bytes as
|
|
1005
|
+
* `uint16_t` on the same (little-endian ARM) device, so no swap is
|
|
1006
|
+
* needed.
|
|
1007
|
+
*/
|
|
1008
|
+
private fun acquireDepth16Packed(
|
|
1009
|
+
frame: com.google.ar.core.Frame,
|
|
1010
|
+
): ArDepthData? {
|
|
1011
|
+
return try {
|
|
1012
|
+
frame.acquireDepthImage16Bits()?.use { img ->
|
|
1013
|
+
val w = img.width
|
|
1014
|
+
val h = img.height
|
|
1015
|
+
if (w <= 0 || h <= 0) return null
|
|
1016
|
+
val plane = img.planes[0]
|
|
1017
|
+
val rowStride = plane.rowStride // may exceed w*2
|
|
1018
|
+
val src = plane.buffer // direct ByteBuffer
|
|
1019
|
+
val rowBytes = w * 2 // DEPTH16: 2 bytes/px
|
|
1020
|
+
val out = ByteArray(rowBytes * h)
|
|
1021
|
+
// Copy ROW BY ROW — only the first `rowBytes` of each
|
|
1022
|
+
// `rowStride`-byte source row are real pixels; the tail
|
|
1023
|
+
// (rowStride - rowBytes) is alignment padding to skip.
|
|
1024
|
+
val row = ByteArray(rowBytes)
|
|
1025
|
+
for (y in 0 until h) {
|
|
1026
|
+
src.position(y * rowStride)
|
|
1027
|
+
src.get(row, 0, rowBytes)
|
|
1028
|
+
System.arraycopy(row, 0, out, y * rowBytes, rowBytes)
|
|
1029
|
+
}
|
|
1030
|
+
ArDepthData(bytes = out, width = w, height = h)
|
|
1031
|
+
}
|
|
1032
|
+
} catch (t: Throwable) {
|
|
1033
|
+
// NotYetAvailableException (early frames), depth unsupported,
|
|
1034
|
+
// or any plane-access failure — treat as "no depth this frame".
|
|
1035
|
+
if (forwardLogTick % 30 == 1) {
|
|
1036
|
+
Log.d(TAG, "acquireDepth16Packed: no depth this frame: ${t.message}")
|
|
1037
|
+
}
|
|
1038
|
+
null
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Reconstruct a triangle mesh from this frame's DEPTH16 map.
|
|
1044
|
+
*
|
|
1045
|
+
* ARCore (unlike ARKit's `ARMeshAnchor`) exposes no scene mesh, so we
|
|
1046
|
+
* unproject every valid depth pixel into a camera-local 3D point and
|
|
1047
|
+
* triangulate the resulting grid. The output is ONE `ArAnchorData`
|
|
1048
|
+
* with type="mesh", id="mesh-depth", an IDENTITY transform (vertices
|
|
1049
|
+
* are camera-local, not world), a Float32 vertex buffer (xyz triplets,
|
|
1050
|
+
* little-endian) and a Uint32 triangle-index buffer (little-endian).
|
|
1051
|
+
*
|
|
1052
|
+
* ## Intrinsics
|
|
1053
|
+
*
|
|
1054
|
+
* `camera.imageIntrinsics` gives focal length + principal point at the
|
|
1055
|
+
* CAMERA-IMAGE resolution. The depth map is much smaller (~160x120 on
|
|
1056
|
+
* ARCore), so we SCALE the intrinsics to the depth resolution:
|
|
1057
|
+
* fx_d = fx * depthW / imgW, cx_d = cx * depthW / imgW (and y).
|
|
1058
|
+
*
|
|
1059
|
+
* ## Unprojection
|
|
1060
|
+
*
|
|
1061
|
+
* Depth z (metres) = (raw uint16 & 0x1FFF) / 1000.0 (low 13 bits = mm;
|
|
1062
|
+
* high 3 bits = confidence, masked off). z==0 ⇒ invalid (skipped).
|
|
1063
|
+
* X = (u - cx_d) * z / fx_d
|
|
1064
|
+
* Y = (v - cy_d) * z / fy_d
|
|
1065
|
+
* Z = z
|
|
1066
|
+
*
|
|
1067
|
+
* ## Triangulation
|
|
1068
|
+
*
|
|
1069
|
+
* For each grid cell whose 4 corners are ALL valid, emit 2 triangles
|
|
1070
|
+
* (6 Uint32 indices into the vertex array). No decimation (non-goal).
|
|
1071
|
+
*
|
|
1072
|
+
* Returns null if the depth map has no valid pixels / no full cells.
|
|
1073
|
+
*/
|
|
1074
|
+
private fun buildDepthMesh(
|
|
1075
|
+
depth: ArDepthData,
|
|
1076
|
+
intrinsics: com.google.ar.core.CameraIntrinsics,
|
|
1077
|
+
): ArAnchorData? {
|
|
1078
|
+
val w = depth.width
|
|
1079
|
+
val h = depth.height
|
|
1080
|
+
if (w <= 1 || h <= 1) return null
|
|
1081
|
+
|
|
1082
|
+
// Scale camera-image intrinsics to the depth-map resolution.
|
|
1083
|
+
val imgW = intrinsics.imageDimensions[0].toDouble()
|
|
1084
|
+
val imgH = intrinsics.imageDimensions[1].toDouble()
|
|
1085
|
+
if (imgW <= 0.0 || imgH <= 0.0) return null
|
|
1086
|
+
val sx = w.toDouble() / imgW
|
|
1087
|
+
val sy = h.toDouble() / imgH
|
|
1088
|
+
val fxD = intrinsics.focalLength[0].toDouble() * sx
|
|
1089
|
+
val fyD = intrinsics.focalLength[1].toDouble() * sy
|
|
1090
|
+
val cxD = intrinsics.principalPoint[0].toDouble() * sx
|
|
1091
|
+
val cyD = intrinsics.principalPoint[1].toDouble() * sy
|
|
1092
|
+
if (fxD <= 0.0 || fyD <= 0.0) return null
|
|
1093
|
+
|
|
1094
|
+
// Read DEPTH16 as little-endian uint16 (raw mm in low 13 bits).
|
|
1095
|
+
val depthBuf = ByteBuffer.wrap(depth.bytes).order(ByteOrder.LITTLE_ENDIAN)
|
|
1096
|
+
val px = w * h
|
|
1097
|
+
|
|
1098
|
+
// Unproject every valid pixel; build a pixel->vertex index map
|
|
1099
|
+
// (-1 for invalid) so triangulation can reference the compacted
|
|
1100
|
+
// vertex array.
|
|
1101
|
+
val vertXyz = FloatArray(px * 3) // upper-bound; trimmed on write
|
|
1102
|
+
val indexMap = IntArray(px) { -1 }
|
|
1103
|
+
var vertCount = 0
|
|
1104
|
+
for (v in 0 until h) {
|
|
1105
|
+
val rowBase = v * w
|
|
1106
|
+
for (u in 0 until w) {
|
|
1107
|
+
val raw = depthBuf.getShort((rowBase + u) * 2).toInt() and 0xFFFF
|
|
1108
|
+
val mm = raw and 0x1FFF
|
|
1109
|
+
if (mm == 0) continue // invalid depth — skip
|
|
1110
|
+
val z = mm / 1000.0
|
|
1111
|
+
val x = (u - cxD) * z / fxD
|
|
1112
|
+
val y = (v - cyD) * z / fyD
|
|
1113
|
+
val o = vertCount * 3
|
|
1114
|
+
vertXyz[o] = x.toFloat()
|
|
1115
|
+
vertXyz[o + 1] = y.toFloat()
|
|
1116
|
+
vertXyz[o + 2] = z.toFloat()
|
|
1117
|
+
indexMap[rowBase + u] = vertCount
|
|
1118
|
+
vertCount++
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (vertCount == 0) return null
|
|
1122
|
+
|
|
1123
|
+
// Triangulate the grid: each cell with all 4 corners valid → 2
|
|
1124
|
+
// triangles. Index buffer is grown dynamically (count of full
|
|
1125
|
+
// cells isn't known ahead without a second pass).
|
|
1126
|
+
// tl tr
|
|
1127
|
+
// bl br → (tl, bl, br) + (tl, br, tr)
|
|
1128
|
+
val faces = ArrayList<Int>(px * 2)
|
|
1129
|
+
for (v in 0 until h - 1) {
|
|
1130
|
+
val r0 = v * w
|
|
1131
|
+
val r1 = r0 + w
|
|
1132
|
+
for (u in 0 until w - 1) {
|
|
1133
|
+
val tl = indexMap[r0 + u]
|
|
1134
|
+
val tr = indexMap[r0 + u + 1]
|
|
1135
|
+
val bl = indexMap[r1 + u]
|
|
1136
|
+
val br = indexMap[r1 + u + 1]
|
|
1137
|
+
if (tl < 0 || tr < 0 || bl < 0 || br < 0) continue
|
|
1138
|
+
faces.add(tl); faces.add(bl); faces.add(br)
|
|
1139
|
+
faces.add(tl); faces.add(br); faces.add(tr)
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
if (faces.isEmpty()) return null
|
|
1143
|
+
|
|
1144
|
+
// Pack vertices (Float32 xyz) + faces (Uint32) into little-endian
|
|
1145
|
+
// byte arrays — the JSI layer reinterprets these as ArrayBuffers
|
|
1146
|
+
// verbatim (Float32Array / Uint32Array on the same LE ARM device).
|
|
1147
|
+
val vertBytes = ByteArray(vertCount * 3 * 4)
|
|
1148
|
+
val vbuf = ByteBuffer.wrap(vertBytes).order(ByteOrder.LITTLE_ENDIAN)
|
|
1149
|
+
for (i in 0 until vertCount * 3) vbuf.putFloat(vertXyz[i])
|
|
1150
|
+
|
|
1151
|
+
val faceBytes = ByteArray(faces.size * 4)
|
|
1152
|
+
val fbuf = ByteBuffer.wrap(faceBytes).order(ByteOrder.LITTLE_ENDIAN)
|
|
1153
|
+
for (idx in faces) fbuf.putInt(idx)
|
|
1154
|
+
|
|
1155
|
+
// Identity 4x4 (row-major == column-major for identity).
|
|
1156
|
+
val identity = DoubleArray(16)
|
|
1157
|
+
identity[0] = 1.0; identity[5] = 1.0; identity[10] = 1.0; identity[15] = 1.0
|
|
1158
|
+
|
|
1159
|
+
return ArAnchorData(
|
|
1160
|
+
id = "mesh-depth",
|
|
1161
|
+
type = "mesh",
|
|
1162
|
+
transform = identity,
|
|
1163
|
+
meshVertices = vertBytes,
|
|
1164
|
+
meshFaces = faceBytes,
|
|
707
1165
|
)
|
|
708
1166
|
}
|
|
709
1167
|
|
|
1168
|
+
// ── onArFrame (v0.18.0) — LIGHT AR-metadata event channel ────────
|
|
1169
|
+
//
|
|
1170
|
+
// Build + throttle + emit the shared `ARFrameMeta` payload over the
|
|
1171
|
+
// `RNImageStitcherARFrame` device event. Runs every render frame
|
|
1172
|
+
// from `onDrawFrame`, but is near-free unless a host has opted in
|
|
1173
|
+
// via `RNSARSession.setArFrameMetaEnabled(true, intervalMs)`:
|
|
1174
|
+
// - one volatile read of `arFrameMetaEnabled` short-circuits the
|
|
1175
|
+
// disabled case,
|
|
1176
|
+
// - a monotonic `nanoTime()` compare throttles to `intervalMs`.
|
|
1177
|
+
//
|
|
1178
|
+
// The payload mirrors the shared contract EXACTLY (timestamp ns,
|
|
1179
|
+
// trackingState string, pose {rotation[4], translation[3]},
|
|
1180
|
+
// intrinsics|null, depth|null, anchors[], mesh|null). depth/anchors/
|
|
1181
|
+
// mesh honour the SAME `enableDepth`/`enableAnchors`/`enableMesh`
|
|
1182
|
+
// extraction flags the worklet fan-out uses, so a host pays no
|
|
1183
|
+
// depth-acquire / anchor-collect cost for a field it didn't request.
|
|
1184
|
+
//
|
|
1185
|
+
// CRITICAL: this is LIGHT. No pixel copies — depth is read for
|
|
1186
|
+
// dimensions + confidence-presence only (no `acquireDepth16Packed`
|
|
1187
|
+
// row-pack), and mesh is reported as anchor/vertex/face COUNTS only
|
|
1188
|
+
// (no vertex/face byte marshaling). The heavy buffers stay on the
|
|
1189
|
+
// `arFrameProcessor` worklet path.
|
|
1190
|
+
|
|
1191
|
+
private fun maybeEmitArFrameMeta(
|
|
1192
|
+
frame: com.google.ar.core.Frame,
|
|
1193
|
+
camera: Camera,
|
|
1194
|
+
) {
|
|
1195
|
+
// Gate: disabled is the overwhelmingly common case — bail on a
|
|
1196
|
+
// single volatile read before touching the clock or the frame.
|
|
1197
|
+
if (!RNSARSession.arFrameMetaEnabled) return
|
|
1198
|
+
|
|
1199
|
+
// Throttle: emit at most once per `arFrameMetaIntervalMs`. Uses
|
|
1200
|
+
// System.nanoTime() (monotonic; immune to wall-clock jumps). A
|
|
1201
|
+
// 0 interval disables throttling (emit every render frame).
|
|
1202
|
+
val nowNs = System.nanoTime()
|
|
1203
|
+
val intervalMs = RNSARSession.arFrameMetaIntervalMs
|
|
1204
|
+
if (intervalMs > 0L) {
|
|
1205
|
+
val last = RNSARSession.arFrameMetaLastEmitNs
|
|
1206
|
+
if (last != 0L && (nowNs - last) < intervalMs * 1_000_000L) return
|
|
1207
|
+
}
|
|
1208
|
+
RNSARSession.arFrameMetaLastEmitNs = nowNs
|
|
1209
|
+
|
|
1210
|
+
val session = RNSARSession.instance ?: return
|
|
1211
|
+
|
|
1212
|
+
// ── trackingState (always) — contract string enum ───────────
|
|
1213
|
+
val trackingStr = when (camera.trackingState) {
|
|
1214
|
+
TrackingState.TRACKING -> "normal"
|
|
1215
|
+
TrackingState.PAUSED -> "limited"
|
|
1216
|
+
TrackingState.STOPPED -> "notAvailable"
|
|
1217
|
+
else -> "notAvailable"
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// ── pose (always) — rotation quaternion [x,y,z,w] + translation
|
|
1221
|
+
val pose = camera.pose
|
|
1222
|
+
val q = pose.rotationQuaternion // x, y, z, w
|
|
1223
|
+
val t = pose.translation // x, y, z
|
|
1224
|
+
|
|
1225
|
+
val meta = com.facebook.react.bridge.Arguments.createMap()
|
|
1226
|
+
meta.putDouble("timestamp", frame.timestamp.toDouble()) // ns
|
|
1227
|
+
meta.putString("trackingState", trackingStr)
|
|
1228
|
+
|
|
1229
|
+
val poseMap = com.facebook.react.bridge.Arguments.createMap()
|
|
1230
|
+
val rotArr = com.facebook.react.bridge.Arguments.createArray()
|
|
1231
|
+
rotArr.pushDouble(q[0].toDouble()); rotArr.pushDouble(q[1].toDouble())
|
|
1232
|
+
rotArr.pushDouble(q[2].toDouble()); rotArr.pushDouble(q[3].toDouble())
|
|
1233
|
+
poseMap.putArray("rotation", rotArr)
|
|
1234
|
+
val transArr = com.facebook.react.bridge.Arguments.createArray()
|
|
1235
|
+
transArr.pushDouble(t[0].toDouble()); transArr.pushDouble(t[1].toDouble())
|
|
1236
|
+
transArr.pushDouble(t[2].toDouble())
|
|
1237
|
+
poseMap.putArray("translation", transArr)
|
|
1238
|
+
meta.putMap("pose", poseMap)
|
|
1239
|
+
|
|
1240
|
+
// ── intrinsics (always) — fx,fy,cx,cy + image dims, or null ──
|
|
1241
|
+
// camera.imageIntrinsics is always present once tracking has a
|
|
1242
|
+
// frame; guarded defensively (older devices can throw before the
|
|
1243
|
+
// first valid frame).
|
|
1244
|
+
val intrinsicsMap: com.facebook.react.bridge.WritableMap? = try {
|
|
1245
|
+
val intr = camera.imageIntrinsics
|
|
1246
|
+
com.facebook.react.bridge.Arguments.createMap().apply {
|
|
1247
|
+
putDouble("fx", intr.focalLength[0].toDouble())
|
|
1248
|
+
putDouble("fy", intr.focalLength[1].toDouble())
|
|
1249
|
+
putDouble("cx", intr.principalPoint[0].toDouble())
|
|
1250
|
+
putDouble("cy", intr.principalPoint[1].toDouble())
|
|
1251
|
+
putInt("imageWidth", intr.imageDimensions[0])
|
|
1252
|
+
putInt("imageHeight", intr.imageDimensions[1])
|
|
1253
|
+
}
|
|
1254
|
+
} catch (t2: Throwable) {
|
|
1255
|
+
null
|
|
1256
|
+
}
|
|
1257
|
+
if (intrinsicsMap != null) meta.putMap("intrinsics", intrinsicsMap)
|
|
1258
|
+
else meta.putNull("intrinsics")
|
|
1259
|
+
|
|
1260
|
+
// Honour the SAME extraction flags as the worklet fan-out so
|
|
1261
|
+
// depth/anchors/mesh only cost work when the host opted in.
|
|
1262
|
+
val flags = StitcherWorkletRuntime.extractionFlags()
|
|
1263
|
+
|
|
1264
|
+
// ── depth (only when enableDepth) — DIMS + confidence presence,
|
|
1265
|
+
// NO pixel copy. DEPTH16 packs an 8-bit (high 3 bits)
|
|
1266
|
+
// confidence with each sample, so when a depth image exists
|
|
1267
|
+
// confidence is always present.
|
|
1268
|
+
if (flags.depth) {
|
|
1269
|
+
val depthDims = acquireDepthDimsLight(frame)
|
|
1270
|
+
if (depthDims != null) {
|
|
1271
|
+
val depthMap = com.facebook.react.bridge.Arguments.createMap()
|
|
1272
|
+
depthMap.putInt("width", depthDims[0])
|
|
1273
|
+
depthMap.putInt("height", depthDims[1])
|
|
1274
|
+
depthMap.putBoolean("hasConfidence", true)
|
|
1275
|
+
meta.putMap("depth", depthMap)
|
|
1276
|
+
} else {
|
|
1277
|
+
meta.putNull("depth")
|
|
1278
|
+
}
|
|
1279
|
+
} else {
|
|
1280
|
+
meta.putNull("depth")
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// ── anchors (only when enableAnchors) — descriptors, no pixels.
|
|
1284
|
+
// Reuses the existing collectTrackingAnchors (id/type/alignment/
|
|
1285
|
+
// extent/transform); the depth-mesh anchor is NOT included here
|
|
1286
|
+
// (mesh is reported as counts in the `mesh` field below).
|
|
1287
|
+
val anchorsArr = com.facebook.react.bridge.Arguments.createArray()
|
|
1288
|
+
if (flags.anchors) {
|
|
1289
|
+
val anchors = sessionRef.get()?.let { collectTrackingAnchors(it) } ?: emptyList()
|
|
1290
|
+
for (a in anchors) {
|
|
1291
|
+
val am = com.facebook.react.bridge.Arguments.createMap()
|
|
1292
|
+
am.putString("id", a.id)
|
|
1293
|
+
am.putString("type", a.type)
|
|
1294
|
+
if (a.alignment.isNotEmpty()) am.putString("alignment", a.alignment)
|
|
1295
|
+
a.extent?.let { ext ->
|
|
1296
|
+
val extArr = com.facebook.react.bridge.Arguments.createArray()
|
|
1297
|
+
extArr.pushDouble(ext[0]); extArr.pushDouble(ext[1])
|
|
1298
|
+
am.putArray("extent", extArr)
|
|
1299
|
+
}
|
|
1300
|
+
// classification: ARCore exposes none for plane/image
|
|
1301
|
+
// trackables (ARKit-only field) — omit it (JS sees
|
|
1302
|
+
// `classification === undefined`), matching the
|
|
1303
|
+
// `classification?` optionality in the contract.
|
|
1304
|
+
val tArr = com.facebook.react.bridge.Arguments.createArray()
|
|
1305
|
+
for (v in a.transform) tArr.pushDouble(v)
|
|
1306
|
+
am.putArray("transform", tArr)
|
|
1307
|
+
anchorsArr.pushMap(am)
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
meta.putArray("anchors", anchorsArr)
|
|
1311
|
+
|
|
1312
|
+
// ── mesh (only when enableMesh) — COUNTS only, no byte marshal.
|
|
1313
|
+
// ARCore has no native scene mesh; the depth-reconstructed
|
|
1314
|
+
// mesh is what the worklet path emits. For the LIGHT channel
|
|
1315
|
+
// we report a single anchor (anchorCount=1) whose vertex/face
|
|
1316
|
+
// counts come from a count-only depth scan (no buffer build).
|
|
1317
|
+
// Reported only when mesh is on AND a depth image is available.
|
|
1318
|
+
if (flags.mesh) {
|
|
1319
|
+
val meshCounts = computeDepthMeshCountsLight(frame)
|
|
1320
|
+
if (meshCounts != null) {
|
|
1321
|
+
val meshMap = com.facebook.react.bridge.Arguments.createMap()
|
|
1322
|
+
meshMap.putInt("anchorCount", 1)
|
|
1323
|
+
meshMap.putInt("vertexCount", meshCounts[0])
|
|
1324
|
+
meshMap.putInt("faceCount", meshCounts[1])
|
|
1325
|
+
meta.putMap("mesh", meshMap)
|
|
1326
|
+
} else {
|
|
1327
|
+
meta.putNull("mesh")
|
|
1328
|
+
}
|
|
1329
|
+
} else {
|
|
1330
|
+
meta.putNull("mesh")
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// ── plugins (0.19.0) — native-plugin SYNC results, if any ────────
|
|
1334
|
+
// `lastPluginSyncResults` was stashed by `runArPlugins` earlier
|
|
1335
|
+
// in THIS same onDrawFrame tick (forwardToIncremental runs before
|
|
1336
|
+
// maybeEmitArFrameMeta). Each value is the WritableMap a plugin
|
|
1337
|
+
// returned from `process()`; we re-key it under `plugins[name]`.
|
|
1338
|
+
// Omitted entirely when no plugin produced a sync result (JS sees
|
|
1339
|
+
// `meta.plugins === undefined`), matching the optional `plugins?`
|
|
1340
|
+
// field in the ARFrameMeta contract.
|
|
1341
|
+
val pluginResults = lastPluginSyncResults
|
|
1342
|
+
if (!pluginResults.isNullOrEmpty()) {
|
|
1343
|
+
val pluginsMap = com.facebook.react.bridge.Arguments.createMap()
|
|
1344
|
+
for ((name, value) in pluginResults) {
|
|
1345
|
+
when (value) {
|
|
1346
|
+
is com.facebook.react.bridge.WritableMap ->
|
|
1347
|
+
pluginsMap.putMap(name, value)
|
|
1348
|
+
null -> pluginsMap.putNull(name)
|
|
1349
|
+
// Defensive: a plugin should only ever return a
|
|
1350
|
+
// WritableMap, but never let an unexpected type crash the
|
|
1351
|
+
// emit — drop it.
|
|
1352
|
+
else -> { /* skip unsupported result type */ }
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
meta.putMap("plugins", pluginsMap)
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
session.emitArFrameMeta(meta)
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* LIGHT depth probe — return `[width, height]` of this frame's
|
|
1363
|
+
* DEPTH16 image WITHOUT copying any pixels (the contract's depth
|
|
1364
|
+
* field carries dims + confidence-presence only). `use {}` closes
|
|
1365
|
+
* the ARCore Image deterministically in all paths. Returns null when
|
|
1366
|
+
* depth is unavailable (unsupported device, early frames, or depth
|
|
1367
|
+
* not configured).
|
|
1368
|
+
*/
|
|
1369
|
+
private fun acquireDepthDimsLight(
|
|
1370
|
+
frame: com.google.ar.core.Frame,
|
|
1371
|
+
): IntArray? {
|
|
1372
|
+
return try {
|
|
1373
|
+
frame.acquireDepthImage16Bits()?.use { img ->
|
|
1374
|
+
val w = img.width
|
|
1375
|
+
val h = img.height
|
|
1376
|
+
if (w <= 0 || h <= 0) null else intArrayOf(w, h)
|
|
1377
|
+
}
|
|
1378
|
+
} catch (t: Throwable) {
|
|
1379
|
+
// NotYetAvailableException / depth unsupported — no depth.
|
|
1380
|
+
null
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* LIGHT mesh count probe — return `[vertexCount, faceCount]` for the
|
|
1386
|
+
* depth-reconstructed mesh WITHOUT building any vertex/face byte
|
|
1387
|
+
* buffers (the contract's mesh field carries counts only).
|
|
1388
|
+
*
|
|
1389
|
+
* Mirrors [buildDepthMesh]'s validity rules exactly (z==0 ⇒ invalid
|
|
1390
|
+
* vertex; a grid cell contributes 2 faces iff all 4 corners are
|
|
1391
|
+
* valid) so the reported counts match what the worklet path would
|
|
1392
|
+
* actually marshal — but we never allocate the vertex/index/byte
|
|
1393
|
+
* arrays. Reuses [acquireDepth16Packed] for the row-packed DEPTH16
|
|
1394
|
+
* read (the only depth read available), then scans it numerically.
|
|
1395
|
+
*
|
|
1396
|
+
* Returns null when no depth image is available or the mesh would be
|
|
1397
|
+
* empty (no valid pixels / no full cells).
|
|
1398
|
+
*
|
|
1399
|
+
* Note: camera intrinsics are NOT needed here — vertex/face VALIDITY
|
|
1400
|
+
* is purely a function of the depth value (mm != 0), and counts are
|
|
1401
|
+
* invariant to the unprojection the worklet path performs.
|
|
1402
|
+
*/
|
|
1403
|
+
private fun computeDepthMeshCountsLight(
|
|
1404
|
+
frame: com.google.ar.core.Frame,
|
|
1405
|
+
): IntArray? {
|
|
1406
|
+
val depth = acquireDepth16Packed(frame) ?: return null
|
|
1407
|
+
val w = depth.width
|
|
1408
|
+
val h = depth.height
|
|
1409
|
+
if (w <= 1 || h <= 1) return null
|
|
1410
|
+
|
|
1411
|
+
val depthBuf = ByteBuffer.wrap(depth.bytes).order(ByteOrder.LITTLE_ENDIAN)
|
|
1412
|
+
|
|
1413
|
+
// Per-pixel validity (matches buildDepthMesh: low 13 bits = mm;
|
|
1414
|
+
// mm==0 ⇒ invalid). Track which pixels are valid so face cells
|
|
1415
|
+
// can test their 4 corners without re-reading the buffer.
|
|
1416
|
+
val valid = BooleanArray(w * h)
|
|
1417
|
+
var vertexCount = 0
|
|
1418
|
+
for (i in 0 until w * h) {
|
|
1419
|
+
val raw = depthBuf.getShort(i * 2).toInt() and 0xFFFF
|
|
1420
|
+
if ((raw and 0x1FFF) != 0) {
|
|
1421
|
+
valid[i] = true
|
|
1422
|
+
vertexCount++
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
if (vertexCount == 0) return null
|
|
1426
|
+
|
|
1427
|
+
// Faces: each cell with all 4 corners valid → 2 triangles.
|
|
1428
|
+
var faceCount = 0
|
|
1429
|
+
for (v in 0 until h - 1) {
|
|
1430
|
+
val r0 = v * w
|
|
1431
|
+
val r1 = r0 + w
|
|
1432
|
+
for (u in 0 until w - 1) {
|
|
1433
|
+
if (valid[r0 + u] && valid[r0 + u + 1] &&
|
|
1434
|
+
valid[r1 + u] && valid[r1 + u + 1]
|
|
1435
|
+
) {
|
|
1436
|
+
faceCount += 2
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
if (faceCount == 0) return null
|
|
1441
|
+
return intArrayOf(vertexCount, faceCount)
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* Collect every currently-TRACKING anchor from the session as
|
|
1446
|
+
* `ArAnchorData` (id, coarse type, row-major 4x4 transform).
|
|
1447
|
+
*
|
|
1448
|
+
* `Pose.toMatrix(float[16], 0)` yields a COLUMN-MAJOR (OpenGL) matrix;
|
|
1449
|
+
* we TRANSPOSE it to the row-major layout the shared C++ contract
|
|
1450
|
+
* expects (`ArAnchor.transform`, anchor->world, row-major).
|
|
1451
|
+
*
|
|
1452
|
+
* Cross-platform parity: ARKit's `frame.anchors` auto-includes detected
|
|
1453
|
+
* `ARPlaneAnchor`s (planeDetection is on), so iOS surfaces planes as
|
|
1454
|
+
* anchors for free. ARCore exposes detected planes / augmented images
|
|
1455
|
+
* as TRACKABLES (not `Anchor`s) until you call `createAnchor`, and this
|
|
1456
|
+
* app creates none — so to give the worklet the same useful per-frame
|
|
1457
|
+
* spatial data, we surface detected plane + augmented-image trackables
|
|
1458
|
+
* (in TRACKING state) directly as anchors. `centerPose` is the anchor
|
|
1459
|
+
* pose; `Pose.toMatrix` is COLUMN-MAJOR (OpenGL) so we transpose to the
|
|
1460
|
+
* row-major layout the shared C++ contract (`ArAnchor.transform`,
|
|
1461
|
+
* anchor->world) expects. ids are per-session-stable (identity hash).
|
|
1462
|
+
*/
|
|
1463
|
+
private fun collectTrackingAnchors(
|
|
1464
|
+
session: Session,
|
|
1465
|
+
): List<ArAnchorData> {
|
|
1466
|
+
val out = ArrayList<ArAnchorData>()
|
|
1467
|
+
val colMajor = FloatArray(16)
|
|
1468
|
+
|
|
1469
|
+
// Transpose ARCore's COLUMN-MAJOR (OpenGL) pose matrix to the
|
|
1470
|
+
// ROW-MAJOR (anchor->world) layout the shared C++ contract wants.
|
|
1471
|
+
fun rowMajorTransform(pose: com.google.ar.core.Pose): DoubleArray {
|
|
1472
|
+
pose.toMatrix(colMajor, 0) // COLUMN-MAJOR (OpenGL)
|
|
1473
|
+
val rowMajor = DoubleArray(16)
|
|
1474
|
+
for (r in 0 until 4) {
|
|
1475
|
+
for (c in 0 until 4) {
|
|
1476
|
+
rowMajor[r * 4 + c] = colMajor[c * 4 + r].toDouble()
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return rowMajor
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// Image/mesh anchors carry no alignment/extent (alignment=""/
|
|
1483
|
+
// extent=null) — same shape as before this change.
|
|
1484
|
+
fun emit(id: String, type: String, pose: com.google.ar.core.Pose) {
|
|
1485
|
+
out.add(ArAnchorData(id = id, type = type, transform = rowMajorTransform(pose)))
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// Read the JS `<Camera planeDetection=...>` filter once per frame
|
|
1489
|
+
// ("vertical" | "horizontal" | "both"). We FILTER which plane
|
|
1490
|
+
// orientations are surfaced here — ARCore's planeFindingMode stays
|
|
1491
|
+
// HORIZONTAL_AND_VERTICAL (see RNSARSession.setPlaneDetection).
|
|
1492
|
+
val planeMode = RNSARSession.planeDetectionMode
|
|
1493
|
+
|
|
1494
|
+
for (plane in session.getAllTrackables(com.google.ar.core.Plane::class.java)) {
|
|
1495
|
+
if (plane.trackingState != TrackingState.TRACKING) continue
|
|
1496
|
+
// Skip planes merged into a larger one (avoids duplicate poses).
|
|
1497
|
+
if (plane.subsumedBy != null) continue
|
|
1498
|
+
|
|
1499
|
+
val alignment = when (plane.type) {
|
|
1500
|
+
com.google.ar.core.Plane.Type.HORIZONTAL_UPWARD_FACING,
|
|
1501
|
+
com.google.ar.core.Plane.Type.HORIZONTAL_DOWNWARD_FACING -> "horizontal"
|
|
1502
|
+
com.google.ar.core.Plane.Type.VERTICAL -> "vertical"
|
|
1503
|
+
else -> ""
|
|
1504
|
+
}
|
|
1505
|
+
// Filter by the JS plane-detection prop (applied AFTER the
|
|
1506
|
+
// subsumedBy / trackingState skips above). "both" keeps all.
|
|
1507
|
+
when (planeMode) {
|
|
1508
|
+
"vertical" -> if (alignment != "vertical") continue
|
|
1509
|
+
"horizontal" -> if (alignment != "horizontal") continue
|
|
1510
|
+
else -> { /* "both" — keep all orientations */ }
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
out.add(
|
|
1514
|
+
ArAnchorData(
|
|
1515
|
+
id = "plane-${System.identityHashCode(plane)}",
|
|
1516
|
+
type = "plane",
|
|
1517
|
+
transform = rowMajorTransform(plane.centerPose),
|
|
1518
|
+
alignment = alignment,
|
|
1519
|
+
// extentX/extentZ: plane size (metres) along its local
|
|
1520
|
+
// X/Z axes (Y is the normal).
|
|
1521
|
+
extent = doubleArrayOf(
|
|
1522
|
+
plane.extentX.toDouble(),
|
|
1523
|
+
plane.extentZ.toDouble(),
|
|
1524
|
+
),
|
|
1525
|
+
),
|
|
1526
|
+
)
|
|
1527
|
+
}
|
|
1528
|
+
for (img in session.getAllTrackables(com.google.ar.core.AugmentedImage::class.java)) {
|
|
1529
|
+
if (img.trackingState != TrackingState.TRACKING) continue
|
|
1530
|
+
emit("image-${System.identityHashCode(img)}", "image", img.centerPose)
|
|
1531
|
+
}
|
|
1532
|
+
return out
|
|
1533
|
+
}
|
|
1534
|
+
|
|
710
1535
|
/// v0.13.2 — map the JS physical device orientation to the
|
|
711
1536
|
/// `Surface.ROTATION_*` value `YuvImageConverter.encodeToJpeg`
|
|
712
1537
|
/// expects. Mirrors the equivalence documented in encodeToJpeg's
|