@needle-tools/engine 5.1.8 → 5.1.10

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 (108) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/SKILL.md +62 -4
  3. package/components.needle.json +1 -1
  4. package/dist/{needle-engine.bundle-CaD5rOJ0.js → needle-engine.bundle-DP--RGAU.js} +6126 -5969
  5. package/dist/{needle-engine.bundle-X7Y4A-EO.min.js → needle-engine.bundle-DZD-GG7l.min.js} +144 -143
  6. package/dist/{needle-engine.bundle-D8gP8Nd0.umd.cjs → needle-engine.bundle-Dv5TidOk.umd.cjs} +141 -140
  7. package/dist/needle-engine.d.ts +133 -36
  8. package/dist/needle-engine.js +617 -613
  9. package/dist/needle-engine.min.js +1 -1
  10. package/dist/needle-engine.umd.cjs +1 -1
  11. package/dist/three-examples.js +727 -781
  12. package/dist/three-examples.min.js +12 -12
  13. package/dist/three-examples.umd.cjs +9 -9
  14. package/lib/engine/api.d.ts +1 -1
  15. package/lib/engine/api.js +1 -1
  16. package/lib/engine/api.js.map +1 -1
  17. package/lib/engine/engine_init.js +2 -2
  18. package/lib/engine/engine_init.js.map +1 -1
  19. package/lib/engine/engine_license.d.ts +7 -7
  20. package/lib/engine/engine_license.js +71 -71
  21. package/lib/engine/engine_license.js.map +1 -1
  22. package/lib/engine/engine_materialpropertyblock.d.ts +13 -4
  23. package/lib/engine/engine_materialpropertyblock.js +16 -5
  24. package/lib/engine/engine_materialpropertyblock.js.map +1 -1
  25. package/lib/engine/engine_networking_blob.js +3 -3
  26. package/lib/engine/engine_networking_blob.js.map +1 -1
  27. package/lib/engine/engine_utils_qrcode.js +2 -2
  28. package/lib/engine/engine_utils_qrcode.js.map +1 -1
  29. package/lib/engine/postprocessing/postprocessing.d.ts +18 -0
  30. package/lib/engine/postprocessing/postprocessing.js +31 -2
  31. package/lib/engine/postprocessing/postprocessing.js.map +1 -1
  32. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  33. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
  34. package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
  35. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  36. package/lib/engine/webcomponents/needle-engine.ar-overlay.js +11 -0
  37. package/lib/engine/webcomponents/needle-engine.ar-overlay.js.map +1 -1
  38. package/lib/engine/webcomponents/needle-engine.js +2 -2
  39. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  40. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  41. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  42. package/lib/engine/xr/NeedleXRSession.d.ts +7 -2
  43. package/lib/engine/xr/NeedleXRSession.js +65 -5
  44. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  45. package/lib/engine/xr/TempXRContext.js +2 -2
  46. package/lib/engine/xr/TempXRContext.js.map +1 -1
  47. package/lib/engine/xr/XRHandMeshModel.d.ts +31 -0
  48. package/lib/engine/xr/XRHandMeshModel.js +153 -0
  49. package/lib/engine/xr/XRHandMeshModel.js.map +1 -0
  50. package/lib/engine-components/Component.d.ts +2 -1
  51. package/lib/engine-components/Component.js.map +1 -1
  52. package/lib/engine-components/ReflectionProbe.js +3 -3
  53. package/lib/engine-components/ReflectionProbe.js.map +1 -1
  54. package/lib/engine-components/RendererLightmap.js +1 -1
  55. package/lib/engine-components/RendererLightmap.js.map +1 -1
  56. package/lib/engine-components/SyncedRoom.js +5 -0
  57. package/lib/engine-components/SyncedRoom.js.map +1 -1
  58. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  59. package/lib/engine-components/export/usdz/USDZExporter.js.map +1 -1
  60. package/lib/engine-components/postprocessing/Effects/DepthOfField.js +10 -2
  61. package/lib/engine-components/postprocessing/Effects/DepthOfField.js.map +1 -1
  62. package/lib/engine-components/postprocessing/index.d.ts +4 -0
  63. package/lib/engine-components/postprocessing/index.js +7 -0
  64. package/lib/engine-components/postprocessing/index.js.map +1 -1
  65. package/lib/engine-components/utils/LookAt.d.ts +13 -4
  66. package/lib/engine-components/utils/LookAt.js +13 -4
  67. package/lib/engine-components/utils/LookAt.js.map +1 -1
  68. package/lib/engine-components/webxr/WebXR.js +1 -1
  69. package/lib/engine-components/webxr/WebXR.js.map +1 -1
  70. package/lib/engine-components/webxr/WebXRImageTracking.d.ts +35 -18
  71. package/lib/engine-components/webxr/WebXRImageTracking.js +93 -78
  72. package/lib/engine-components/webxr/WebXRImageTracking.js.map +1 -1
  73. package/lib/engine-components/webxr/WebXRImageTracking.utils.d.ts +35 -0
  74. package/lib/engine-components/webxr/WebXRImageTracking.utils.js +96 -0
  75. package/lib/engine-components/webxr/WebXRImageTracking.utils.js.map +1 -0
  76. package/lib/engine-components/webxr/controllers/XRControllerModel.d.ts +1 -1
  77. package/lib/engine-components/webxr/controllers/XRControllerModel.js +12 -7
  78. package/lib/engine-components/webxr/controllers/XRControllerModel.js.map +1 -1
  79. package/package.json +2 -2
  80. package/plugins/common/license.js +4 -4
  81. package/plugins/vite/license.js +4 -4
  82. package/src/engine/api.ts +1 -1
  83. package/src/engine/engine_init.ts +2 -2
  84. package/src/engine/engine_license.ts +68 -68
  85. package/src/engine/engine_materialpropertyblock.ts +17 -5
  86. package/src/engine/engine_networking_blob.ts +3 -3
  87. package/src/engine/engine_utils_qrcode.ts +2 -2
  88. package/src/engine/postprocessing/postprocessing.ts +32 -2
  89. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  90. package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
  91. package/src/engine/webcomponents/needle-engine.ar-overlay.ts +13 -0
  92. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  93. package/src/engine/webcomponents/needle-engine.ts +2 -2
  94. package/src/engine/xr/NeedleXRSession.ts +68 -8
  95. package/src/engine/xr/TempXRContext.ts +2 -2
  96. package/src/engine/xr/XRHandMeshModel.ts +179 -0
  97. package/src/engine-components/Component.ts +2 -1
  98. package/src/engine-components/ReflectionProbe.ts +3 -3
  99. package/src/engine-components/RendererLightmap.ts +1 -1
  100. package/src/engine-components/SyncedRoom.ts +5 -0
  101. package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
  102. package/src/engine-components/postprocessing/Effects/DepthOfField.ts +10 -2
  103. package/src/engine-components/postprocessing/index.ts +7 -0
  104. package/src/engine-components/utils/LookAt.ts +13 -4
  105. package/src/engine-components/webxr/WebXR.ts +1 -1
  106. package/src/engine-components/webxr/WebXRImageTracking.ts +120 -66
  107. package/src/engine-components/webxr/WebXRImageTracking.utils.ts +105 -0
  108. package/src/engine-components/webxr/controllers/XRControllerModel.ts +13 -7
@@ -83,12 +83,20 @@ export class DepthOfField extends PostProcessingEffect {
83
83
  // inverted: true,
84
84
  // // blendFunction: BlendFunction.SET,
85
85
  // });
86
+ // Seed from the parameters, the same way BloomEffect does. The
87
+ // onValueChanged handlers below only fire on a LATER assignment, so
88
+ // hardcoding defaults here discarded anything set before the effect
89
+ // was created — which is the usual case, since components are
90
+ // configured right after addComponent but the effect is built when
91
+ // the composer asks for it. Values applied in awake/start were lost
92
+ // and only took hold if the same value was assigned a second time.
86
93
  const dof = new MODULES.POSTPROCESSING.MODULE.DepthOfFieldEffect(this.context.mainCamera!, {
87
- worldFocusRange: .2,
94
+ worldFocusRange: this.focalLength.value,
88
95
  focalLength: 1,
89
- bokehScale: 20,
96
+ bokehScale: this.aperture.value,
90
97
  resolutionScale: this.resolutionScale.value,
91
98
  });
99
+ dof.cocMaterial.worldFocusDistance = this.focusDistance.value;
92
100
 
93
101
  this.focusDistance.onValueChanged = v => {
94
102
  dof.cocMaterial.worldFocusDistance = v;
@@ -1,6 +1,13 @@
1
1
  export * from "./PostProcessingEffect.js";
2
2
  export * from "./PostProcessingHandler.js"
3
3
  export { PostProcessingEffectOrder } from "./utils.js";
4
+ /* Effect option enums. The effect components themselves are exported from
5
+ codegen/components.ts, but these were unreachable: `DepthOfField.mode`
6
+ could not be set to a named value from outside the package. */
7
+ export { DepthOfFieldMode } from "./Effects/DepthOfField.js";
8
+ export { QualityLevel } from "./Effects/Antialiasing.js";
9
+ export { ScreenSpaceAmbientOcclusionN8QualityMode } from "./Effects/ScreenspaceAmbientOcclusionN8.js";
10
+ export { NEToneMappingMode } from "./Effects/Tonemapping.utils.js";
4
11
  export { PostProcessingManager } from "./Volume.js"
5
12
  export { Volume } from "./Volume.js"
6
13
  export * from "./VolumeParameter.js"
@@ -20,25 +20,34 @@ import { Behaviour } from "../Component.js";
20
20
  export class LookAt extends Behaviour implements UsdzBehaviour {
21
21
 
22
22
  /**
23
- * The target object to look at. If not set, the main camera will be used.
23
+ * The target object to look at.
24
+ * @default undefined — when no target is set the main camera is used
24
25
  */
25
26
  @serializable(Object3D)
26
27
  target?: Object3D;
27
28
 
28
29
  /**
29
- * Inverts the forward direction.
30
+ * Inverts the forward direction, so the object turns its back to the target
31
+ * instead of facing it. Useful for models authored facing -Z.
32
+ * @default false
30
33
  */
31
34
  @serializable()
32
35
  invertForward: boolean = false;
33
36
 
34
37
  /**
35
- * Keep the up direction.
38
+ * Keeps the object upright while turning, so it only rotates around the up
39
+ * axis rather than tilting to point directly at a target above or below it.
40
+ * @default true
36
41
  */
37
42
  @serializable()
38
43
  keepUpDirection: boolean = true;
39
44
 
40
45
  /**
41
- * Copy the target rotation.
46
+ * Copies the target's rotation instead of aiming at its position. Use this to
47
+ * match a target's orientation, for example to align with the camera.
48
+ *
49
+ * Ignored during a VR or AR session.
50
+ * @default false
42
51
  */
43
52
  @serializable()
44
53
  copyTargetRotation: boolean = false;
@@ -520,7 +520,7 @@ export class WebXR extends Behaviour {
520
520
  models = this.gameObject.addComponent(XRControllerModel)!;
521
521
  this._createdComponentsInSession.push(models);
522
522
  models.createControllerModel = this.showControllerModels;
523
- models.createHandModel == this.showHandModels;
523
+ models.createHandModel = this.showHandModels;
524
524
  }
525
525
  if (models) models.enabled = enabled;
526
526
  return models;
@@ -4,6 +4,7 @@ import { Object3DEventMap } from "three";
4
4
  import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../../engine/debug/index.js";
5
5
  import { AssetReference } from "../../engine/engine_addressables.js";
6
6
  import { Context } from "../../engine/engine_context.js";
7
+ import { Telemetry } from "../../engine/engine_license.js";
7
8
  import { serializable } from "../../engine/engine_serialization.js";
8
9
  import { IGameObject } from "../../engine/engine_types.js";
9
10
  import { CircularBuffer, delay, DeviceUtilities, getParam } from "../../engine/engine_utils.js";
@@ -14,6 +15,7 @@ import { USDZExporter } from "../../engine-components/export/usdz/USDZExporter.j
14
15
  import { Behaviour, GameObject } from "../Component.js";
15
16
  import { EventList } from "../EventList.js";
16
17
  import { Renderer } from "../Renderer.js";
18
+ import { _imageElements, collectTrackedImages, loadImage } from "./WebXRImageTracking.utils.js";
17
19
 
18
20
  // https://github.com/immersive-web/marker-tracking/blob/main/explainer.md
19
21
 
@@ -216,9 +218,9 @@ export class WebXRImageTrackingModel {
216
218
  * @param params.imageDoesNotMove Enable for static markers (floor/wall mounted) to improve tracking stability
217
219
  * @param params.hideWhenTrackingIsLost If true, hides the object when tracking is lost; if false, leaves it at the last known position
218
220
  */
219
- constructor(params: { url: string, widthInMeters: number, /** Object to track */ object: AssetReference | Object3D, createObjectInstance?: boolean, imageDoesNotMove?: boolean, hideWhenTrackingIsLost?: boolean }) {
221
+ constructor(params: Omit<WebXRImageTrackingOptions, "asPrimary">) {
220
222
  this.image = params.url;
221
- this.widthInMeters = params.widthInMeters;
223
+ if (params.widthInMeters !== undefined) this.widthInMeters = params.widthInMeters;
222
224
  if (params.object instanceof Object3D) {
223
225
  this.object = new AssetReference({ asset: params.object });
224
226
  }
@@ -322,6 +324,27 @@ export class WebXRImageTrackingModel {
322
324
  }
323
325
  }
324
326
 
327
+ /**
328
+ * Options for adding a trackable image marker via {@link WebXRImageTracking.addImage}.
329
+ * A convenience shape that also accepts a plain {@link Object3D} for `object`.
330
+ */
331
+ export interface WebXRImageTrackingOptions {
332
+ /** URL to the marker image to track. Use high-contrast images with unique features for reliable tracking. */
333
+ url?: string;
334
+ /** The 3D object (or {@link AssetReference}) to display when the marker is detected. */
335
+ object: AssetReference | Object3D;
336
+ /** Physical width of the printed marker in meters. Must match the real marker size. @default 0.25 */
337
+ widthInMeters?: number;
338
+ /** Create a new instance of the object for each detection. @default false */
339
+ createObjectInstance?: boolean;
340
+ /** Enable for static markers (walls/floor) to improve tracking stability. @default false */
341
+ imageDoesNotMove?: boolean;
342
+ /** Hide the object when tracking is lost. @default true */
343
+ hideWhenTrackingIsLost?: boolean;
344
+ /** Make this the primary marker (used in QuickLook fallback mode). @default false */
345
+ asPrimary?: boolean;
346
+ }
347
+
325
348
  /** Data passed to image tracking event listeners. */
326
349
  export interface WebXRImageTrackingEvent {
327
350
  /** The images currently being tracked this frame. */
@@ -619,27 +642,37 @@ export class WebXRImageTracking extends Behaviour {
619
642
  * **Tip:** Add all your markers upfront. In WebXR mode they all work simultaneously.
620
643
  * In QuickLook mode, the first (primary) marker is used.
621
644
  *
622
- * @param image The marker configuration to add
623
- * @param asPrimary Set to true to make this the primary marker (for QuickLook fallback)
645
+ * @param image The marker configuration - either a plain options object or a {@link WebXRImageTrackingModel}.
646
+ * Set `asPrimary: true` to make this the primary marker (for QuickLook fallback).
624
647
  *
625
648
  * @example
626
649
  * ```ts
627
- * // Super simple - works across all platforms
628
- * const marker = new WebXRImageTrackingModel({
650
+ * // Super simple - just pass the marker url and the object to show
651
+ * imageTracking.addImage({
629
652
  * url: "https://mysite.com/poster.png",
630
- * widthInMeters: 0.42, // A3 poster width
631
- * object: cool3DModel
653
+ * object: cool3DModel,
654
+ * widthInMeters: 0.42, // A3 poster width (optional, defaults to 0.25)
632
655
  * });
633
- * imageTracking.addImage(marker);
634
656
  * // That's it! Needle does the rest.
635
657
  * ```
636
658
  */
637
- addImage(image: WebXRImageTrackingModel, asPrimary: boolean = false) {
638
- if (!this.trackedImages.includes(image)) {
639
- this.trackedImages.push(image);
640
- loadImage(image.image!);
659
+ addImage(options: WebXRImageTrackingOptions): void;
660
+ addImage(model: WebXRImageTrackingModel): void;
661
+ addImage(image: WebXRImageTrackingOptions | WebXRImageTrackingModel): void {
662
+ // Accept either a ready-made model or a plain options object (which allows `object` to be a
663
+ // plain Object3D). Normalize to a WebXRImageTrackingModel; the constructor wraps Object3D in
664
+ // an AssetReference for us.
665
+ const model = image instanceof WebXRImageTrackingModel ? image : new WebXRImageTrackingModel(image);
666
+ if (!this.trackedImages.includes(model)) {
667
+ this.trackedImages.push(model);
668
+ loadImage(model.image!);
669
+ if(isDevEnvironment()) {
670
+ if(model.widthInMeters <= 0) {
671
+ this.onInvalidImageSizeDetected(model.image!);
672
+ }
673
+ }
641
674
  }
642
- if (asPrimary) this.setPrimaryImage(image);
675
+ if (!(image instanceof WebXRImageTrackingModel) && image.asPrimary) this.setPrimaryImage(model);
643
676
  }
644
677
 
645
678
 
@@ -689,6 +722,9 @@ export class WebXRImageTracking extends Behaviour {
689
722
  for (const trackedImage of this.trackedImages) {
690
723
  if (trackedImage.image) {
691
724
  loadImage(trackedImage.image);
725
+ if(isDevEnvironment() && trackedImage.widthInMeters <= 0) {
726
+ this.onInvalidImageSizeDetected(trackedImage.image);
727
+ }
692
728
  }
693
729
  }
694
730
  }
@@ -701,6 +737,16 @@ export class WebXRImageTracking extends Behaviour {
701
737
  USDZExporter.beforeExport.removeEventListener(this.onBeforeUSDZExport);
702
738
  }
703
739
 
740
+
741
+ private onInvalidImageSizeDetected(url:string) {
742
+ if(globalThis["__NEEDLE_DEBUG_WebARImageSizeInvalid__"]) return;
743
+ globalThis["__NEEDLE_DEBUG_WebARImageSizeInvalid__"] = true;
744
+ console.warn(`[WebXRImageTracking] Invalid marker size detected for image ${url}. Ensure 'widthInMeters' is greater than 0 for accurate tracking.`);
745
+ if(isDevEnvironment()) {
746
+ showBalloonWarning(`[WebXRImageTracking] Invalid marker size detected for image ${url}. Ensure 'widthInMeters' is greater than 0 for accurate tracking.`);
747
+ }
748
+ }
749
+
704
750
  private onBeforeUSDZExport = (args: { exporter: USDZExporter }) => {
705
751
  if (this.activeAndEnabled && this.trackedImages?.length) {
706
752
  args.exporter.extensions.push(new ImageTrackingExtension(args.exporter, this));
@@ -709,31 +755,30 @@ export class WebXRImageTracking extends Behaviour {
709
755
 
710
756
 
711
757
  /** @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");
758
+ async onBeforeXR(_mode: XRSessionMode, args: XRSessionInit & { trackedImages: Array<any> }): Promise<void> {
759
+ if (!this.trackedImages) return;
718
760
 
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
- }
761
+ args.optionalFeatures = args.optionalFeatures || [];
762
+ if (!args.optionalFeatures.includes("image-tracking"))
763
+ args.optionalFeatures.push("image-tracking");
764
+
765
+ // Resolve the marker bitmaps *before* the session is requested.
766
+ // onBeforeXR runs the moment "Enter AR" is tapped. Marker images load asynchronously
767
+ // (network fetch + createImageBitmap), so without awaiting here a marker that hasn't finished
768
+ // decoding yet would be silently dropped from `trackedImages` and never registered with the
769
+ // native/WebXR session - it would then only start working after exiting and re-entering AR,
770
+ // once the bitmap has been cached. Awaiting the (idempotent) loads closes that race.
771
+ // A marker that can not be loaded resolves to null and is skipped individually, so a single
772
+ // unavailable image never rejects the whole XR session request. See forum topic #2859.
773
+ this.trackedImageIndexMap.clear();
774
+ const indexMap = await collectTrackedImages(this.trackedImages, args);
775
+ for (const [index, model] of indexMap) {
776
+ this.trackedImageIndexMap.set(index, model);
732
777
  }
733
778
  }
734
779
 
735
780
  /** @internal */
736
- onEnterXR(_args: NeedleXREventArgs): void {
781
+ onEnterXR(args: NeedleXREventArgs): void {
737
782
  if (this.trackedImages) {
738
783
  for (const trackedImage of this.trackedImages) {
739
784
  if (trackedImage.object?.asset) {
@@ -753,11 +798,52 @@ export class WebXRImageTracking extends Behaviour {
753
798
  for (const trackedData of this.imageToObjectMap.values()) {
754
799
  trackedData.frames = 0;
755
800
  }
801
+
802
+ // A session GRANTED before the scene finished loading (App Clip / NeedleGo
803
+ // handoff, Quest link traversal) was requested without this component's
804
+ // markers — onBeforeXR never contributed to its init, and the WebXR spec has
805
+ // no way to add tracked images to a running session. The NeedleGo polyfill
806
+ // CAN (ARKit re-runs its configuration with new detection images), so
807
+ // register late where supported and surface the misconfiguration otherwise.
808
+ if (this.trackedImages?.length && this.trackedImageIndexMap.size === 0) {
809
+ if (isDevEnvironment() || debug) console.debug(`[WebXRImageTracking] No marker images made it into the session init (granted session?) — attempting runtime registration for ${this.trackedImages.length} image(s)`);
810
+ this.registerImagesWithRunningSession(args.xr.session);
811
+ }
812
+ else if (this.trackedImages?.length && (isDevEnvironment() || debug)) {
813
+ console.debug(`[WebXRImageTracking] ${this.trackedImageIndexMap.size} marker image(s) registered via the session init`);
814
+ }
756
815
  };
757
816
 
817
+ /** Late-registers this component's marker images into an already running session
818
+ * via the NeedleGo polyfill's runtime hook. See {@link onEnterXR}. */
819
+ private async registerImagesWithRunningSession(session: XRSession) {
820
+ const register = (session as XRSession & { nonStandard_registerTrackedImages?: (images: Array<{ image: ImageBitmap, widthInMeters: number }>) => number }).nonStandard_registerTrackedImages;
821
+ if (typeof register !== "function") {
822
+ console.warn("[WebXRImageTracking] The XR session was started before the scene finished loading and does not support registering marker images at runtime — image tracking will not work in this session. Exit and re-enter AR from the loaded page.");
823
+ Telemetry.sendEvent(this.context, "xr", { action: "image_tracking_missed_session_init" });
824
+ return;
825
+ }
826
+ const args: { trackedImages?: Array<{ image: ImageBitmap, widthInMeters: number }> } = {};
827
+ const indexMap = await collectTrackedImages(this.trackedImages, args);
828
+ if (!args.trackedImages?.length) return;
829
+ // the session may have ended (or been replaced) while marker bitmaps loaded
830
+ if (this.context.xr?.session !== session) return;
831
+ const baseIndex = register.call(session, args.trackedImages);
832
+ if (baseIndex < 0) return;
833
+ for (const [index, model] of indexMap) {
834
+ this.trackedImageIndexMap.set(baseIndex + index, model);
835
+ }
836
+ if (isDevEnvironment() || debug) console.debug(`[WebXRImageTracking] Registered ${args.trackedImages.length} marker image(s) with the running session (granted before the scene loaded)`);
837
+ }
838
+
758
839
  /** @internal */
759
840
  onLeaveXR(_args: NeedleXREventArgs): void {
760
841
 
842
+ // result indices are scoped to the session that registered the images — a new
843
+ // session rebuilds the map (onBeforeXR, or the late registration in onEnterXR
844
+ // for granted sessions, which detects the empty map)
845
+ this.trackedImageIndexMap.clear();
846
+
761
847
  if (!this.supported && DeviceUtilities.isAndroidDevice()) {
762
848
  showBalloonWarning(this.webXRIncubationsWarning);
763
849
  }
@@ -942,36 +1028,4 @@ export class WebXRImageTracking extends Behaviour {
942
1028
  }
943
1029
  }
944
1030
  }
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
1031
  }
@@ -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
+ }
@@ -1,7 +1,6 @@
1
1
  import { AxesHelper, Group, Material, Mesh, Object3D, XRHandSpace } from "three";
2
2
  import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
3
3
  import { XRControllerModelFactory } from "three/examples/jsm/webxr/XRControllerModelFactory.js";
4
- import { XRHandMeshModel } from "three/examples/jsm/webxr/XRHandMeshModel.js";
5
4
 
6
5
  import { AssetReference } from "../../../engine/engine_addressables.js";
7
6
  import { setDontDestroy } from "../../../engine/engine_gameobject.js";
@@ -15,6 +14,7 @@ import { NeedleXRController, type NeedleXRControllerEventArgs, type NeedleXREven
15
14
  import { registerComponentExtension, registerExtensions } from "../../../engine/extensions/extensions.js";
16
15
  import { NEEDLE_progressive } from "../../../engine/extensions/NEEDLE_progressive.js";
17
16
  import { flipForwardMatrix } from "../../../engine/xr/internal.js";
17
+ import { XRHandMeshModel } from "../../../engine/xr/XRHandMeshModel.js";
18
18
  import { Behaviour, Component, GameObject } from "../../Component.js"
19
19
 
20
20
  const debug = getParam("debugwebxr");
@@ -288,8 +288,6 @@ export class XRControllerModel extends Behaviour {
288
288
 
289
289
  const loader = new GLTFLoader();
290
290
  addDracoAndKTX2Loaders(loader, context);
291
- await registerExtensions(loader, context, this.sourceId ?? "", this.sourceId ?? "");
292
- const componentsExtension = registerComponentExtension(loader);
293
291
 
294
292
  let filename = "";
295
293
 
@@ -306,22 +304,30 @@ export class XRControllerModel extends Behaviour {
306
304
  loader.setPath('https://cdn.jsdelivr.net/npm/@webxr-input-profiles/assets@1.0/dist/profiles/generic-hand/');
307
305
  }
308
306
 
307
+ // The hand is a separate glTF and needs its own source id: its components resolve their
308
+ // relative uris against it (resolveUrl) and its lightmaps/skybox are keyed by it. Using
309
+ // this component's id resolved the hand's uris next to the HOST glb and let the hand's
310
+ // skybox overwrite the scene's. customHand.url is already resolved, `filename` is not
311
+ // (extension stripped for XRHandMeshModel).
312
+ const handSourceId = customHand?.url ?? `${loader.path}${filename}.glb`;
313
+ await registerExtensions(loader, context, handSourceId, handSourceId);
314
+ const componentsExtension = registerComponentExtension(loader);
315
+
309
316
 
310
317
  const handObject = new Object3D();
311
318
  setDontDestroy(handObject);
312
- // @ts-ignore
313
319
  const handmesh = new XRHandMeshModel(handObject, hand, loader.path, filename, loader, (object: Object3D) => {
314
320
 
315
321
  const gltf = componentsExtension?.gltf;
316
- // The XRHandMeshController removes the hand from the gltf before calling this callback
317
- // we need this in the GLTF scene however for creating the builtin components
322
+ // XRHandMeshModel reparents the hand out of the gltf scene before calling this
323
+ // callback; we need it back in the scene so the builtin components are created on it
318
324
  if (gltf?.scene.children?.length === 0) {
319
325
  gltf.scene.children[0] = object;
320
326
  }
321
327
 
322
328
  // console.log(controller.side, componentsExtension.gltf, object, componentsExtension.gltf.scene?.children)
323
329
  if (componentsExtension?.gltf)
324
- getLoader().createBuiltinComponents(comp.context, comp.sourceId || filename, componentsExtension.gltf, null, componentsExtension);
330
+ getLoader().createBuiltinComponents(comp.context, handSourceId, componentsExtension.gltf, null, componentsExtension);
325
331
 
326
332
  // The hand mesh should not receive raycasts
327
333
  object.traverse(child => {