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.
Files changed (46) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/RNImageStitcher.podspec +1 -1
  3. package/android/src/main/cpp/CMakeLists.txt +4 -4
  4. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +216 -7
  5. package/android/src/main/java/io/imagestitcher/rn/ARFrameContext.kt +89 -0
  6. package/android/src/main/java/io/imagestitcher/rn/ARFramePlugin.kt +57 -0
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +831 -6
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARPluginRegistry.kt +109 -0
  9. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +184 -0
  10. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +1 -1
  11. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +84 -2
  12. package/cpp/{stitcher_frame_data.hpp → camera_frame_data.hpp} +96 -13
  13. package/cpp/{stitcher_frame_jsi.cpp → camera_frame_jsi.cpp} +154 -11
  14. package/cpp/{stitcher_frame_jsi.hpp → camera_frame_jsi.hpp} +12 -12
  15. package/cpp/stitcher_proxy_jsi.cpp +31 -0
  16. package/cpp/stitcher_proxy_jsi.hpp +16 -0
  17. package/cpp/stitcher_worklet_dispatch.cpp +5 -5
  18. package/cpp/stitcher_worklet_dispatch.hpp +5 -5
  19. package/dist/camera/ARCameraView.d.ts +81 -3
  20. package/dist/camera/ARCameraView.js +103 -1
  21. package/dist/camera/Camera.d.ts +73 -7
  22. package/dist/camera/Camera.js +2 -2
  23. package/dist/index.d.ts +3 -1
  24. package/dist/stitching/ARFrameMeta.d.ts +149 -0
  25. package/dist/stitching/{StitcherFrame.js → ARFrameMeta.js} +1 -1
  26. package/dist/stitching/{StitcherFrame.d.ts → CameraFrame.d.ts} +70 -11
  27. package/dist/stitching/CameraFrame.js +4 -0
  28. package/dist/stitching/useStitcherWorklet.d.ts +4 -4
  29. package/dist/stitching/useStitcherWorklet.js +4 -4
  30. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +23 -1
  31. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +172 -2
  32. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.h +108 -0
  33. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.mm +772 -0
  34. package/ios/Sources/RNImageStitcher/RNISARFramePlugin.swift +247 -0
  35. package/ios/Sources/RNImageStitcher/RNSARSession.swift +418 -34
  36. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +2 -2
  37. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +4 -4
  38. package/package.json +1 -1
  39. package/src/camera/ARCameraView.tsx +230 -5
  40. package/src/camera/Camera.tsx +91 -7
  41. package/src/index.ts +12 -3
  42. package/src/stitching/ARFrameMeta.ts +157 -0
  43. package/src/stitching/{StitcherFrame.ts → CameraFrame.ts} +79 -11
  44. package/src/stitching/useStitcherWorklet.ts +9 -9
  45. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
  46. 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). `forwardToIncremental` does the
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
- // native registry count. `hasHostWorklets()` is a cheap atomic
407
- // read (microseconds) so the common capture-off / no-worklet
408
- // preview path stays near-free.
409
- if (ingestActive || StitcherWorkletRuntime.hasHostWorklets()) {
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