@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.
- package/CHANGELOG.md +10 -0
- package/dist/{needle-engine.bundle-X7Y4A-EO.min.js → needle-engine.bundle-CHq9xqXv.min.js} +144 -144
- package/dist/{needle-engine.bundle-D8gP8Nd0.umd.cjs → needle-engine.bundle-DYPk7VZg.umd.cjs} +146 -146
- package/dist/{needle-engine.bundle-CaD5rOJ0.js → needle-engine.bundle-XZ6cKssu.js} +6086 -6052
- package/dist/needle-engine.d.ts +49 -25
- package/dist/needle-engine.js +251 -251
- package/dist/needle-engine.min.js +1 -1
- package/dist/needle-engine.umd.cjs +1 -1
- package/lib/engine/api.d.ts +1 -1
- package/lib/engine/api.js +1 -1
- package/lib/engine/api.js.map +1 -1
- package/lib/engine/engine_init.js +2 -2
- package/lib/engine/engine_init.js.map +1 -1
- package/lib/engine/engine_license.d.ts +7 -7
- package/lib/engine/engine_license.js +71 -71
- package/lib/engine/engine_license.js.map +1 -1
- package/lib/engine/engine_networking_blob.js +3 -3
- package/lib/engine/engine_networking_blob.js.map +1 -1
- package/lib/engine/engine_utils_qrcode.js +2 -2
- package/lib/engine/engine_utils_qrcode.js.map +1 -1
- package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
- package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
- package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
- package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.ar-overlay.js +11 -0
- package/lib/engine/webcomponents/needle-engine.ar-overlay.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.js +2 -2
- package/lib/engine/webcomponents/needle-engine.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
- package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
- package/lib/engine/xr/NeedleXRSession.d.ts +7 -2
- package/lib/engine/xr/NeedleXRSession.js +65 -5
- package/lib/engine/xr/NeedleXRSession.js.map +1 -1
- package/lib/engine/xr/TempXRContext.js +2 -2
- package/lib/engine/xr/TempXRContext.js.map +1 -1
- package/lib/engine-components/Component.d.ts +2 -1
- package/lib/engine-components/Component.js.map +1 -1
- package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
- package/lib/engine-components/export/usdz/USDZExporter.js.map +1 -1
- package/lib/engine-components/webxr/WebXRImageTracking.d.ts +35 -18
- package/lib/engine-components/webxr/WebXRImageTracking.js +93 -78
- package/lib/engine-components/webxr/WebXRImageTracking.js.map +1 -1
- package/lib/engine-components/webxr/WebXRImageTracking.utils.d.ts +35 -0
- package/lib/engine-components/webxr/WebXRImageTracking.utils.js +96 -0
- package/lib/engine-components/webxr/WebXRImageTracking.utils.js.map +1 -0
- package/package.json +1 -1
- package/plugins/common/license.js +4 -4
- package/plugins/vite/license.js +4 -4
- package/src/engine/api.ts +1 -1
- package/src/engine/engine_init.ts +2 -2
- package/src/engine/engine_license.ts +68 -68
- package/src/engine/engine_networking_blob.ts +3 -3
- package/src/engine/engine_utils_qrcode.ts +2 -2
- package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
- package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
- package/src/engine/webcomponents/needle-engine.ar-overlay.ts +13 -0
- package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
- package/src/engine/webcomponents/needle-engine.ts +2 -2
- package/src/engine/xr/NeedleXRSession.ts +68 -8
- package/src/engine/xr/TempXRContext.ts +2 -2
- package/src/engine-components/Component.ts +2 -1
- package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
- package/src/engine-components/webxr/WebXRImageTracking.ts +120 -66
- 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 {
|
|
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 =
|
|
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 =
|
|
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() &&
|
|
396
|
+
// if (!isDevEnvironment() && _$vVym) {
|
|
397
397
|
// if (debugLicense) console.log("Waiting for runtime license check");
|
|
398
|
-
// await
|
|
399
|
-
// commercialLicense =
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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 *
|
|
1325
|
-
const expectedHeight = Math.floor(window.innerHeight *
|
|
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 {
|
|
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 (
|
|
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 {
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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:
|
|
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
|
|
623
|
-
*
|
|
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 -
|
|
628
|
-
*
|
|
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
|
-
*
|
|
631
|
-
*
|
|
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(
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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(
|
|
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
|
+
}
|