@needle-tools/engine 5.1.7 → 5.1.8-next.1784885350.large-bamboo.746608e

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 (70) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/components.needle.json +1 -1
  3. package/dist/{needle-engine.bundle-DlX4Rcl_.min.js → needle-engine.bundle-Cw8tbJuS.min.js} +133 -133
  4. package/dist/{needle-engine.bundle-DT6F1AXh.js → needle-engine.bundle-bOImzZkB.js} +3895 -3880
  5. package/dist/{needle-engine.bundle-1QN--joB.umd.cjs → needle-engine.bundle-uEyfssJk.umd.cjs} +135 -135
  6. package/dist/needle-engine.d.ts +49 -18
  7. package/dist/needle-engine.js +334 -334
  8. package/dist/needle-engine.min.js +1 -1
  9. package/dist/needle-engine.umd.cjs +1 -1
  10. package/lib/engine/api.d.ts +1 -1
  11. package/lib/engine/api.js +1 -1
  12. package/lib/engine/api.js.map +1 -1
  13. package/lib/engine/engine_init.js +2 -2
  14. package/lib/engine/engine_init.js.map +1 -1
  15. package/lib/engine/engine_license.d.ts +7 -7
  16. package/lib/engine/engine_license.js +71 -71
  17. package/lib/engine/engine_license.js.map +1 -1
  18. package/lib/engine/engine_networking_blob.js +3 -3
  19. package/lib/engine/engine_networking_blob.js.map +1 -1
  20. package/lib/engine/engine_utils_qrcode.js +2 -2
  21. package/lib/engine/engine_utils_qrcode.js.map +1 -1
  22. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  23. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
  24. package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
  25. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  26. package/lib/engine/webcomponents/needle-engine.js +2 -2
  27. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  28. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  29. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  30. package/lib/engine/xr/NeedleXRSession.d.ts +2 -2
  31. package/lib/engine/xr/NeedleXRSession.js +9 -1
  32. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  33. package/lib/engine/xr/TempXRContext.js +2 -2
  34. package/lib/engine/xr/TempXRContext.js.map +1 -1
  35. package/lib/engine-components/Component.d.ts +2 -1
  36. package/lib/engine-components/Component.js.map +1 -1
  37. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  38. package/lib/engine-components/export/usdz/USDZExporter.js.map +1 -1
  39. package/lib/engine-components/webxr/TeleportTarget.d.ts +21 -8
  40. package/lib/engine-components/webxr/TeleportTarget.js +21 -8
  41. package/lib/engine-components/webxr/TeleportTarget.js.map +1 -1
  42. package/lib/engine-components/webxr/WebXRImageTracking.d.ts +1 -1
  43. package/lib/engine-components/webxr/WebXRImageTracking.js +19 -48
  44. package/lib/engine-components/webxr/WebXRImageTracking.js.map +1 -1
  45. package/lib/engine-components/webxr/WebXRImageTracking.utils.d.ts +35 -0
  46. package/lib/engine-components/webxr/WebXRImageTracking.utils.js +96 -0
  47. package/lib/engine-components/webxr/WebXRImageTracking.utils.js.map +1 -0
  48. package/lib/engine-components/webxr/controllers/XRControllerMovement.d.ts +18 -1
  49. package/lib/engine-components/webxr/controllers/XRControllerMovement.js +18 -1
  50. package/lib/engine-components/webxr/controllers/XRControllerMovement.js.map +1 -1
  51. package/package.json +1 -1
  52. package/plugins/common/license.js +4 -4
  53. package/plugins/vite/license.js +4 -4
  54. package/src/engine/api.ts +1 -1
  55. package/src/engine/engine_init.ts +2 -2
  56. package/src/engine/engine_license.ts +68 -68
  57. package/src/engine/engine_networking_blob.ts +3 -3
  58. package/src/engine/engine_utils_qrcode.ts +2 -2
  59. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  60. package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
  61. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  62. package/src/engine/webcomponents/needle-engine.ts +2 -2
  63. package/src/engine/xr/NeedleXRSession.ts +11 -3
  64. package/src/engine/xr/TempXRContext.ts +2 -2
  65. package/src/engine-components/Component.ts +2 -1
  66. package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
  67. package/src/engine-components/webxr/TeleportTarget.ts +23 -10
  68. package/src/engine-components/webxr/WebXRImageTracking.ts +19 -51
  69. package/src/engine-components/webxr/WebXRImageTracking.utils.ts +105 -0
  70. package/src/engine-components/webxr/controllers/XRControllerMovement.ts +20 -3
@@ -1,6 +1,6 @@
1
1
  import { needleLogoOnlySVG } from "../assets/index.js"
2
2
  import { isDevEnvironment, showBalloonWarning } from "../debug/index.js";
3
- import { _ERXbX, _owM, _jcZJFQ } from "../engine_license.js";
3
+ import { _$BbU, _WXkIc, __UCuVxqBu } from "../engine_license.js";
4
4
  import { Mathf } from "../engine_math.js";
5
5
  import { LoadingProgressArgs } from "../engine_setup.js";
6
6
  import { getParam } from "../engine_utils.js";
@@ -205,7 +205,7 @@ export class EngineLoadingView implements ILoadingViewHandler {
205
205
  }
206
206
 
207
207
 
208
- const hasLicense = _owM();
208
+ const hasLicense = _WXkIc();
209
209
  if (!existing) {
210
210
  this._loadingElement.style.position = "absolute";
211
211
  this._loadingElement.style.width = "100%";
@@ -373,7 +373,7 @@ export class EngineLoadingView implements ILoadingViewHandler {
373
373
 
374
374
  // private async handleRuntimeLicense(loadingElement: HTMLElement) {
375
375
  // // First check if we have a commercial license
376
- // let commercialLicense = _ERXbX();
376
+ // let commercialLicense = _$BbU();
377
377
  // // if it's the case then we don't need to perform a runtime check
378
378
  // if (commercialLicense) return;
379
379
 
@@ -393,10 +393,10 @@ export class EngineLoadingView implements ILoadingViewHandler {
393
393
  // loadingElement.appendChild(nonCommercialContainer);
394
394
 
395
395
  // // Use the runtime license check
396
- // if (!isDevEnvironment() && _jcZJFQ) {
396
+ // if (!isDevEnvironment() && __UCuVxqBu) {
397
397
  // if (debugLicense) console.log("Waiting for runtime license check");
398
- // await _jcZJFQ;
399
- // commercialLicense = _ERXbX();
398
+ // await __UCuVxqBu;
399
+ // commercialLicense = _$BbU();
400
400
  // }
401
401
  // if (commercialLicense) return;
402
402
  // nonCommercialContainer.style.transition = "opacity .5s ease-in-out";
@@ -3,7 +3,7 @@ import type { Texture } from "three";
3
3
  import { isDevEnvironment, showBalloonWarning } from "../debug/index.js";
4
4
  import { PUBLIC_KEY, VERSION } from "../engine_constants.js";
5
5
  import { ContextEvent, ContextRegistry } from "../engine_context_registry.js";
6
- import { _ERXbX, Telemetry } from "../engine_license.js";
6
+ import { _$BbU, Telemetry } from "../engine_license.js";
7
7
  import { onStart } from "../engine_lifecycle_api.js";
8
8
  import { setDracoDecoderPath, setDracoDecoderType, setKtx2TranscoderPath } from "../engine_loaders.gltf.js";
9
9
  import { Context, ContextCreateArgs } from "../engine_setup.js";
@@ -579,7 +579,7 @@ export class NeedleEngineWebComponent extends HTMLElementBase implements INeedle
579
579
 
580
580
 
581
581
  // Loading start events
582
- const allowOverridingDefaultLoading = _ERXbX();
582
+ const allowOverridingDefaultLoading = _$BbU();
583
583
  // default loading can be overriden by calling preventDefault in the onload start event
584
584
  this.ensureLoadStartIsRegistered();
585
585
  let useDefaultLoading = this.dispatchEvent(new CustomEvent("loadstart", {
@@ -72,8 +72,8 @@ let debugFPSFramesSinceLastUpdate = 0;
72
72
  export interface INeedleXRSessionEventReceiver extends Pick<IComponent, "destroyed"> {
73
73
  get activeAndEnabled(): boolean;
74
74
  supportsXR?(mode: XRSessionMode): boolean;
75
- /** Called before requesting a XR session */
76
- onBeforeXR?(mode: XRSessionMode, args: XRSessionInit): void;
75
+ /** Called before requesting a XR session. May return a promise that is awaited before requesting. */
76
+ onBeforeXR?(mode: XRSessionMode, args: XRSessionInit): void | Promise<void>;
77
77
  onEnterXR?(args: NeedleXREventArgs): void;
78
78
  onUpdateXR?(args: NeedleXREventArgs): void;
79
79
  onLeaveXR?(args: NeedleXREventArgs): void;
@@ -757,7 +757,15 @@ export class NeedleXRSession implements INeedleXRSession {
757
757
  console.log("%c" + `Requesting ${mode} session`, "font-weight:bold;");
758
758
  for (const script of scripts) {
759
759
  if (script.onBeforeXR && script.activeAndEnabled && !script.destroyed) {
760
- script.onBeforeXR(mode, init);
760
+ try {
761
+ // Await: onBeforeXR may be async (e.g. WebXRImageTracking awaits marker bitmap loads
762
+ // so late-decoding markers aren't dropped). A failing hook must not abort the session
763
+ // request for every other component.
764
+ await script.onBeforeXR(mode, init);
765
+ }
766
+ catch (err) {
767
+ console.error("[NeedleXRSession] onBeforeXR hook failed:", err);
768
+ }
761
769
  }
762
770
  }
763
771
  this.invokeSessionRequestStart(mode, init, context ?? Context.Current);
@@ -3,7 +3,7 @@ import { ArrayCamera, AxesHelper, Camera, Color, DirectionalLight, Fog, GridHelp
3
3
  import { needleLogoOnlySVG } from "../assets/index.js";
4
4
  import { isDevEnvironment } from "../debug/index.js";
5
5
  import { ObjectUtils, PrimitiveType } from "../engine_create_objects.js";
6
- import { _ERXbX } from "../engine_license.js";
6
+ import { _$BbU } from "../engine_license.js";
7
7
  import { Mathf } from "../engine_math.js";
8
8
  import { delay, DeviceUtilities } from "../engine_utils.js";
9
9
 
@@ -215,7 +215,7 @@ export class TemporaryXRContext {
215
215
  this._scene.background = new Color(0x000000);
216
216
 
217
217
  let logoSrc = needleLogoOnlySVG;
218
- if (_ERXbX()) {
218
+ if (_$BbU()) {
219
219
  const htmlComponent = document.querySelector("needle-engine");
220
220
  if (htmlComponent) {
221
221
  const licenseLogo = htmlComponent.getAttribute("logo-src");
@@ -930,8 +930,9 @@ export abstract class Component implements IComponent, EventTarget,
930
930
  * Use to modify session initialization parameters
931
931
  * @param mode The XR session mode being requested
932
932
  * @param args The session initialization parameters that can be modified
933
+ * @returns void, or a promise that is awaited before the session is requested (e.g. to load assets)
933
934
  */
934
- onBeforeXR?(mode: XRSessionMode, args: XRSessionInit): void;
935
+ onBeforeXR?(mode: XRSessionMode, args: XRSessionInit): void | Promise<void>;
935
936
 
936
937
  /**
937
938
  * Called when this component joins an XR session or becomes active in a running session
@@ -3,7 +3,7 @@ import { Euler, Material, Matrix4, Mesh, Object3D, Quaternion, Vector3 } from "t
3
3
 
4
4
  import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../../../engine/debug/index.js";
5
5
  import { findObjectOfType } from "../../../engine/engine_components.js";
6
- import { _owM } from "../../../engine/engine_license.js";
6
+ import { _WXkIc } from "../../../engine/engine_license.js";
7
7
  import { serializable } from "../../../engine/engine_serialization.js";
8
8
  import { getFormattedDate, Progress } from "../../../engine/engine_time_utils.js";
9
9
  import { DeviceUtilities, getParam } from "../../../engine/engine_utils.js";
@@ -277,7 +277,7 @@ export class USDZExporter extends Behaviour {
277
277
  let name = this.exportFileName ?? this.objectToExport?.name ?? this.name;
278
278
  name += "-" + getFormattedDate(); // seems iOS caches the file in some cases, this ensures we always have a fresh file
279
279
 
280
- if (!_owM()) {
280
+ if (!_WXkIc()) {
281
281
  if (name !== "") name += "-";
282
282
  name += "MadeWithNeedle";
283
283
  }
@@ -682,7 +682,7 @@ export class USDZExporter extends Behaviour {
682
682
  if (debug)
683
683
  showBalloonMessage("Quicklook url: " + callToActionURL);
684
684
  if (callToActionURL) {
685
- if (!_owM()) {
685
+ if (!_WXkIc()) {
686
686
  console.warn("Quicklook closed: custom redirects require a Needle Engine Pro license: https://needle.tools/pricing", callToActionURL)
687
687
  }
688
688
  else {
@@ -697,7 +697,7 @@ export class USDZExporter extends Behaviour {
697
697
  private buildQuicklookOverlay(): CustomBranding {
698
698
  const obj: CustomBranding = {};
699
699
  if (this.customBranding) Object.assign(obj, this.customBranding);
700
- if (!_owM()) {
700
+ if (!_WXkIc()) {
701
701
  console.log("Custom Quicklook banner text requires pro license: https://needle.tools/pricing");
702
702
  obj.callToAction = "Close";
703
703
  obj.checkoutTitle = "🌵 Made with Needle";
@@ -2,26 +2,39 @@ import { Behaviour } from "../Component.js";
2
2
  import type { XRControllerMovement } from "./controllers/XRControllerMovement.js";
3
3
 
4
4
  /**
5
- * Marks a GameObject as a valid teleportation target for VR locomotion.
6
- * Add this component to objects or surfaces where users should be able to teleport.
5
+ * Marks a GameObject as a valid teleportation target for VR locomotion.
6
+ * Add this component to objects or surfaces where users should be able to teleport.
7
7
  *
8
- * **Usage:**
9
- * - Add to floor surfaces, platforms, or designated teleport spots
10
- * - Works with {@link XRControllerMovement} component's teleport system
11
- * - Can be used to restrict teleportation to specific areas
8
+ * **How targeting works (important):**
9
+ * The teleport ray uses a **visual raycast against rendered meshes** — NOT physics colliders.
10
+ * When {@link XRControllerMovement.useTeleportTarget} is enabled, the controller ray is cast
11
+ * against the rendered scene and the hit object (or any of its parents) is checked for a
12
+ * `TeleportTarget` component. You can only teleport onto an object if that object, or one of
13
+ * its ancestors, has this component.
14
+ *
15
+ * **Requirements for a target to be hittable:**
16
+ * - It MUST have a visible, rendered mesh (a `MeshRenderer` / geometry). The ray hits the
17
+ * rendered geometry directly.
18
+ * - **A collider is NOT required.** Teleport does not use physics colliders — adding a
19
+ * `BoxCollider` (or any collider) has no effect on whether the target can be teleported to.
20
+ * - It must NOT be on the IgnoreRaycast layer (three.js layer 2) and must not have
21
+ * `raycastAllowed` / `visible` set to false, otherwise the ray skips it.
22
+ * - The `TeleportTarget` component may be on the hit mesh itself or on any parent
23
+ * (resolved via `getComponentInParent`).
12
24
  *
13
25
  * **Setup:**
14
- * 1. Add this component to GameObjects that should be teleport destinations
15
- * 2. Configure {@link XRControllerMovement} to use teleport targets (optional)
26
+ * 1. Add this component to GameObjects (with a visible mesh) that should be teleport destinations
27
+ * 2. Enable "Use Teleport Target" on the {@link XRControllerMovement} component so teleporting
28
+ * is restricted to objects carrying this component
16
29
  * 3. Test teleportation in VR mode
17
30
  *
18
31
  * @example
19
32
  * ```ts
20
- * // Make a platform teleportable
33
+ * // Make a platform teleportable (the platform already has a visible mesh)
21
34
  * const platform = myPlatform.addComponent(TeleportTarget);
22
35
  * ```
23
36
  *
24
- * @summary Marker component for valid VR teleportation destinations
37
+ * @summary Marker component for valid VR teleportation destinations (mesh hit-test, no collider needed)
25
38
  * @category XR
26
39
  * @group Components
27
40
  * @see {@link XRControllerMovement} for VR locomotion and teleport configuration
@@ -14,6 +14,7 @@ import { USDZExporter } from "../../engine-components/export/usdz/USDZExporter.j
14
14
  import { Behaviour, GameObject } from "../Component.js";
15
15
  import { EventList } from "../EventList.js";
16
16
  import { Renderer } from "../Renderer.js";
17
+ import { _imageElements, collectTrackedImages, loadImage } from "./WebXRImageTracking.utils.js";
17
18
 
18
19
  // https://github.com/immersive-web/marker-tracking/blob/main/explainer.md
19
20
 
@@ -709,26 +710,25 @@ export class WebXRImageTracking extends Behaviour {
709
710
 
710
711
 
711
712
  /** @internal */
712
- onBeforeXR(_mode: XRSessionMode, args: XRSessionInit & { trackedImages: Array<any> }): void {
713
- // console.log("onXRRequested", args, this.trackedImages)
714
- if (this.trackedImages) {
715
- args.optionalFeatures = args.optionalFeatures || [];
716
- if (!args.optionalFeatures.includes("image-tracking"))
717
- args.optionalFeatures.push("image-tracking");
713
+ async onBeforeXR(_mode: XRSessionMode, args: XRSessionInit & { trackedImages: Array<any> }): Promise<void> {
714
+ if (!this.trackedImages) return;
718
715
 
719
- if(!args.trackedImages) args.trackedImages = [];
720
- for (const trackedImage of this.trackedImages) {
721
- if (trackedImage.image?.length && trackedImage.widthInMeters > 0) {
722
- const bitmap = _imageElements.get(trackedImage.image);
723
- if (bitmap) {
724
- this.trackedImageIndexMap.set(args.trackedImages.length, trackedImage);
725
- args.trackedImages.push({
726
- image: bitmap,
727
- widthInMeters: trackedImage.widthInMeters
728
- });
729
- }
730
- }
731
- }
716
+ args.optionalFeatures = args.optionalFeatures || [];
717
+ if (!args.optionalFeatures.includes("image-tracking"))
718
+ args.optionalFeatures.push("image-tracking");
719
+
720
+ // Resolve the marker bitmaps *before* the session is requested.
721
+ // onBeforeXR runs the moment "Enter AR" is tapped. Marker images load asynchronously
722
+ // (network fetch + createImageBitmap), so without awaiting here a marker that hasn't finished
723
+ // decoding yet would be silently dropped from `trackedImages` and never registered with the
724
+ // native/WebXR session - it would then only start working after exiting and re-entering AR,
725
+ // once the bitmap has been cached. Awaiting the (idempotent) loads closes that race.
726
+ // A marker that can not be loaded resolves to null and is skipped individually, so a single
727
+ // unavailable image never rejects the whole XR session request. See forum topic #2859.
728
+ this.trackedImageIndexMap.clear();
729
+ const indexMap = await collectTrackedImages(this.trackedImages, args);
730
+ for (const [index, model] of indexMap) {
731
+ this.trackedImageIndexMap.set(index, model);
732
732
  }
733
733
  }
734
734
 
@@ -942,36 +942,4 @@ export class WebXRImageTracking extends Behaviour {
942
942
  }
943
943
  }
944
944
  }
945
- }
946
-
947
-
948
-
949
-
950
- const _imageElements: Map<string, ImageBitmap | null> = new Map();
951
- const _imageLoadingPromises: Map<string, Promise<boolean>> = new Map();
952
-
953
- async function loadImage(url: string) {
954
- if (_imageElements.has(url)) {
955
- if (_imageLoadingPromises.has(url)) return _imageLoadingPromises.get(url);
956
- return Promise.resolve(true);
957
- }
958
- const promise = new Promise<boolean>(res => {
959
- _imageElements.set(url, null);
960
- if(isDevEnvironment() || debug) console.debug(`[WebXRImageTracking] Start loading image for tracking: ${url}`);
961
- const imageElement = document.createElement("img") as HTMLImageElement;
962
- imageElement.src = url;
963
- imageElement.addEventListener("load", async () => {
964
- if(isDevEnvironment() || debug) console.debug(`[WebXRImageTracking] Loaded image for tracking: ${url}`);
965
- const img = await createImageBitmap(imageElement);
966
- _imageElements.set(url, img);
967
- res(true);
968
- });
969
- });
970
-
971
- _imageLoadingPromises.set(url, promise);
972
- promise.finally(() => {
973
- _imageLoadingPromises.delete(url);
974
- });
975
-
976
- return promise;
977
945
  }
@@ -0,0 +1,105 @@
1
+ import { isDevEnvironment } from "../../engine/debug/index.js";
2
+ import { getParam } from "../../engine/engine_utils.js";
3
+
4
+ import type { WebXRImageTrackingModel } from "./WebXRImageTracking.js";
5
+
6
+ // Kept in a dedicated module (with only lightweight imports) so the marker-loading logic can be unit
7
+ // tested without pulling in the heavy WebXRImageTracking / USDZ exporter import graph.
8
+
9
+ const debug = getParam("debugimagetracking");
10
+
11
+ /**
12
+ * Cache of decoded marker bitmaps, keyed by image url.
13
+ * Also read by the USDZ exporter (see {@link WebXRImageTracking}) to embed the marker into the .usdz.
14
+ */
15
+ export const _imageElements: Map<string, ImageBitmap | null> = new Map();
16
+ const _imageLoadingPromises: Map<string, Promise<ImageBitmap | null>> = new Map();
17
+
18
+ /**
19
+ * Resolve marker bitmaps for the given tracked-image models and append them to `args.trackedImages`
20
+ * (the list handed to `navigator.xr.requestSession`).
21
+ *
22
+ * Every valid marker is awaited so that images which are still decoding when the XR session is
23
+ * requested get included instead of being silently dropped (which previously caused markers to only
24
+ * work after re-entering AR). Loading happens in parallel and never rejects: a marker that can not be
25
+ * loaded is skipped individually so a single unavailable image can not fail the whole session request.
26
+ *
27
+ * @param models The configured tracked-image models
28
+ * @param args The session init object; `trackedImages` is created if missing and appended to
29
+ * @param load Injectable loader (defaults to {@link loadImage}); resolves the decoded bitmap or null
30
+ * @returns Map of `trackedImages` index -> model for the appended images (used to resolve results)
31
+ */
32
+ export async function collectTrackedImages(
33
+ models: readonly WebXRImageTrackingModel[] | undefined | null,
34
+ args: { trackedImages?: Array<{ image: ImageBitmap, widthInMeters: number }> },
35
+ load: (url: string) => Promise<ImageBitmap | null> = loadImage,
36
+ ): Promise<Map<number, WebXRImageTrackingModel>> {
37
+ const indexMap = new Map<number, WebXRImageTrackingModel>();
38
+ if (!args.trackedImages) args.trackedImages = [];
39
+ if (!models) return indexMap;
40
+
41
+ const valid = models.filter(m => !!m.image?.length && m.widthInMeters > 0);
42
+ // Load in parallel and wait for all of them. Guard every load with a catch so that a rejecting
43
+ // loader is treated as "unavailable" (null) rather than aborting the whole session request.
44
+ const bitmaps = await Promise.all(valid.map(m => load(m.image!).catch(() => null)));
45
+
46
+ for (let i = 0; i < valid.length; i++) {
47
+ const model = valid[i];
48
+ const bitmap = bitmaps[i];
49
+ if (bitmap) {
50
+ indexMap.set(args.trackedImages.length, model);
51
+ args.trackedImages.push({ image: bitmap, widthInMeters: model.widthInMeters });
52
+ }
53
+ else if (isDevEnvironment() || debug) {
54
+ console.warn(`[WebXRImageTracking] Marker image could not be loaded, skipping: ${model.image}`);
55
+ }
56
+ }
57
+ return indexMap;
58
+ }
59
+
60
+ /**
61
+ * Load and decode a marker image into an {@link ImageBitmap}, caching it in {@link _imageElements}.
62
+ * Idempotent: concurrent or repeated calls for the same URL share a single in-flight load.
63
+ *
64
+ * Resolves to `null` and never rejects when the image can not be fetched or decoded, so callers can
65
+ * skip an unavailable marker without failing the surrounding XR session request. A failed load clears
66
+ * its cache entry so a later attempt (e.g. re-entering AR) can retry.
67
+ */
68
+ export async function loadImage(url: string): Promise<ImageBitmap | null> {
69
+ const cached = _imageElements.get(url);
70
+ if (cached) return cached;
71
+ const pending = _imageLoadingPromises.get(url);
72
+ if (pending) return pending;
73
+
74
+ const promise = new Promise<ImageBitmap | null>(res => {
75
+ _imageElements.set(url, null); // placeholder to dedupe concurrent loads
76
+ if (isDevEnvironment() || debug) console.debug(`[WebXRImageTracking] Start loading image for tracking: ${url}`);
77
+ const imageElement = document.createElement("img") as HTMLImageElement;
78
+ imageElement.addEventListener("load", async () => {
79
+ try {
80
+ const img = await createImageBitmap(imageElement);
81
+ _imageElements.set(url, img);
82
+ if (isDevEnvironment() || debug) console.debug(`[WebXRImageTracking] Loaded image for tracking: ${url}`);
83
+ res(img);
84
+ }
85
+ catch (err) {
86
+ console.error(`[WebXRImageTracking] Failed to decode marker image for tracking: ${url}`, err);
87
+ _imageElements.delete(url); // allow a retry later (e.g. re-entering AR)
88
+ res(null);
89
+ }
90
+ });
91
+ imageElement.addEventListener("error", (evt) => {
92
+ console.error(`[WebXRImageTracking] Failed to load marker image for tracking: ${url}`, evt);
93
+ _imageElements.delete(url); // allow a retry later (e.g. re-entering AR)
94
+ res(null);
95
+ });
96
+ imageElement.src = url;
97
+ });
98
+
99
+ _imageLoadingPromises.set(url, promise);
100
+ promise.finally(() => {
101
+ _imageLoadingPromises.delete(url);
102
+ });
103
+
104
+ return promise;
105
+ }
@@ -28,8 +28,17 @@ declare type HitPointObject = Object3D & { material: Material & { opacity: numbe
28
28
  *
29
29
  * Additionally it supports teleporting using the right controller's thumbstick or by pinching the index finger tip in front of the hand (if hand tracking is enabled).
30
30
  * It also visualizes controller rays and hit points in the scene.
31
- *
32
- *
31
+ *
32
+ * The component operates on the active XR rig and controllers from the {@link NeedleXRSession} (`args.xr.rig`,
33
+ * `args.xr.leftController` / `rightController`) and receives `onUpdateXR` regardless of where it sits in the hierarchy.
34
+ * It does NOT need to be on the same GameObject as {@link WebXR}, and does not read {@link WebXR} from its parents.
35
+ *
36
+ * **Duplicate-handler gotcha:** {@link WebXR.setDefaultMovementEnabled} (called when `WebXR.useDefaultControls` is enabled,
37
+ * the default) adds an `XRControllerMovement` to the WebXR GameObject only if that object has none. If you place your own
38
+ * `XRControllerMovement` on a different object, WebXR still adds a second, default one at runtime — two handlers then run
39
+ * simultaneously (doubled movement, and your teleport settings such as {@link XRControllerMovement.useTeleportTarget} are
40
+ * bypassed by the default handler). Put your component on the WebXR GameObject, or disable `WebXR.useDefaultControls`.
41
+ *
33
42
  * @summary Move the XR rig using controller input
34
43
  * @category XR
35
44
  * @group Components
@@ -61,7 +70,15 @@ export class XRControllerMovement extends Behaviour implements XRMovementBehavio
61
70
  @serializable()
62
71
  usePinchToTeleport: boolean = true;
63
72
 
64
- /** Enable to only allow teleporting on objects with a TeleportTarget component (see {@link TeleportTarget})
73
+ /** Enable to only allow teleporting on objects with a {@link TeleportTarget} component.
74
+ *
75
+ * The teleport ray is a **visual raycast against rendered meshes** (not physics colliders):
76
+ * it hits the rendered geometry and then looks for a {@link TeleportTarget} on the hit object
77
+ * or any of its parents. A collider is NOT required on the target — only a visible mesh that
78
+ * is not on the IgnoreRaycast layer.
79
+ *
80
+ * When enabled, teleporting is rejected if the ray does not hit a `TeleportTarget` (the
81
+ * ground-plane fallback used in free teleport mode is also disabled).
65
82
  * @default false
66
83
  */
67
84
  @serializable()