gps-plus-slam-app-framework 1.1.0 → 1.3.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 (180) hide show
  1. package/dist/{app-selectors-CR9OWodf.d.ts → app-selectors-B8VQoMDY.d.ts} +1 -1
  2. package/dist/ar/bresenham3d.d.ts +2 -0
  3. package/dist/ar/bresenham3d.js +81 -0
  4. package/dist/ar/camera-blit-capture.d.ts +1 -1
  5. package/dist/ar/camera-blit-capture.js +43 -2
  6. package/dist/ar/capability-checker.d.ts +1 -1
  7. package/dist/ar/capture-failure-tracker.d.ts +1 -1
  8. package/dist/ar/chromium-camera-access-workaround.d.ts +1 -1
  9. package/dist/ar/depth-sampler.d.ts +3 -3
  10. package/dist/ar/depth-sampler.js +63 -8
  11. package/dist/ar/depth-unprojection.d.ts +2 -0
  12. package/dist/ar/depth-unprojection.js +94 -0
  13. package/dist/ar/enable-gps-ar.d.ts +1 -1
  14. package/dist/ar/enable-gps-ar.js +1 -1
  15. package/dist/ar/frame-loop.d.ts +1 -1
  16. package/dist/ar/image-capture.d.ts +2 -2
  17. package/dist/ar/image-capture.js +9 -6
  18. package/dist/ar/index.d.ts +19 -16
  19. package/dist/ar/index.js +7 -4
  20. package/dist/ar/occupancy-grid.d.ts +2 -0
  21. package/dist/ar/occupancy-grid.js +164 -0
  22. package/dist/ar/replay-scene.d.ts +1 -1
  23. package/dist/ar/replay-scene.js +1 -1
  24. package/dist/ar/scene-node-names.d.ts +1 -1
  25. package/dist/ar/webxr-nue-basis.d.ts +2 -0
  26. package/dist/{webxr-nue-basis-BbdqhqY6.js → ar/webxr-nue-basis.js} +1 -1
  27. package/dist/ar/webxr-session.d.ts +3 -3
  28. package/dist/ar/webxr-session.js +1 -969
  29. package/dist/ar/xr-camera-texture.d.ts +1 -1
  30. package/dist/ar/xr-error-handler.d.ts +1 -1
  31. package/dist/ar/xr-frame-loop.d.ts +1 -1
  32. package/dist/{ar-types-isPsQptb.d.ts → ar-types-Dan1MVTi.d.ts} +28 -5
  33. package/dist/{ar-world-group-alignment-Dn4rQk_c.d.ts → ar-world-group-alignment-C9lwwIyI.d.ts} +1 -1
  34. package/dist/bresenham3d-CFfd7n6B.d.ts +59 -0
  35. package/dist/{camera-blit-capture-75WWa9Xb.d.ts → camera-blit-capture-CnxUDbKx.d.ts} +29 -0
  36. package/dist/core/index.d.ts +1 -1
  37. package/dist/{create-slam-app-store-B76AGaI0.d.ts → create-slam-app-store-BljusOua.d.ts} +5 -5
  38. package/dist/depth-sampler-Bhanfwla.d.ts +169 -0
  39. package/dist/depth-unprojection-Y6i_mJuT.d.ts +52 -0
  40. package/dist/{enable-gps-ar-DNbO0zbg.d.ts → enable-gps-ar-BfUJOgpW.d.ts} +4 -4
  41. package/dist/{file-system-TIsDfamK.d.ts → file-system-CwXTNHmB.d.ts} +1 -1
  42. package/dist/file-system-utils-CXIoL-DM.d.ts +40 -0
  43. package/dist/{frame-conversions-D2EYjeNa.d.ts → frame-conversions-BfsafGWR.d.ts} +1 -1
  44. package/dist/{fused-path-BdVHmDW9.d.ts → fused-path-DJoVu98b.d.ts} +1 -1
  45. package/dist/geo/h3-proximity.d.ts +1 -1
  46. package/dist/geo/index.d.ts +1 -1
  47. package/dist/{gps-anchor-CISLPQIb.d.ts → gps-anchor-dYeCvvp5.d.ts} +1 -1
  48. package/dist/{gps-ar-pose-sampler-BypXZUqC.d.ts → gps-ar-pose-sampler-DVb96iOy.d.ts} +1 -1
  49. package/dist/{gps-event-coordinator-BKVZbS1M.d.ts → gps-event-coordinator-CWIDOk4U.d.ts} +3 -3
  50. package/dist/{image-capture-BXZUmj7j.d.ts → image-capture-qAZ5Fh5X.d.ts} +44 -5
  51. package/dist/{index-BB2KEWbN2.d.ts → index-BYYUdf9v.d.ts} +1 -1
  52. package/dist/index.d.ts +67 -64
  53. package/dist/index.js +7 -4
  54. package/dist/{leaflet-map-overlay-jNt-kcUr.d.ts → leaflet-map-overlay-6CJC-E1l.d.ts} +1 -1
  55. package/dist/{map-data-BGIBM5rv.d.ts → map-data-CkloBxIl.d.ts} +1 -1
  56. package/dist/{map-overlay-draw-DcGs9UV3.d.ts → map-overlay-draw-krOKwDAq.d.ts} +1 -1
  57. package/dist/{null-storage-backend-BPQJSaMw.d.ts → null-storage-backend-uKQE8tl7.d.ts} +2 -2
  58. package/dist/occupancy-grid-BRNRaPyT.d.ts +72 -0
  59. package/dist/{opfs-storage-LsLY6VZV.d.ts → opfs-storage-D5zAn_lx.d.ts} +2 -2
  60. package/dist/{opfs-storage-backend-u4QKvgvP.d.ts → opfs-storage-backend-6aYMUZlJ.d.ts} +2 -2
  61. package/dist/{persistence-middleware-xB5sTllJ.d.ts → persistence-middleware-B9ei4Nhx.d.ts} +1 -1
  62. package/dist/{recording-options-BSFpBSZH.d.ts → recording-options-DJBXuMxV.d.ts} +12 -2
  63. package/dist/{recording-replayer-CTL2cUE0.d.ts → recording-replayer-r0DZ9ALp.d.ts} +2 -2
  64. package/dist/{recording-slice-Cb888P6d.d.ts → recording-slice-89O07ebs.d.ts} +10 -2
  65. package/dist/{replay-engine-B-5GIZtc.d.ts → replay-engine-XpDa0dy1.d.ts} +1 -1
  66. package/dist/{replay-scene-DkNfldPh.d.ts → replay-scene-Bw58VL6p.d.ts} +3 -3
  67. package/dist/sensors/gps-error-handler.d.ts +1 -1
  68. package/dist/sensors/gps.d.ts +1 -1
  69. package/dist/sensors/index.d.ts +3 -3
  70. package/dist/sensors/permission-checker.d.ts +1 -1
  71. package/dist/state/app-selectors.d.ts +1 -1
  72. package/dist/state/app-selectors.js +3 -1
  73. package/dist/state/combined-root-state.d.ts +1 -1
  74. package/dist/state/create-slam-app-store.d.ts +1 -1
  75. package/dist/state/gps-ar-pose-sampler.d.ts +3 -3
  76. package/dist/state/gps-event-coordinator.d.ts +1 -1
  77. package/dist/state/index.d.ts +16 -16
  78. package/dist/state/persistence-middleware.d.ts +1 -1
  79. package/dist/state/recording-options.d.ts +1 -1
  80. package/dist/state/recording-options.js +5 -3
  81. package/dist/state/recording-replayer.d.ts +2 -2
  82. package/dist/state/recording-slice.d.ts +1 -1
  83. package/dist/state/recording-slice.js +11 -2
  84. package/dist/state/replay-engine.d.ts +1 -1
  85. package/dist/state/store-subscribers.d.ts +2 -2
  86. package/dist/state/subscribe-to-selector.d.ts +1 -1
  87. package/dist/state/tracking-quality.d.ts +1 -1
  88. package/dist/state/tracking-quality.js +1 -1
  89. package/dist/state/tracking-slice.d.ts +1 -1
  90. package/dist/storage/file-system-utils.d.ts +2 -2
  91. package/dist/storage/file-system-utils.js +17 -1
  92. package/dist/storage/file-system.d.ts +1 -1
  93. package/dist/storage/file-system.js +2 -2
  94. package/dist/storage/index.d.ts +8 -8
  95. package/dist/storage/null-storage-backend.d.ts +1 -1
  96. package/dist/storage/opfs-storage-backend.d.ts +1 -1
  97. package/dist/storage/opfs-storage.d.ts +1 -1
  98. package/dist/storage/opfs-storage.js +4 -4
  99. package/dist/storage/storage-backend.d.ts +1 -1
  100. package/dist/storage/zip-export.d.ts +1 -1
  101. package/dist/storage/zip-export.js +1 -1
  102. package/dist/storage/zip-reader.d.ts +1 -1
  103. package/dist/storage/zip-reader.js +1 -1
  104. package/dist/{storage-backend-yDSKafAQ.d.ts → storage-backend-Do6YDpNo.d.ts} +1 -1
  105. package/dist/{store-subscribers-swlbtg_z.d.ts → store-subscribers-0YWyOLvV.d.ts} +2 -2
  106. package/dist/{subscribe-to-selector--KQpgLXG.d.ts → subscribe-to-selector-CZ6k1NFP.d.ts} +1 -1
  107. package/dist/test-utils/browser-mocks.d.ts +1 -1
  108. package/dist/test-utils/zip-round-trip-helpers.d.ts +1 -1
  109. package/dist/test-utils/zip-round-trip-helpers.js +3 -2
  110. package/dist/{tracking-slice-CDREeoZ_.d.ts → tracking-slice-CdK8c-8P.d.ts} +1 -1
  111. package/dist/types/ar-types.d.ts +2 -2
  112. package/dist/types/geo-types.d.ts +1 -1
  113. package/dist/types/index.d.ts +3 -3
  114. package/dist/utils/concurrency.d.ts +1 -1
  115. package/dist/utils/failure-tracker.d.ts +1 -1
  116. package/dist/utils/format-file-size.d.ts +1 -1
  117. package/dist/utils/fused-path.d.ts +1 -1
  118. package/dist/utils/index.d.ts +6 -6
  119. package/dist/utils/list-formatter.d.ts +1 -1
  120. package/dist/utils/logger.d.ts +1 -1
  121. package/dist/visualization/accuracy-circles.d.ts +1 -1
  122. package/dist/visualization/alignment-lerper.d.ts +1 -1
  123. package/dist/visualization/ar-world-group-alignment.d.ts +1 -1
  124. package/dist/visualization/camera-follower.d.ts +1 -1
  125. package/dist/visualization/css3d-renderer-manager.d.ts +1 -1
  126. package/dist/visualization/frame-conversions.d.ts +1 -1
  127. package/dist/visualization/frustum-visibility.d.ts +1 -1
  128. package/dist/visualization/gps-anchor.d.ts +1 -1
  129. package/dist/visualization/gps-compass-cubes.d.ts +1 -1
  130. package/dist/visualization/gps-event-markers.d.ts +1 -1
  131. package/dist/visualization/gps-event-markers.js +1 -1
  132. package/dist/visualization/hit-test-reticle.d.ts +1 -1
  133. package/dist/visualization/hit-test-reticle.js +1 -1
  134. package/dist/visualization/index.d.ts +18 -18
  135. package/dist/visualization/leaflet-map-overlay.d.ts +1 -1
  136. package/dist/visualization/lerp-utils.d.ts +1 -1
  137. package/dist/visualization/map-data.d.ts +1 -1
  138. package/dist/visualization/map-overlay-draw.d.ts +1 -1
  139. package/dist/visualization/map-overlay.d.ts +1 -1
  140. package/dist/visualization/three-dispose.d.ts +1 -1
  141. package/dist/visualization/vis-colors.d.ts +1 -1
  142. package/dist/{webxr-session-BnArFCew.d.ts → webxr-session-GjZ2BIpS.d.ts} +21 -10
  143. package/dist/webxr-session-TIaD883f.js +1054 -0
  144. package/dist/{zip-export-C0_ErAqT.d.ts → zip-export-G14hkSPG.d.ts} +1 -1
  145. package/package.json +2 -2
  146. package/dist/depth-sampler-CqrkRr2N.d.ts +0 -108
  147. package/dist/file-system-utils-BH6uIs2i.d.ts +0 -24
  148. /package/dist/{accuracy-circles-DEKr0Hoh.d.ts → accuracy-circles-BpwWAs8G.d.ts} +0 -0
  149. /package/dist/{alignment-lerper-CCZJf-Xb.d.ts → alignment-lerper-Dm9G7JJO.d.ts} +0 -0
  150. /package/dist/{camera-follower-B-nS1sr6.d.ts → camera-follower-C2mwEn7c.d.ts} +0 -0
  151. /package/dist/{capability-checker-BTCmZRL4.d.ts → capability-checker-CQXJHxXg.d.ts} +0 -0
  152. /package/dist/{capture-failure-tracker-D4HYJNOj.d.ts → capture-failure-tracker-CUeU3L_M.d.ts} +0 -0
  153. /package/dist/{chromium-camera-access-workaround-MifIwK9x.d.ts → chromium-camera-access-workaround-Jqbn0apH.d.ts} +0 -0
  154. /package/dist/{concurrency-Bsmv53qw.d.ts → concurrency-CO1FQvs3.d.ts} +0 -0
  155. /package/dist/{css3d-renderer-manager-EDED3nvg.d.ts → css3d-renderer-manager-ptu_t3B3.d.ts} +0 -0
  156. /package/dist/{failure-tracker-D7ELffm0.d.ts → failure-tracker-Cn8Fo9D4.d.ts} +0 -0
  157. /package/dist/{format-file-size-B7gJb3Md.d.ts → format-file-size-CLCZz3lC.d.ts} +0 -0
  158. /package/dist/{frame-loop-BTeRpDm4.d.ts → frame-loop-CMewS8cY.d.ts} +0 -0
  159. /package/dist/{frustum-visibility-DGnJqls0.d.ts → frustum-visibility-CmI96ESS.d.ts} +0 -0
  160. /package/dist/{geo-types-clgzl8b5.d.ts → geo-types-SaPnIEUM.d.ts} +0 -0
  161. /package/dist/{gps-B7AlMPz5.d.ts → gps-VuezJWK3.d.ts} +0 -0
  162. /package/dist/{gps-compass-cubes-Ys6Hf1nc.d.ts → gps-compass-cubes-BM1uGiYV.d.ts} +0 -0
  163. /package/dist/{gps-error-handler-BLDhUlw5.d.ts → gps-error-handler-CJl_2MVm.d.ts} +0 -0
  164. /package/dist/{gps-event-markers-BlHq3jYV.d.ts → gps-event-markers-Dc9FT-q1.d.ts} +0 -0
  165. /package/dist/{h3-proximity-DJnghtir.d.ts → h3-proximity-I1msGPgE.d.ts} +0 -0
  166. /package/dist/{hit-test-reticle-BGXOxrUh.d.ts → hit-test-reticle-BEzEybxZ.d.ts} +0 -0
  167. /package/dist/{index--ldLph4V.d.ts → index-CF05QV2m.d.ts} +0 -0
  168. /package/dist/{lerp-utils-DNcSmKVX.d.ts → lerp-utils-BpV-0Dtk.d.ts} +0 -0
  169. /package/dist/{list-formatter-CsqcU4v5.d.ts → list-formatter-RRcBe8Vg.d.ts} +0 -0
  170. /package/dist/{logger-BZ44Dhd3.d.ts → logger-BEHXsnnq.d.ts} +0 -0
  171. /package/dist/{map-overlay-yrFsUtGv.d.ts → map-overlay-bcogY5fK.d.ts} +0 -0
  172. /package/dist/{permission-checker-CfV7INCa.d.ts → permission-checker-GpAiSCwd.d.ts} +0 -0
  173. /package/dist/{scene-node-names-IHW7HR4S.d.ts → scene-node-names-Bu0S66H1.d.ts} +0 -0
  174. /package/dist/{three-dispose-WTBAfaWU.d.ts → three-dispose-D950t71B.d.ts} +0 -0
  175. /package/dist/{vis-colors-Djnyg_UH.d.ts → vis-colors-D6omC64K.d.ts} +0 -0
  176. /package/dist/{index-22v8MXJX.d.ts → webxr-nue-basis-CqKQmZzT.d.ts} +0 -0
  177. /package/dist/{xr-camera-texture-BU5YvKAM.d.ts → xr-camera-texture-DBwCaKTd.d.ts} +0 -0
  178. /package/dist/{xr-error-handler-DcUFshNF.d.ts → xr-error-handler-C0AQRmxs.d.ts} +0 -0
  179. /package/dist/{xr-frame-loop-BKckC7xC.d.ts → xr-frame-loop-BtYytZJg.d.ts} +0 -0
  180. /package/dist/{zip-reader-B2lzN8F5.d.ts → zip-reader-DSEXt7K6.d.ts} +0 -0
@@ -0,0 +1,1054 @@
1
+ import { createLogger } from "./utils/logger.js";
2
+ import { CameraBlitCapture, computeCaptureSize } from "./ar/camera-blit-capture.js";
3
+ import { getLastDeviceOrientation } from "./state/gps-event-coordinator.js";
4
+ import { DepthSampler, wrapXRDepthInfo } from "./ar/depth-sampler.js";
5
+ import { clearFrameUpdates, runFrameUpdates } from "./ar/frame-loop.js";
6
+ import { clearXrFrameUpdates, runXrFrameUpdates } from "./ar/xr-frame-loop.js";
7
+ import { WEBXR_TO_NUE } from "./ar/webxr-nue-basis.js";
8
+ import { DEFAULT_CAPTURE_CONFIG, ImageCaptureManager } from "./ar/image-capture.js";
9
+ import { clearLastRestartedPayload, originReset, poseLost, poseReceived, resetTracking, selectLastRestartedPayload, selectTrackingPhase } from "./state/tracking-slice.js";
10
+ import { acquireCameraTexture } from "./ar/xr-camera-texture.js";
11
+ import { n as runSessionDisposers } from "./session-disposers-M-oashRH.js";
12
+ import { DEFAULT_RECORDING_OPTIONS, validateArCrashIsolationOptions } from "./state/recording-options.js";
13
+ import { SCENE_NODE } from "./ar/scene-node-names.js";
14
+ import { createCss3dRendererManager } from "./visualization/css3d-renderer-manager.js";
15
+ import * as THREE from "three";
16
+ import { nueQuaternionToWebXR, nueToWebXR } from "gps-plus-slam-js";
17
+ //#region ../src/ar/depth-rgb-lookup.ts
18
+ /**
19
+ * Build an {@link RgbLookup} over an RGBA readback buffer.
20
+ *
21
+ * Defensive boundary: returns `null` (no lookup) when the dimensions are
22
+ * not positive integers or the buffer length does not match
23
+ * `width × height × 4` — callers then simply emit color-less points
24
+ * rather than crashing the capture path.
25
+ */
26
+ function createRgbLookup(pixels, width, height) {
27
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || pixels.length !== width * height * 4) return null;
28
+ return (screenX, screenY) => {
29
+ if (!Number.isFinite(screenX) || !Number.isFinite(screenY) || screenX < 0 || screenX > 1 || screenY < 0 || screenY > 1) return null;
30
+ const col = Math.min(width - 1, Math.floor(screenX * width));
31
+ const rowFromTop = Math.min(height - 1, Math.floor(screenY * height));
32
+ const i = ((height - 1 - rowFromTop) * width + col) * 4;
33
+ return [
34
+ pixels[i],
35
+ pixels[i + 1],
36
+ pixels[i + 2]
37
+ ];
38
+ };
39
+ }
40
+ //#endregion
41
+ //#region ../src/ar/webxr-session.ts
42
+ /**
43
+ * WebXR Session Module
44
+ *
45
+ * Handles WebXR AR session initialization and frame loop.
46
+ *
47
+ * ARCHITECTURE NOTE: See docs/architecture-ar-gps-pose-separation.md
48
+ *
49
+ * Scene Hierarchy:
50
+ * scene (GPS world frame — NUE: X=North, Y=Up, Z=East)
51
+ * └── arWorldGroup (local space = NUE; receives alignment matrix)
52
+ * └── basisChangeNode ('webxr-to-nue', constant WEBXR_TO_NUE matrix)
53
+ * └── arpose (local space = WebXR)
54
+ * └── camera (raw AR pose from WebXR)
55
+ *
56
+ * The arWorldGroup is transformed by the GpsPlusSlamJs alignment matrix.
57
+ * The camera's LOCAL transform is the raw AR pose from WebXR.
58
+ * The camera's WORLD transform is the GPS-aligned world pose.
59
+ */
60
+ const log = createLogger("WebXR");
61
+ function isXRCameraLike(value) {
62
+ if (typeof value !== "object" || value === null) return false;
63
+ const camera = value;
64
+ return typeof camera.width === "number" && Number.isFinite(camera.width) && camera.width > 0 && typeof camera.height === "number" && Number.isFinite(camera.height) && camera.height > 0;
65
+ }
66
+ /**
67
+ * Extract a validated XRCameraLike from an XRViewerPose.
68
+ *
69
+ * Returns null in every case where the per-frame texture acquisition must be
70
+ * skipped — i.e. whenever the caller should treat any previously cached
71
+ * texture as stale and clear it. Centralising the preconditions here makes
72
+ * it impossible to accidentally fall through without clearing the cache.
73
+ *
74
+ * Cases that return null:
75
+ * - pose is null (tracking lost)
76
+ * - pose has no views
77
+ * - the first view has no `camera` property (camera-access not granted)
78
+ * - the camera property is not a valid XRCameraLike (zero/NaN dimensions)
79
+ *
80
+ * @see docs/2026-02-06-bug-camera-frames-black.md
81
+ */
82
+ function getXrCameraFromPose(pose) {
83
+ if (!pose) return null;
84
+ const view = pose.views[0];
85
+ if (!view) return null;
86
+ const candidate = view.camera;
87
+ return isXRCameraLike(candidate) ? candidate : null;
88
+ }
89
+ /**
90
+ * Decide whether the one-time camera-access grant diagnostic should fire
91
+ * on the current XR frame.
92
+ *
93
+ * The diagnostic is only meaningful when we actually have a valid pose:
94
+ * if `pose` is null (e.g. session starts with tracking lost), then the
95
+ * derived `xrCamera` is null regardless of permission state, and logging
96
+ * "NOT GRANTED" would be a false negative that permanently suppresses the
97
+ * correct status because `cameraAccessLoggedOnce` would latch to true.
98
+ *
99
+ * Additional conditions:
100
+ * - `alreadyLogged` prevents spamming the log every frame.
101
+ * - `captureActive` ensures we only care when an image capture session
102
+ * is running (otherwise the diagnostic has no audience).
103
+ *
104
+ * This predicate is extracted as a pure function so the guard against the
105
+ * "first frame has no pose" false-negative regression is directly testable.
106
+ */
107
+ function shouldLogCameraAccessDiagnostic(pose, alreadyLogged, captureActive) {
108
+ return pose !== null && !alreadyLogged && captureActive;
109
+ }
110
+ let renderer = null;
111
+ let scene = null;
112
+ let camera = null;
113
+ let xrSession = null;
114
+ /**
115
+ * Monotonic time of the previous XR frame, in milliseconds (XR `time`
116
+ * argument). Reset to 0 by `resetWebXRState()` so the first frame of a
117
+ * new session sees `dt = 0` rather than a stale delta from the prior
118
+ * session.
119
+ */
120
+ let lastFrameTime = 0;
121
+ /**
122
+ * Reset WebXR module state - exported for testing only.
123
+ * @internal
124
+ */
125
+ function resetWebXRState() {
126
+ if (renderer) {
127
+ renderer.setAnimationLoop(null);
128
+ if (renderer.domElement.parentElement) renderer.domElement.parentElement.removeChild(renderer.domElement);
129
+ renderer.dispose();
130
+ }
131
+ renderer = null;
132
+ scene = null;
133
+ camera = null;
134
+ xrSession = null;
135
+ arWorldGroup = null;
136
+ arPoseNode = null;
137
+ latestArPose = null;
138
+ lastFrameTime = 0;
139
+ clearFrameUpdates();
140
+ clearXrFrameUpdates();
141
+ runSessionDisposers();
142
+ imageCaptureManager = null;
143
+ onImageCaptured = null;
144
+ getScreenRotation = null;
145
+ onCaptureFailed = null;
146
+ onSuspiciousImage = null;
147
+ if (trackingPhaseUnsubscribe) {
148
+ trackingPhaseUnsubscribe();
149
+ trackingPhaseUnsubscribe = null;
150
+ }
151
+ trackingStore = null;
152
+ onTrackingRestarted = null;
153
+ onTrackingLost = null;
154
+ onTrackingRecovered = null;
155
+ depthSampler = null;
156
+ onDepthCaptured = null;
157
+ onDepthUnavailable = null;
158
+ if (depthRgbBlit) {
159
+ depthRgbBlit.dispose();
160
+ depthRgbBlit = null;
161
+ }
162
+ onFrameCallback = null;
163
+ if (css3dManager) {
164
+ css3dManager.dispose();
165
+ css3dManager = null;
166
+ }
167
+ cameraAccessLoggedOnce = false;
168
+ getCameraTextureNullCount = 0;
169
+ latestCameraWidth = 0;
170
+ latestCameraHeight = 0;
171
+ currentArCrashIsolationOptions = { ...DEFAULT_RECORDING_OPTIONS.arCrashIsolation };
172
+ cleanupBlitResources();
173
+ }
174
+ /**
175
+ * The AR world group - parent of camera and all AR-tracked content.
176
+ * This group's transform = the alignment matrix from GpsPlusSlamJs.
177
+ * When the library computes a new alignment, apply it to this group.
178
+ */
179
+ let arWorldGroup = null;
180
+ /**
181
+ * The arpose Object3D — intermediate node between arWorldGroup and camera.
182
+ * During recording it stays at identity; during replay it receives recorded
183
+ * odomPosition/odomRotation from the store subscriber.
184
+ */
185
+ let arPoseNode = null;
186
+ /**
187
+ * Stores the latest raw AR pose from WebXR (updated every frame).
188
+ * This is read by the GPS callback to create paired GPS+AR events.
189
+ */
190
+ let latestArPose = null;
191
+ /**
192
+ * Image capture manager instance (created when AR session starts)
193
+ */
194
+ let imageCaptureManager = null;
195
+ /**
196
+ * Callback for when an image is captured (set via setImageCaptureCallback)
197
+ */
198
+ let onImageCaptured = null;
199
+ /**
200
+ * Callback for when image capture fails (set via setImageCaptureCallback)
201
+ */
202
+ let onCaptureFailed = null;
203
+ /**
204
+ * Callback for when a captured image appears suspicious/black
205
+ * (set via setImageCaptureCallback)
206
+ */
207
+ let onSuspiciousImage = null;
208
+ /**
209
+ * Screen rotation getter (set via setImageCaptureCallback)
210
+ */
211
+ let getScreenRotation = null;
212
+ /**
213
+ * Redux store injected by the host (`setTrackingStore`). When present
214
+ * together with `onTrackingRestarted`, `onXRFrame` dispatches
215
+ * `poseReceived`/`poseLost`, the XR reference-space reset listener
216
+ * dispatches `originReset`, and a `subscribeToSelector` translation
217
+ * surface translates phase transitions back into the existing
218
+ * `onTrackingLost` / `onTrackingRestarted` / `onTrackingRecovered`
219
+ * callbacks. When the store is absent the tracking pipeline simply
220
+ * no-ops.
221
+ */
222
+ let trackingStore = null;
223
+ /**
224
+ * Unsubscribe handle returned by the phase subscription set up in
225
+ * `initAR`. Cleared in `resetWebXRState` and on session-end so we never
226
+ * leave dangling listeners on a stale store.
227
+ */
228
+ let trackingPhaseUnsubscribe = null;
229
+ /**
230
+ * Callback for when tracking restarts (set via setTrackingCallbacks)
231
+ */
232
+ let onTrackingRestarted = null;
233
+ /**
234
+ * Callback for when tracking is lost (set via setTrackingLostCallback)
235
+ * Field Test Readiness Issue #3: Provide user feedback when tracking is lost
236
+ */
237
+ let onTrackingLost = null;
238
+ /**
239
+ * Callback for when tracking recovers seamlessly without origin reset (Case 1).
240
+ * Set via setTrackingRecoveredCallback.
241
+ */
242
+ let onTrackingRecovered = null;
243
+ /**
244
+ * Depth sampler instance (created when AR session starts with depth callbacks)
245
+ */
246
+ let depthSampler = null;
247
+ /**
248
+ * Callback for when a depth sample is captured (set via setDepthCaptureCallback)
249
+ */
250
+ let onDepthCaptured = null;
251
+ /**
252
+ * Callback for when depth sensing is determined to be unavailable.
253
+ * Field Test Readiness Issue #8: Notify user if depth was requested but not granted.
254
+ */
255
+ let onDepthUnavailable = null;
256
+ /**
257
+ * Per-frame callback for custom updates (e.g., map overlay position).
258
+ * Called every XR frame after pose updates but before render.
259
+ */
260
+ let onFrameCallback = null;
261
+ /**
262
+ * CSS3D renderer manager for rendering DOM-based 3D objects (e.g., Leaflet map)
263
+ * alongside the WebGL render. Created in initAR(), disposed in resetWebXRState().
264
+ */
265
+ let css3dManager = null;
266
+ /**
267
+ * Camera blit capture instance for reading WebXR opaque camera textures.
268
+ * Created when image capture starts, disposed when it stops.
269
+ * @see docs/2026-02-06-bug-camera-frames-black.md
270
+ */
271
+ let blitCapture = null;
272
+ /**
273
+ * Dedicated small blit target for per-depth-sample RGB lookups
274
+ * (occupancy-grid port plan Iter 8). Separate from `blitCapture`: the JPEG
275
+ * path resizes to (camera resolution ÷ divisor) while this one stays tiny —
276
+ * only ≤ gridSize² positions are ever read from it, so 256×192 suffices and
277
+ * keeps the 1 Hz readback stall negligible. Created lazily on the first
278
+ * sample that needs it (no GPU allocation when the rgb option is off),
279
+ * disposed by resetWebXRState().
280
+ */
281
+ let depthRgbBlit = null;
282
+ /** Readback size for the depth-RGB blit (plan §5: "e.g. 256×192 suffices"). */
283
+ const DEPTH_RGB_BLIT_CONFIG = {
284
+ width: 256,
285
+ height: 192
286
+ };
287
+ /**
288
+ * Latest WebXR camera texture, updated each frame when camera-access is enabled.
289
+ * Acquired via Three.js's renderer.xr.getCameraTexture() API (ExternalTexture).
290
+ * @see xr-camera-texture.ts
291
+ */
292
+ let latestCameraTexture = null;
293
+ /**
294
+ * Latest camera frame dimensions from XRCamera (native resolution).
295
+ * Used to dynamically resize the blit render target for full-quality captures.
296
+ */
297
+ let latestCameraWidth = 0;
298
+ let latestCameraHeight = 0;
299
+ /**
300
+ * Track whether camera-access diagnostic status has been logged.
301
+ * Reset on each new session via resetWebXRState().
302
+ */
303
+ let cameraAccessLoggedOnce = false;
304
+ /**
305
+ * Counter for throttled getCameraTexture diagnostic logging.
306
+ * Only logs the first few null returns to avoid log spam.
307
+ */
308
+ let getCameraTextureNullCount = 0;
309
+ const GET_CAMERA_TEXTURE_LOG_THRESHOLD = 5;
310
+ let currentArCrashIsolationOptions = { ...DEFAULT_RECORDING_OPTIONS.arCrashIsolation };
311
+ /**
312
+ * Dispose blit capture resources and clear the cached camera texture.
313
+ * Shared by resetWebXRState() and stopImageCapture() to avoid duplication.
314
+ */
315
+ function cleanupBlitResources() {
316
+ if (blitCapture) {
317
+ blitCapture.dispose();
318
+ blitCapture = null;
319
+ }
320
+ latestCameraTexture = null;
321
+ }
322
+ /**
323
+ * Acquire a camera-color lookup for the current XR frame (passed to the
324
+ * DepthSampler as `acquireRgbLookup`; called at most once per emitted
325
+ * sample). Returns null — color-less points — when camera access or the
326
+ * readback is unavailable; the blit instance lazily (re)creates itself so
327
+ * a disposal elsewhere is self-healing.
328
+ */
329
+ function acquireDepthRgbLookup() {
330
+ if (!renderer || !latestCameraTexture) return null;
331
+ depthRgbBlit ??= new CameraBlitCapture(DEPTH_RGB_BLIT_CONFIG);
332
+ const readback = depthRgbBlit.captureToPixels(renderer, latestCameraTexture);
333
+ return readback ? createRgbLookup(readback.pixels, readback.width, readback.height) : null;
334
+ }
335
+ /**
336
+ * Extract the reset transform from an XRReferenceSpaceEvent-like object.
337
+ *
338
+ * Distinguishes three cases per OdometryTrackingRestartedPayload semantics:
339
+ * - Transform property missing (older browsers) → returns `undefined`
340
+ * - Transform property present but null (runtime can't determine delta) → returns `null`
341
+ * - Transform property present with data → returns `ResetTransformData`
342
+ *
343
+ * This is a pure function extracted for testability.
344
+ *
345
+ * @param event - The event object, cast to a record with an optional transform property
346
+ * @returns ResetTransformData, null, or undefined
347
+ */
348
+ function extractResetTransformData(event) {
349
+ if (!("transform" in event)) return;
350
+ const transform = event.transform;
351
+ if (!transform) return null;
352
+ const pos = transform.position;
353
+ const ori = transform.orientation;
354
+ return {
355
+ position: [
356
+ pos.x,
357
+ pos.y,
358
+ pos.z
359
+ ],
360
+ orientation: [
361
+ ori.x,
362
+ ori.y,
363
+ ori.z,
364
+ ori.w
365
+ ]
366
+ };
367
+ }
368
+ /**
369
+ * Extract pose data from an XRViewerPose.
370
+ * Returns null if pose or views are unavailable.
371
+ *
372
+ * This is a pure function extracted for testability.
373
+ *
374
+ * @param pose - The XRViewerPose from frame.getViewerPose()
375
+ * @returns ARPose with position and orientation, or null
376
+ */
377
+ function extractPoseFromViewer(pose) {
378
+ if (!pose) return null;
379
+ const view = pose.views[0];
380
+ if (!view) return null;
381
+ const { position, orientation } = view.transform;
382
+ return {
383
+ position: {
384
+ x: position.x,
385
+ y: position.y,
386
+ z: position.z
387
+ },
388
+ orientation: {
389
+ x: orientation.x,
390
+ y: orientation.y,
391
+ z: orientation.z,
392
+ w: orientation.w
393
+ }
394
+ };
395
+ }
396
+ /**
397
+ * Get the current raw AR pose from the latest XR frame.
398
+ * This is updated every frame and should be called when GPS arrives
399
+ * to get the AR pose at that moment.
400
+ *
401
+ * IMPORTANT: This returns the RAW pose from WebXR, NOT transformed
402
+ * by any alignment matrix. This is what we record to the store.
403
+ *
404
+ * @returns The latest AR pose, or null if no pose available yet
405
+ */
406
+ function getCurrentArPose() {
407
+ return latestArPose;
408
+ }
409
+ /**
410
+ * Build XR session init options.
411
+ * Extracted as a pure function for testability.
412
+ *
413
+ * @param rootElement - The DOM element for DOM overlay
414
+ * @param isolationOptions - Crash-isolation diagnostic flags (DOM overlay,
415
+ * depth-sensing, camera-access)
416
+ * @param sessionFeatures - Opt-in standard WebXR features that are independent
417
+ * of crash isolation (currently `requestHitTest`)
418
+ * @returns XRSessionInit options
419
+ * @throws Error if rootElement is null
420
+ */
421
+ function buildSessionOptions(rootElement, isolationOptions = {}, sessionFeatures = {}) {
422
+ if (!rootElement) throw new Error("App root element not found");
423
+ const normalizedOptions = validateArCrashIsolationOptions(isolationOptions);
424
+ const optionalFeatures = [];
425
+ const sessionOptions = { requiredFeatures: ["local-floor"] };
426
+ if (normalizedOptions.enableDomOverlay) {
427
+ optionalFeatures.push("dom-overlay");
428
+ sessionOptions.domOverlay = { root: rootElement };
429
+ }
430
+ if (normalizedOptions.enableDepthSensingFeature) {
431
+ optionalFeatures.push("depth-sensing");
432
+ Object.assign(sessionOptions, { depthSensing: {
433
+ usagePreference: ["cpu-optimized"],
434
+ dataFormatPreference: ["luminance-alpha", "float32"]
435
+ } });
436
+ }
437
+ if (normalizedOptions.enableCameraAccess) optionalFeatures.push("camera-access");
438
+ if (sessionFeatures.requestHitTest) optionalFeatures.push("hit-test");
439
+ if (optionalFeatures.length > 0) sessionOptions.optionalFeatures = optionalFeatures;
440
+ return sessionOptions;
441
+ }
442
+ /**
443
+ * Check if WebXR immersive-ar is supported
444
+ */
445
+ async function isWebXRSupported() {
446
+ if (!navigator.xr) return false;
447
+ try {
448
+ return await navigator.xr.isSessionSupported("immersive-ar");
449
+ } catch {
450
+ return false;
451
+ }
452
+ }
453
+ /**
454
+ * Create the scene hierarchy with proper AR/GPS frame separation.
455
+ * This is a pure function for testability.
456
+ *
457
+ * Hierarchy:
458
+ * scene (GPS world frame — NUE: X=North, Y=Up, Z=East)
459
+ * ├── ambientLight
460
+ * ├── directionalLight
461
+ * └── arWorldGroup (local space = NUE; receives alignment matrix)
462
+ * └── basisChangeNode ('webxr-to-nue', constant WEBXR_TO_NUE matrix)
463
+ * └── arpose (Object3D — AR pose; local space = WebXR)
464
+ * └── camera (PerspectiveCamera)
465
+ *
466
+ * basisChangeNode is a static scene-graph node that holds the WEBXR_TO_NUE
467
+ * basis-change matrix permanently (matrixAutoUpdate=false). Moving it here
468
+ * instead of composing it in applyAlignmentMatrix() keeps arWorldGroup's
469
+ * local space in the **NUE axis convention** (X=North, Y=Up, Z=East), so no
470
+ * WebXR↔NUE swizzle is needed for children.
471
+ *
472
+ * CAUTION — two NUE frames: arWorldGroup's local space is the *AR-odometry*
473
+ * NUE frame, i.e. the **domain** of the alignment matrix, NOT the GPS-world
474
+ * NUE frame of the scene root. Only content authored in AR-odometry
475
+ * coordinates (e.g. the camera subtree) may be placed with raw local values.
476
+ * GPS-world content (a lat/lon → NUE point) is expressed in the scene-root
477
+ * frame and must be pre-multiplied by alignment⁻¹ before being used as a
478
+ * local position under arWorldGroup — see createGpsAnchor and the
479
+ * alignment-frame bug doc
480
+ * (GpsPlusSlamJs_Docs/docs/2026-05-31-gps-anchor-alignment-frame-bug.md).
481
+ *
482
+ * - Recording: arpose stays at identity; WebXRManager writes to camera.
483
+ * - Replay: arpose receives recorded odomPosition/odomRotation;
484
+ * camera is owned by user controls (OrbitControls / FPS).
485
+ *
486
+ * @returns Object containing scene, arWorldGroup, arpose, and camera
487
+ */
488
+ function createSceneHierarchy() {
489
+ const newScene = new THREE.Scene();
490
+ const newArWorldGroup = new THREE.Group();
491
+ newArWorldGroup.name = "ar-world";
492
+ newScene.add(newArWorldGroup);
493
+ const newBasisChangeNode = new THREE.Group();
494
+ newBasisChangeNode.name = SCENE_NODE.BASIS_CHANGE;
495
+ newBasisChangeNode.matrix.copy(WEBXR_TO_NUE);
496
+ newBasisChangeNode.matrixAutoUpdate = false;
497
+ newArWorldGroup.add(newBasisChangeNode);
498
+ const newArPose = new THREE.Object3D();
499
+ newArPose.name = "ar-pose";
500
+ newBasisChangeNode.add(newArPose);
501
+ const newCamera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, .01, 100);
502
+ newArPose.add(newCamera);
503
+ const ambientLight = new THREE.AmbientLight(16777215, .5);
504
+ newScene.add(ambientLight);
505
+ const directionalLight = new THREE.DirectionalLight(16777215, .8);
506
+ directionalLight.position.set(0, 10, 5);
507
+ newScene.add(directionalLight);
508
+ return {
509
+ scene: newScene,
510
+ arWorldGroup: newArWorldGroup,
511
+ arpose: newArPose,
512
+ camera: newCamera
513
+ };
514
+ }
515
+ /**
516
+ * Initialize the AR session and Three.js renderer.
517
+ * @param container - DOM element to host the AR canvas and CSS3D overlay.
518
+ * @param isolationOptions - Crash-isolation diagnostic flags.
519
+ * @param sessionFeatures - Opt-in standard WebXR features (e.g.
520
+ * `requestHitTest`) forwarded to the session negotiation.
521
+ */
522
+ async function initAR(container, isolationOptions = {}, sessionFeatures = {}) {
523
+ if (!navigator.xr) throw new Error("WebXR not available");
524
+ if (renderer || xrSession) throw new Error("AR session already initialized — call endARSession() before initAR() again");
525
+ currentArCrashIsolationOptions = validateArCrashIsolationOptions(isolationOptions);
526
+ renderer = new THREE.WebGLRenderer({
527
+ antialias: true,
528
+ alpha: true
529
+ });
530
+ renderer.setPixelRatio(window.devicePixelRatio);
531
+ renderer.setSize(window.innerWidth, window.innerHeight);
532
+ renderer.xr.enabled = true;
533
+ container.insertBefore(renderer.domElement, container.firstChild);
534
+ if (currentArCrashIsolationOptions.enableCss3dRenderer) css3dManager = createCss3dRendererManager(container, window.innerWidth, window.innerHeight);
535
+ const hierarchy = createSceneHierarchy();
536
+ scene = hierarchy.scene;
537
+ arWorldGroup = hierarchy.arWorldGroup;
538
+ arPoseNode = hierarchy.arpose;
539
+ camera = hierarchy.camera;
540
+ const sessionOptions = buildSessionOptions(container, currentArCrashIsolationOptions, sessionFeatures);
541
+ xrSession = await navigator.xr.requestSession("immersive-ar", sessionOptions);
542
+ xrSession.addEventListener("end", () => {
543
+ log.info("Session ended");
544
+ if (trackingStore) trackingStore.dispatch(resetTracking());
545
+ xrSession = null;
546
+ latestArPose = null;
547
+ });
548
+ await renderer.xr.setSession(xrSession);
549
+ if (onTrackingRestarted && trackingStore) {
550
+ const store = trackingStore;
551
+ store.dispatch(resetTracking());
552
+ trackingPhaseUnsubscribe = subscribeToTrackingPhase(store);
553
+ const referenceSpace = renderer.xr.getReferenceSpace();
554
+ if (referenceSpace) referenceSpace.addEventListener("reset", (event) => {
555
+ const transformData = extractResetTransformData(event);
556
+ store.dispatch(originReset(transformData));
557
+ log.warn("XR reference space reset detected", transformData ? "(transform available)" : "(no transform)");
558
+ });
559
+ }
560
+ if (onDepthCaptured) depthSampler = new DepthSampler({
561
+ onSampleCaptured: onDepthCaptured,
562
+ getCurrentPose: getCurrentArPose,
563
+ acquireRgbLookup: acquireDepthRgbLookup,
564
+ onDepthUnavailable: onDepthUnavailable ?? void 0
565
+ });
566
+ renderer.setAnimationLoop(onXRFrame);
567
+ log.info("AR session started");
568
+ }
569
+ /**
570
+ * Snapshot the current `DeviceOrientation` (with documented fallback
571
+ * defaults) for inclusion in `poseReceived` payloads.
572
+ */
573
+ function snapshotDeviceOrientation() {
574
+ const orientation = getLastDeviceOrientation();
575
+ return {
576
+ alpha: orientation?.alpha ?? 0,
577
+ beta: orientation?.beta ?? 0,
578
+ gamma: orientation?.gamma ?? 0,
579
+ absolute: orientation?.absolute ?? false
580
+ };
581
+ }
582
+ /**
583
+ * Wire the tracking-slice → host-callbacks translation. The subscriber
584
+ * runs synchronously inside each `dispatch`, so the host callbacks fire
585
+ * in the same order as a direct invocation would.
586
+ *
587
+ * Translation rules (locked in by tracking-slice tests):
588
+ * - `tracking → lost`: clear `latestArPose` (drops in-flight GPS events)
589
+ * and call `onTrackingLost?.()`.
590
+ * - `lost → tracking` with `lastRestartedPayload !== null` (Case 2):
591
+ * call `onTrackingRestarted?.(payload)` then dispatch
592
+ * `clearLastRestartedPayload` so a subsequent loss cycle starts clean.
593
+ * - `lost → tracking` with payload null (Case 1: seamless recovery):
594
+ * call `onTrackingRecovered?.()`.
595
+ * - `initializing → tracking`: no callback (initial acquisition is not
596
+ * a restart — same behaviour as the manager).
597
+ */
598
+ function subscribeToTrackingPhase(store) {
599
+ let prev = "initializing";
600
+ return store.subscribe(() => {
601
+ const next = selectTrackingPhase(store.getState());
602
+ if (next === prev) return;
603
+ const previous = prev;
604
+ prev = next;
605
+ if (previous === "tracking" && next === "lost") {
606
+ log.warn("Tracking lost");
607
+ latestArPose = null;
608
+ onTrackingLost?.();
609
+ return;
610
+ }
611
+ if (previous === "lost" && next === "tracking") {
612
+ const payload = selectLastRestartedPayload(store.getState());
613
+ if (payload !== null) {
614
+ log.info("Tracking restarted (origin reset)");
615
+ onTrackingRestarted?.(payload);
616
+ store.dispatch(clearLastRestartedPayload());
617
+ } else {
618
+ log.info("Tracking recovered (same coordinate frame)");
619
+ onTrackingRecovered?.();
620
+ }
621
+ }
622
+ });
623
+ }
624
+ /**
625
+ * Dispatch the per-frame `poseReceived` / `poseLost` action into the
626
+ * tracking slice. No-op when no store is bound or when tracking wiring
627
+ * was not requested.
628
+ */
629
+ function updateTrackingState(arPose) {
630
+ if (!trackingStore || !onTrackingRestarted) return;
631
+ if (arPose) trackingStore.dispatch(poseReceived({
632
+ pose: arPose,
633
+ sensorOrientation: snapshotDeviceOrientation()
634
+ }));
635
+ else trackingStore.dispatch(poseLost());
636
+ }
637
+ /**
638
+ * Called each XR frame
639
+ */
640
+ function onXRFrame(time, frame) {
641
+ if (!renderer || !scene || !camera || !frame) return;
642
+ const referenceSpace = renderer.xr.getReferenceSpace();
643
+ if (!referenceSpace) return;
644
+ const pose = frame.getViewerPose(referenceSpace);
645
+ const arPose = extractPoseFromViewer(pose);
646
+ updateTrackingState(arPose);
647
+ const dt = lastFrameTime === 0 ? 0 : (time - lastFrameTime) / 1e3;
648
+ const elapsed = time / 1e3;
649
+ lastFrameTime = time;
650
+ runFrameUpdates(dt, elapsed);
651
+ if (xrSession) runXrFrameUpdates({
652
+ frame,
653
+ referenceSpace,
654
+ session: xrSession,
655
+ dt,
656
+ elapsed
657
+ });
658
+ if (arPose) latestArPose = arPose;
659
+ latestCameraTexture = null;
660
+ if (currentArCrashIsolationOptions.enableCameraTextureAcquisition) {
661
+ const xrCamera = getXrCameraFromPose(pose);
662
+ if (shouldLogCameraAccessDiagnostic(pose, cameraAccessLoggedOnce, imageCaptureManager !== null)) {
663
+ cameraAccessLoggedOnce = true;
664
+ if (xrCamera) log.info("camera-access GRANTED — XRView.camera is available for blit capture");
665
+ else log.warn("camera-access NOT GRANTED — XRView.camera is undefined. Image capture will fall back to canvas.toBlob() which may produce black/empty frames in WebXR.");
666
+ }
667
+ if (xrCamera) {
668
+ const result = acquireCameraTexture(renderer, xrCamera);
669
+ if (result) {
670
+ latestCameraTexture = result.texture;
671
+ latestCameraWidth = result.width;
672
+ latestCameraHeight = result.height;
673
+ } else {
674
+ getCameraTextureNullCount++;
675
+ if (getCameraTextureNullCount <= GET_CAMERA_TEXTURE_LOG_THRESHOLD) log.warn(`getCameraTexture() returned null (occurrence ${getCameraTextureNullCount}/${GET_CAMERA_TEXTURE_LOG_THRESHOLD}). camera-access is granted but Three.js did not provide a texture.`);
676
+ }
677
+ }
678
+ }
679
+ if (imageCaptureManager) imageCaptureManager.onFrame(time);
680
+ if (depthSampler) {
681
+ const depthInfo = getDepthInfoFromFrame(frame, pose);
682
+ depthSampler.onFrame(time, depthInfo);
683
+ }
684
+ if (onFrameCallback) try {
685
+ onFrameCallback();
686
+ } catch (error) {
687
+ log.error("Error in onFrameCallback:", error);
688
+ }
689
+ renderer.render(scene, camera);
690
+ if (currentArCrashIsolationOptions.enableCss3dRenderer && css3dManager) css3dManager.render(scene, camera);
691
+ }
692
+ /**
693
+ * Extract depth information from an XR frame.
694
+ * Returns null if depth sensing is not available.
695
+ */
696
+ function getDepthInfoFromFrame(frame, pose) {
697
+ const view = pose?.views[0];
698
+ if (!view) return null;
699
+ const xrFrame = frame;
700
+ if (typeof xrFrame.getDepthInformation !== "function") return null;
701
+ try {
702
+ const result = xrFrame.getDepthInformation(view);
703
+ if (!result) return null;
704
+ return wrapXRDepthInfo(result, view.projectionMatrix);
705
+ } catch {
706
+ return null;
707
+ }
708
+ }
709
+ /**
710
+ * Get the current Three.js scene (for adding objects like map)
711
+ */
712
+ function getScene() {
713
+ return scene;
714
+ }
715
+ /**
716
+ * Get the AR world group (for adding AR-tracked content)
717
+ * Content added here will be transformed by the alignment matrix.
718
+ */
719
+ function getArWorldGroup() {
720
+ return arWorldGroup;
721
+ }
722
+ /**
723
+ * Get the current camera
724
+ */
725
+ function getCamera() {
726
+ return camera;
727
+ }
728
+ /**
729
+ * Set the scene externally (for replay mode).
730
+ * Allows non-WebXR code paths to register a scene so that modules
731
+ * calling getScene() receive it.
732
+ * @see docs/2026-02-19-replay-mode.md Risk R1
733
+ */
734
+ function setScene(s) {
735
+ scene = s;
736
+ }
737
+ /**
738
+ * Set the AR world group externally (for replay mode).
739
+ * Allows non-WebXR code paths to register an arWorldGroup so that
740
+ * applyAlignmentMatrix() and visualizers work correctly.
741
+ * @see docs/2026-02-19-replay-mode.md Risk R1
742
+ */
743
+ function setArWorldGroup(g) {
744
+ arWorldGroup = g;
745
+ }
746
+ /**
747
+ * Set the camera externally (for replay mode).
748
+ * Allows non-WebXR code paths to register a camera so that modules
749
+ * calling getCamera() receive it.
750
+ * @see docs/2026-02-19-replay-mode.md Risk R1
751
+ */
752
+ function setCamera(c) {
753
+ camera = c;
754
+ }
755
+ /**
756
+ * Get the arpose Object3D (intermediate node between arWorldGroup and camera).
757
+ * Returns null before scene initialization.
758
+ */
759
+ function getArPose() {
760
+ return arPoseNode;
761
+ }
762
+ /**
763
+ * Set the arpose Object3D externally (for replay mode).
764
+ * Allows non-WebXR code paths to register an arpose so that
765
+ * store subscribers can update it with recorded odom data.
766
+ */
767
+ function setArPose(a) {
768
+ arPoseNode = a;
769
+ }
770
+ /**
771
+ * Apply an alignment matrix to the AR world group.
772
+ *
773
+ * The alignment matrix maps odometry positions in NUE space
774
+ * to GPS world space (also NUE). It is applied directly to arWorldGroup
775
+ * whose local space is NUE.
776
+ *
777
+ * The WebXR→NUE basis change lives permanently in basisChangeNode (a
778
+ * child of arWorldGroup), so the full camera world transform is:
779
+ *
780
+ * camera_world = arWorldGroup × basisChangeNode × arpose × camera_local
781
+ * = alignment × WEBXR_TO_NUE × arpose × camera_local
782
+ *
783
+ * This is mathematically identical to the previous composition
784
+ * (alignment × WEBXR_TO_NUE applied directly to arWorldGroup.matrix),
785
+ * but arWorldGroup's local space is now NUE: objects placed as children
786
+ * of arWorldGroup use NUE coordinates directly ([1,0,0]=North, [0,0,1]=East).
787
+ *
788
+ * Replay note: arpose still lives in WebXR space (below basisChangeNode),
789
+ * so nuePositionToWebXR() is still required when setting arpose.position.
790
+ *
791
+ * @param matrix - 16-element column-major matrix (gl-matrix mat4 format)
792
+ */
793
+ function applyAlignmentMatrix(matrix) {
794
+ if (!arWorldGroup) {
795
+ log.warn("Cannot apply alignment - arWorldGroup not initialized");
796
+ return;
797
+ }
798
+ if (matrix.length !== 16) {
799
+ log.error("Invalid alignment matrix - expected 16 elements");
800
+ return;
801
+ }
802
+ arWorldGroup.matrix.fromArray(matrix);
803
+ arWorldGroup.matrixAutoUpdate = false;
804
+ arWorldGroup.updateMatrixWorld(true);
805
+ }
806
+ /**
807
+ * Convert a position from internal NUE convention (X=North, Y=Up, Z=East)
808
+ * to WebXR local-floor convention (X=East, Y=Up, Z=South).
809
+ *
810
+ * Delegates to the canonical library implementation (nueToWebXR).
811
+ * Accepts `readonly number[]` for call-site convenience.
812
+ *
813
+ * NUE [n, u, e] → WebXR [e, u, -n]
814
+ */
815
+ function nuePositionToWebXR(nue) {
816
+ return nueToWebXR(nue);
817
+ }
818
+ /**
819
+ * Convert a quaternion from internal NUE convention to WebXR local-floor convention.
820
+ *
821
+ * Delegates to the canonical library implementation (nueQuaternionToWebXR).
822
+ * Accepts `readonly number[]` for call-site convenience.
823
+ *
824
+ * NUE [x, y, z, w] → WebXR [z, y, -x, w]
825
+ */
826
+ function nueQuaternionToWebXR$1(nue) {
827
+ return nueQuaternionToWebXR(nue);
828
+ }
829
+ /**
830
+ * End the current XR session and clean up all resources.
831
+ *
832
+ * Stops the animation loop, ends the XR session, then delegates the full
833
+ * teardown to {@link resetWebXRState} so every module-level reference is
834
+ * cleared (renderer/scene/camera, image-capture, depth, the tracking-phase
835
+ * subscription, the frame-update registry, diagnostics, blit resources).
836
+ * This is the production cleanup path — call it when the AR experience is
837
+ * finished.
838
+ */
839
+ async function endARSession() {
840
+ if (renderer) renderer.setAnimationLoop(null);
841
+ try {
842
+ if (xrSession) await xrSession.end();
843
+ } finally {
844
+ resetWebXRState();
845
+ }
846
+ }
847
+ /**
848
+ * Set up image capture callbacks.
849
+ * Call this before starting image capture to wire up the callback handlers.
850
+ *
851
+ * @param onCaptured - Called when an image is successfully captured
852
+ * @param screenRotationGetter - Returns current device screen rotation (0, 90, 180, 270)
853
+ * @param onFailed - Optional callback for when image capture fails (e.g., low memory)
854
+ * @param onSuspicious - Optional callback for when a captured image appears black/empty
855
+ */
856
+ function setImageCaptureCallback(onCaptured, screenRotationGetter, onFailed, onSuspicious) {
857
+ onImageCaptured = onCaptured;
858
+ getScreenRotation = screenRotationGetter;
859
+ onCaptureFailed = onFailed ?? null;
860
+ onSuspiciousImage = onSuspicious ?? null;
861
+ }
862
+ /**
863
+ * Start capturing images during recording.
864
+ * Must call setImageCaptureCallback first.
865
+ *
866
+ * @param config - Optional capture configuration. Accepts the whole user
867
+ * image-options section (`intervalMs`, `quality`, `resolutionDivisor`; any
868
+ * extra keys such as `enabled` are ignored). Passing the section as one
869
+ * object means a newly-added option flows through without editing this seam
870
+ * — see `2026-06-12-payload-rebuild-field-drop-audit.md` (F3).
871
+ */
872
+ function startImageCapture(config) {
873
+ if (!renderer) {
874
+ log.warn("Cannot start image capture - renderer not initialized");
875
+ return;
876
+ }
877
+ if (!onImageCaptured || !getScreenRotation) {
878
+ log.warn("Cannot start image capture - callbacks not set");
879
+ return;
880
+ }
881
+ if (imageCaptureManager || blitCapture) {
882
+ log.warn("Image capture already running - stopping previous session");
883
+ stopImageCapture();
884
+ }
885
+ const callbacks = {
886
+ getCurrentPose: getCurrentArPose,
887
+ getScreenRotation,
888
+ onCaptured: onImageCaptured,
889
+ onCaptureFailed: onCaptureFailed ?? void 0,
890
+ onSuspiciousImage: onSuspiciousImage ?? void 0
891
+ };
892
+ const mergedConfig = {
893
+ ...DEFAULT_CAPTURE_CONFIG,
894
+ ...config
895
+ };
896
+ blitCapture = new CameraBlitCapture();
897
+ const currentRenderer = renderer;
898
+ const divisor = mergedConfig.resolutionDivisor;
899
+ callbacks.captureFrame = async (quality) => {
900
+ if (!blitCapture || !latestCameraTexture) {
901
+ const canvas = currentRenderer.domElement;
902
+ return new Promise((resolve) => {
903
+ canvas.toBlob((blob) => resolve(blob ? {
904
+ blob,
905
+ width: canvas.width,
906
+ height: canvas.height
907
+ } : null), "image/jpeg", quality);
908
+ });
909
+ }
910
+ if (latestCameraWidth > 0 && latestCameraHeight > 0) {
911
+ const target = computeCaptureSize(latestCameraWidth, latestCameraHeight, divisor);
912
+ blitCapture.resizeIfNeeded(target.width, target.height);
913
+ }
914
+ const blob = await blitCapture.captureToBlob(currentRenderer, latestCameraTexture, quality);
915
+ if (!blob) return null;
916
+ return {
917
+ blob,
918
+ width: blitCapture.getWidth(),
919
+ height: blitCapture.getHeight()
920
+ };
921
+ };
922
+ log.info(`Blit capture pipeline initialized (resolutionDivisor=${divisor})`);
923
+ imageCaptureManager = new ImageCaptureManager(renderer.domElement, callbacks, mergedConfig);
924
+ imageCaptureManager.start();
925
+ log.info("Image capture started");
926
+ }
927
+ /**
928
+ * Stop capturing images.
929
+ */
930
+ function stopImageCapture() {
931
+ if (imageCaptureManager) {
932
+ imageCaptureManager.stop();
933
+ log.info(`Image capture stopped (${imageCaptureManager.getFrameCount()} frames captured)`);
934
+ imageCaptureManager = null;
935
+ }
936
+ cleanupBlitResources();
937
+ }
938
+ /**
939
+ * Get the current image capture frame count.
940
+ */
941
+ function getImageCaptureFrameCount() {
942
+ return imageCaptureManager?.getFrameCount() ?? 0;
943
+ }
944
+ /**
945
+ * Inject the Redux store used by the tracking-state slice pipeline.
946
+ *
947
+ * MUST be called before `initAR()` whenever the host also wires tracking
948
+ * callbacks via `setTrackingCallbacks`. Without a store the tracking
949
+ * pipeline silently no-ops.
950
+ *
951
+ * @param store — any store satisfying {@link TrackingSubscribableStore}.
952
+ * `null` clears the binding (useful for teardown in tests).
953
+ */
954
+ function setTrackingStore(store) {
955
+ if (trackingPhaseUnsubscribe) {
956
+ trackingPhaseUnsubscribe();
957
+ trackingPhaseUnsubscribe = null;
958
+ }
959
+ trackingStore = store;
960
+ }
961
+ /**
962
+ * Set up tracking state callbacks.
963
+ * Call this BEFORE initAR() to enable tracking restart detection.
964
+ *
965
+ * @param onRestarted - Called when tracking restarts after being lost
966
+ */
967
+ function setTrackingCallbacks(onRestarted) {
968
+ onTrackingRestarted = onRestarted;
969
+ }
970
+ /**
971
+ * Set a callback for when AR tracking is lost.
972
+ * Call this BEFORE initAR() to enable tracking loss notifications.
973
+ * Field Test Readiness Issue #3: Provide user feedback when tracking is lost.
974
+ *
975
+ * @param callback - Called when tracking is lost (pose becomes null)
976
+ */
977
+ function setTrackingLostCallback(callback) {
978
+ onTrackingLost = callback;
979
+ }
980
+ /**
981
+ * Set a callback for when AR tracking recovers seamlessly (Case 1: same coordinate frame).
982
+ * Call this BEFORE initAR() to enable seamless recovery notifications.
983
+ *
984
+ * @param callback - Called when tracking recovers without origin reset
985
+ */
986
+ function setTrackingRecoveredCallback(callback) {
987
+ onTrackingRecovered = callback;
988
+ }
989
+ /**
990
+ * Set up depth capture callback.
991
+ * Call this BEFORE initAR() to enable depth sampling.
992
+ *
993
+ * @param onCaptured - Called when a depth sample is captured
994
+ * @param onUnavailable - Called once if depth is unavailable after threshold
995
+ */
996
+ function setDepthCaptureCallback(onCaptured, onUnavailable) {
997
+ onDepthCaptured = onCaptured;
998
+ onDepthUnavailable = onUnavailable ?? null;
999
+ }
1000
+ /**
1001
+ * Start depth sampling during recording.
1002
+ * Must call setDepthCaptureCallback before initAR.
1003
+ *
1004
+ * @param config - optional sampler overrides (typically the user's
1005
+ * `depth.intervalMs`/`depth.gridSize` recording options); applied via
1006
+ * `DepthSampler.updateConfig` before sampling starts. Without this the
1007
+ * sampler's own defaults apply — the settings knobs were dead before
1008
+ * this parameter existed (occupancy-grid port plan, Iter 6).
1009
+ */
1010
+ function startDepthCapture(config) {
1011
+ if (!depthSampler) {
1012
+ log.warn("Cannot start depth capture - sampler not initialized");
1013
+ return;
1014
+ }
1015
+ if (config) depthSampler.updateConfig(config);
1016
+ depthSampler.start();
1017
+ log.info(`Depth capture started (interval: ${depthSampler.getConfig().intervalMs}ms, grid: ${depthSampler.getConfig().gridSize}×${depthSampler.getConfig().gridSize})`);
1018
+ }
1019
+ /**
1020
+ * Stop depth sampling.
1021
+ */
1022
+ function stopDepthCapture() {
1023
+ if (depthSampler) {
1024
+ const count = depthSampler.getSampleCount();
1025
+ depthSampler.stop();
1026
+ log.info(`Depth capture stopped (${count} samples captured)`);
1027
+ }
1028
+ }
1029
+ /**
1030
+ * Get the current depth sample count.
1031
+ */
1032
+ function getDepthSampleCount() {
1033
+ return depthSampler?.getSampleCount() ?? 0;
1034
+ }
1035
+ /**
1036
+ * Set a per-frame callback for custom updates.
1037
+ * The callback is invoked every XR frame after pose updates but before render.
1038
+ * Useful for updating elements that need to follow the camera smoothly
1039
+ * (e.g., map overlay position).
1040
+ *
1041
+ * @param callback - Function to call each frame, or null to clear
1042
+ */
1043
+ function setFrameCallback(callback) {
1044
+ onFrameCallback = callback;
1045
+ }
1046
+ /**
1047
+ * Get the CSS3D renderer manager for live AR mode.
1048
+ * Returns null if no AR session is active or CSS3D was not created.
1049
+ */
1050
+ function getLiveCss3dManager() {
1051
+ return css3dManager;
1052
+ }
1053
+ //#endregion
1054
+ export { setTrackingLostCallback as A, setArWorldGroup as C, setImageCaptureCallback as D, setFrameCallback as E, startImageCapture as F, stopDepthCapture as I, stopImageCapture as L, setTrackingStore as M, shouldLogCameraAccessDiagnostic as N, setScene as O, startDepthCapture as P, setArPose as S, setDepthCaptureCallback as T, isWebXRSupported as _, extractPoseFromViewer as a, nueQuaternionToWebXR$1 as b, getArWorldGroup as c, getDepthSampleCount as d, getImageCaptureFrameCount as f, initAR as g, getXrCameraFromPose as h, endARSession as i, setTrackingRecoveredCallback as j, setTrackingCallbacks as k, getCamera as l, getScene as m, buildSessionOptions as n, extractResetTransformData as o, getLiveCss3dManager as p, createSceneHierarchy as r, getArPose as s, applyAlignmentMatrix as t, getCurrentArPose as u, isXRCameraLike as v, setCamera as w, resetWebXRState as x, nuePositionToWebXR as y };