@needle-tools/engine 5.1.8 → 5.1.9

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 (64) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/{needle-engine.bundle-X7Y4A-EO.min.js → needle-engine.bundle-CHq9xqXv.min.js} +144 -144
  3. package/dist/{needle-engine.bundle-D8gP8Nd0.umd.cjs → needle-engine.bundle-DYPk7VZg.umd.cjs} +146 -146
  4. package/dist/{needle-engine.bundle-CaD5rOJ0.js → needle-engine.bundle-XZ6cKssu.js} +6086 -6052
  5. package/dist/needle-engine.d.ts +49 -25
  6. package/dist/needle-engine.js +251 -251
  7. package/dist/needle-engine.min.js +1 -1
  8. package/dist/needle-engine.umd.cjs +1 -1
  9. package/lib/engine/api.d.ts +1 -1
  10. package/lib/engine/api.js +1 -1
  11. package/lib/engine/api.js.map +1 -1
  12. package/lib/engine/engine_init.js +2 -2
  13. package/lib/engine/engine_init.js.map +1 -1
  14. package/lib/engine/engine_license.d.ts +7 -7
  15. package/lib/engine/engine_license.js +71 -71
  16. package/lib/engine/engine_license.js.map +1 -1
  17. package/lib/engine/engine_networking_blob.js +3 -3
  18. package/lib/engine/engine_networking_blob.js.map +1 -1
  19. package/lib/engine/engine_utils_qrcode.js +2 -2
  20. package/lib/engine/engine_utils_qrcode.js.map +1 -1
  21. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  22. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
  23. package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
  24. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  25. package/lib/engine/webcomponents/needle-engine.ar-overlay.js +11 -0
  26. package/lib/engine/webcomponents/needle-engine.ar-overlay.js.map +1 -1
  27. package/lib/engine/webcomponents/needle-engine.js +2 -2
  28. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  29. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  30. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  31. package/lib/engine/xr/NeedleXRSession.d.ts +7 -2
  32. package/lib/engine/xr/NeedleXRSession.js +65 -5
  33. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  34. package/lib/engine/xr/TempXRContext.js +2 -2
  35. package/lib/engine/xr/TempXRContext.js.map +1 -1
  36. package/lib/engine-components/Component.d.ts +2 -1
  37. package/lib/engine-components/Component.js.map +1 -1
  38. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  39. package/lib/engine-components/export/usdz/USDZExporter.js.map +1 -1
  40. package/lib/engine-components/webxr/WebXRImageTracking.d.ts +35 -18
  41. package/lib/engine-components/webxr/WebXRImageTracking.js +93 -78
  42. package/lib/engine-components/webxr/WebXRImageTracking.js.map +1 -1
  43. package/lib/engine-components/webxr/WebXRImageTracking.utils.d.ts +35 -0
  44. package/lib/engine-components/webxr/WebXRImageTracking.utils.js +96 -0
  45. package/lib/engine-components/webxr/WebXRImageTracking.utils.js.map +1 -0
  46. package/package.json +1 -1
  47. package/plugins/common/license.js +4 -4
  48. package/plugins/vite/license.js +4 -4
  49. package/src/engine/api.ts +1 -1
  50. package/src/engine/engine_init.ts +2 -2
  51. package/src/engine/engine_license.ts +68 -68
  52. package/src/engine/engine_networking_blob.ts +3 -3
  53. package/src/engine/engine_utils_qrcode.ts +2 -2
  54. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  55. package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
  56. package/src/engine/webcomponents/needle-engine.ar-overlay.ts +13 -0
  57. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  58. package/src/engine/webcomponents/needle-engine.ts +2 -2
  59. package/src/engine/xr/NeedleXRSession.ts +68 -8
  60. package/src/engine/xr/TempXRContext.ts +2 -2
  61. package/src/engine-components/Component.ts +2 -1
  62. package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
  63. package/src/engine-components/webxr/WebXRImageTracking.ts +120 -66
  64. package/src/engine-components/webxr/WebXRImageTracking.utils.ts +105 -0
@@ -63,7 +63,20 @@ export class AROverlayHandler {
63
63
  else {
64
64
  console.warn("WebXRViewer: No DOM Overlay found");
65
65
  }
66
+
67
+ // Balloon messages raised BEFORE the session started live in the
68
+ // needle-engine shadow root — these platforms hide everything except the
69
+ // overlay root during the session, so exactly the messages that explain
70
+ // a broken session start (e.g. a marker image failing ARKit validation)
71
+ // would be invisible. Move them into the AR overlay; they stay inside
72
+ // the overlay container afterwards, which returns to the shadow root on
73
+ // session end.
74
+ const preSessionLogs = context.domElement?.shadowRoot?.querySelector(":scope > [data-needle_engine_debug_overlay]");
75
+ if (preSessionLogs && this.arContainer && this.arContainer !== preSessionLogs.parentNode) {
76
+ this.arContainer.appendChild(preSessionLogs);
77
+ }
66
78
  }
79
+
67
80
  this.ensureQuitARButton(this.arContainer);
68
81
  }
69
82
 
@@ -1,6 +1,6 @@
1
1
  import { needleLogoOnlySVG } from "../assets/index.js"
2
2
  import { isDevEnvironment, showBalloonWarning } from "../debug/index.js";
3
- import { __sKHtRdk, LuMYUbW, __pRlh } from "../engine_license.js";
3
+ import { _$qxlHFE, _iXmRJVD, _$vVym } 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 = LuMYUbW();
208
+ const hasLicense = _iXmRJVD();
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 = __sKHtRdk();
376
+ // let commercialLicense = _$qxlHFE();
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() && __pRlh) {
396
+ // if (!isDevEnvironment() && _$vVym) {
397
397
  // if (debugLicense) console.log("Waiting for runtime license check");
398
- // await __pRlh;
399
- // commercialLicense = __sKHtRdk();
398
+ // await _$vVym;
399
+ // commercialLicense = _$qxlHFE();
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 { __sKHtRdk, Telemetry } from "../engine_license.js";
6
+ import { _$qxlHFE, 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 = __sKHtRdk();
582
+ const allowOverridingDefaultLoading = _$qxlHFE();
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", {
@@ -1,6 +1,6 @@
1
1
  import { Camera, Object3D, PerspectiveCamera, Quaternion, Vector3 } from "three";
2
2
 
3
- import { enableSpatialConsole, isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../debug/index.js";
3
+ import { enableSpatialConsole, isDevEnvironment, showBalloonError, showBalloonMessage, showBalloonWarning } from "../debug/index.js";
4
4
  import { Application, internalOnUserInputRegistered } from "../engine_application.js";
5
5
  import { Context, FrameEvent } from "../engine_context.js";
6
6
  import { ContextEvent, ContextRegistry } from "../engine_context_registry.js";
@@ -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;
@@ -243,6 +243,20 @@ if (typeof window !== "undefined" && DeviceUtilities.isDesktop() && isDevEnviron
243
243
  });
244
244
  }
245
245
 
246
+ // Native ARKit diagnostics forwarded by the Needle App Clip (WebXRBridge
247
+ // logToWebConsole dispatches these alongside a console line): surface warnings and
248
+ // errors as balloon messages — the balloon overlay lives in the dom-overlay root,
249
+ // so they are visible INSIDE a running AR session (e.g. "marker failed ARKit
250
+ // validation" instead of a marker that silently never tracks).
251
+ if (typeof document !== "undefined") {
252
+ document.addEventListener("needlego-log", evt => {
253
+ const detail = (evt as CustomEvent<{ l?: string, m?: string }>).detail;
254
+ if (!detail?.m) return;
255
+ if (detail.l === "error") showBalloonError(detail.m);
256
+ else if (detail.l === "warn") showBalloonWarning(detail.m);
257
+ });
258
+ }
259
+
246
260
  function handleAutoStart(value: string | null) {
247
261
  if (!value) return;
248
262
  switch (value?.toLowerCase()) {
@@ -757,7 +771,15 @@ export class NeedleXRSession implements INeedleXRSession {
757
771
  console.log("%c" + `Requesting ${mode} session`, "font-weight:bold;");
758
772
  for (const script of scripts) {
759
773
  if (script.onBeforeXR && script.activeAndEnabled && !script.destroyed) {
760
- script.onBeforeXR(mode, init);
774
+ try {
775
+ // Await: onBeforeXR may be async (e.g. WebXRImageTracking awaits marker bitmap loads
776
+ // so late-decoding markers aren't dropped). A failing hook must not abort the session
777
+ // request for every other component.
778
+ await script.onBeforeXR(mode, init);
779
+ }
780
+ catch (err) {
781
+ console.error("[NeedleXRSession] onBeforeXR hook failed:", err);
782
+ }
761
783
  }
762
784
  }
763
785
  this.invokeSessionRequestStart(mode, init, context ?? Context.Current);
@@ -888,7 +910,13 @@ export class NeedleXRSession implements INeedleXRSession {
888
910
  /** XR Session Mode: AR or VR */
889
911
  readonly mode: XRSessionMode;
890
912
 
891
- /**
913
+ /** The framebuffer scale factor this session was started with. On this
914
+ * version the engine never requests a custom scale, so it is always 1 —
915
+ * the property exists so session-resolution handling (e.g. the App Clip
916
+ * canvas scaling) reads one authoritative value. */
917
+ readonly appliedFramebufferScaleFactor: number = 1;
918
+
919
+ /**
892
920
  * The XRSession interface's read-only interactionMode property describes the best space (according to the user agent) for the application to draw an interactive UI for the current session.
893
921
  * @link https://developer.mozilla.org/en-US/docs/Web/API/XRSession/interactionMode
894
922
  */
@@ -1320,13 +1348,19 @@ export class NeedleXRSession implements INeedleXRSession {
1320
1348
  const dpr = window.devicePixelRatio || 1;
1321
1349
  const currentWidth = canvas.width;
1322
1350
  const currentHeight = canvas.height;
1351
+ // The NeedleGo polyfill renders through the canvas, so the canvas buffer
1352
+ // size IS the AR render resolution — apply the session's framebuffer
1353
+ // scale here too (a real WebXR framebuffer gets it via
1354
+ // setFramebufferScaleFactor; without this the App Clip silently renders
1355
+ // at full native DPR and e.g. the splat low preset's 0.8 scale is lost).
1356
+ const scale = dpr * this.appliedFramebufferScaleFactor;
1323
1357
  // Check if DPR is already applied (avoid double-scaling)
1324
- const expectedWidth = Math.floor(window.innerWidth * dpr);
1325
- const expectedHeight = Math.floor(window.innerHeight * dpr);
1358
+ const expectedWidth = Math.floor(window.innerWidth * scale);
1359
+ const expectedHeight = Math.floor(window.innerHeight * scale);
1326
1360
  if (Math.abs(currentWidth - expectedWidth) > 2 || Math.abs(currentHeight - expectedHeight) > 2) {
1327
1361
  canvas.width = expectedWidth;
1328
1362
  canvas.height = expectedHeight;
1329
- console.debug("Applied DPR scaling for Needle AppClip XR session", dpr, canvas.width, canvas.height);
1363
+ console.debug("Applied DPR scaling for Needle AppClip XR session", dpr, this.appliedFramebufferScaleFactor, canvas.width, canvas.height);
1330
1364
  }
1331
1365
  });
1332
1366
  }
@@ -1420,6 +1454,32 @@ export class NeedleXRSession implements INeedleXRSession {
1420
1454
 
1421
1455
  console.debug("XR Session ended");
1422
1456
 
1457
+ // Diagnostic for the App Clip post-session freeze: on device, exactly ONE
1458
+ // render-loop tick runs after session end, then the page appears dead while
1459
+ // focus/visibility events still arrive (observed on every App Clip AR exit).
1460
+ // This heartbeat distinguishes the possible causes: no heartbeat logs = JS
1461
+ // fully suspended by the native layer; heartbeats with rAF ticks ≈ 0 = the
1462
+ // compositor starves rAF (webview still marked hidden); rAF ticks ≈ 60 while
1463
+ // the engine renders nothing = three's animation-loop handoff from the
1464
+ // polyfill session back to the window broke.
1465
+ if (DeviceUtilities.isNeedleAppClip() && isDevEnvironment()) {
1466
+ let rafTicks = 0;
1467
+ let beats = 0;
1468
+ let rafHandle = window.requestAnimationFrame(function countRaf() {
1469
+ rafTicks++;
1470
+ rafHandle = window.requestAnimationFrame(countRaf);
1471
+ });
1472
+ const heartbeat = window.setInterval(() => {
1473
+ beats++;
1474
+ console.debug(`[XR] post-session heartbeat ${beats}/10: window rAF ticks last second=${rafTicks}, engine frame=${this.context.time.frameCount}, visibility=${document.visibilityState}, focus=${document.hasFocus()}`);
1475
+ rafTicks = 0;
1476
+ if (beats >= 10) {
1477
+ window.clearInterval(heartbeat);
1478
+ window.cancelAnimationFrame(rafHandle);
1479
+ }
1480
+ }, 1000);
1481
+ }
1482
+
1423
1483
  Telemetry.sendEvent(Context.Current, "xr", {
1424
1484
  action: "session_end",
1425
1485
  mode: this.mode,
@@ -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 { __sKHtRdk } from "../engine_license.js";
6
+ import { _$qxlHFE } 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 (__sKHtRdk()) {
218
+ if (_$qxlHFE()) {
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 { LuMYUbW } from "../../../engine/engine_license.js";
6
+ import { _iXmRJVD } 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 (!LuMYUbW()) {
280
+ if (!_iXmRJVD()) {
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 (!LuMYUbW()) {
685
+ if (!_iXmRJVD()) {
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 (!LuMYUbW()) {
700
+ if (!_iXmRJVD()) {
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";
@@ -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
+ }