@needle-tools/engine 5.1.0 → 5.1.2
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 +7 -1
- package/dist/{needle-engine.bundle-5oIL49YP.js → needle-engine.bundle-BAUfRyBA.js} +7692 -7506
- package/dist/{needle-engine.bundle-DvOrfXws.umd.cjs → needle-engine.bundle-BL2wG5Te.umd.cjs} +159 -159
- package/dist/{needle-engine.bundle-DAAfj8Cs.min.js → needle-engine.bundle-BpNRkY3s.min.js} +184 -184
- package/dist/needle-engine.d.ts +25 -7
- package/dist/needle-engine.js +533 -533
- 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 +45 -7
- package/lib/engine/engine_license.js +276 -77
- package/lib/engine/engine_license.js.map +1 -1
- package/lib/engine/engine_networking.js +6 -0
- package/lib/engine/engine_networking.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_networking_peer.js +19 -13
- package/lib/engine/engine_networking_peer.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/WebXRButtons.js +5 -6
- package/lib/engine/webcomponents/WebXRButtons.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.js +31 -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 -1
- package/lib/engine/xr/NeedleXRSession.js +70 -11
- package/lib/engine/xr/NeedleXRSession.js.map +1 -1
- package/lib/engine/xr/NeedleXRSync.js +23 -4
- package/lib/engine/xr/NeedleXRSync.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/SyncedTransform.d.ts +5 -1
- package/lib/engine-components/SyncedTransform.js +45 -8
- package/lib/engine-components/SyncedTransform.js.map +1 -1
- package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
- package/lib/engine-components/webxr/Avatar.d.ts +8 -0
- package/lib/engine-components/webxr/Avatar.js +60 -32
- package/lib/engine-components/webxr/Avatar.js.map +1 -1
- package/lib/engine-components/webxr/WebXR.js +25 -14
- package/lib/engine-components/webxr/WebXR.js.map +1 -1
- package/lib/engine-schemes/synced-transform-model.d.ts +2 -0
- package/lib/engine-schemes/synced-transform-model.js +8 -1
- package/lib/engine-schemes/synced-transform-model.js.map +1 -1
- package/package.json +2 -2
- package/plugins/common/license.js +4 -4
- package/plugins/types/userconfig.d.ts +4 -3
- package/plugins/vite/build-pipeline.d.ts +3 -1
- package/plugins/vite/build-pipeline.js +19 -9
- 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 +263 -73
- package/src/engine/engine_networking.ts +5 -0
- package/src/engine/engine_networking_blob.ts +3 -3
- package/src/engine/engine_networking_peer.ts +12 -13
- package/src/engine/engine_utils_qrcode.ts +2 -2
- package/src/engine/webcomponents/WebXRButtons.ts +5 -6
- 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.loading.ts +6 -6
- package/src/engine/webcomponents/needle-engine.ts +31 -2
- package/src/engine/xr/NeedleXRSession.ts +72 -14
- package/src/engine/xr/NeedleXRSync.ts +24 -4
- package/src/engine/xr/TempXRContext.ts +2 -2
- package/src/engine-components/SyncedTransform.ts +49 -10
- package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
- package/src/engine-components/webxr/Avatar.ts +60 -33
- package/src/engine-components/webxr/WebXR.ts +23 -15
- package/src/engine-schemes/synced-transform-model.ts +10 -1
- package/src/engine-schemes/transforms.fbs +1 -0
|
@@ -4,7 +4,7 @@ import { Quaternion, Vector2, Vector3, Vector4 } from "three";
|
|
|
4
4
|
|
|
5
5
|
import { needleLogoOnlySVG } from "./assets/index.js";
|
|
6
6
|
import { isDevEnvironment } from "./debug/debug.js";
|
|
7
|
-
import {
|
|
7
|
+
import { Jkdun } from "./engine_license.js";
|
|
8
8
|
import { InternalAttributeUtils } from "./engine_utils_attributes.js";
|
|
9
9
|
import type { NeedleEngineWebComponent } from "./webcomponents/needle-engine.js";
|
|
10
10
|
|
|
@@ -135,7 +135,7 @@ async function internalRenderQRCodeOverlays(canvas: HTMLCanvasElement, args: { s
|
|
|
135
135
|
console.debug("[QR Code] No web component found")
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
const canUseCustomLogo =
|
|
138
|
+
const canUseCustomLogo = Jkdun();
|
|
139
139
|
|
|
140
140
|
// Query logo src from needle-engine attribute.
|
|
141
141
|
// For any supported attribute it's possible to use "falsey" values (e.g. "0" or "false" to disable the logo in the QR code)
|
|
@@ -217,12 +217,11 @@ export class WebXRButtonFactory {
|
|
|
217
217
|
// make sure to hide the button when we have VR support directly on the device
|
|
218
218
|
if (!DeviceUtilities.isMozillaXR()) { // WebXR Viewer can't attach events before session start
|
|
219
219
|
navigator.xr?.addEventListener("devicechange", () => {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
220
|
+
// isSessionSupported() returns a Promise (always truthy) — must await it.
|
|
221
|
+
// Route through the cached/guarded check so we don't re-trip the policy.
|
|
222
|
+
NeedleXRSession.isSessionSupported("immersive-vr").then(supported => {
|
|
223
|
+
button.style.display = supported ? "none" : "";
|
|
224
|
+
});
|
|
226
225
|
});
|
|
227
226
|
}
|
|
228
227
|
return button;
|
|
@@ -2,7 +2,7 @@ import { Mesh, Object3D, TextureLoader, Vector4 } from "three";
|
|
|
2
2
|
import ThreeMeshUI from "three-mesh-ui";
|
|
3
3
|
|
|
4
4
|
import { addNewComponent } from "../../engine_components.js";
|
|
5
|
-
import {
|
|
5
|
+
import { Jkdun } from "../../engine_license.js";
|
|
6
6
|
import { OneEuroFilterXYZ } from "../../engine_math.js";
|
|
7
7
|
import type { Context } from "../../engine_setup.js";
|
|
8
8
|
import { lookAtObject } from "../../engine_three_utils.js";
|
|
@@ -321,7 +321,7 @@ export class NeedleSpatialMenu {
|
|
|
321
321
|
}
|
|
322
322
|
if (this.menu) {
|
|
323
323
|
const index = this.menu.children.indexOf(this._poweredByNeedleElement as any);
|
|
324
|
-
if (!this._showNeedleLogo &&
|
|
324
|
+
if (!this._showNeedleLogo && Jkdun()) {
|
|
325
325
|
if (index >= 0) {
|
|
326
326
|
this._poweredByNeedleElement.removeFromParent();
|
|
327
327
|
this.markDirty();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { showBalloonMessage } from "../../debug/debug.js";
|
|
2
2
|
import type { Context } from "../../engine_context.js";
|
|
3
|
-
import {
|
|
3
|
+
import { Jkdun, qWc, Telemetry } from "../../engine_license.js";
|
|
4
4
|
import { isLocalNetwork } from "../../engine_networking_utils.js";
|
|
5
5
|
import { HTMLElementBase } from "../../engine_ssr.js";
|
|
6
6
|
import { DeviceUtilities, getParam } from "../../engine_utils.js";
|
|
@@ -778,8 +778,8 @@ export class NeedleMenuElement extends HTMLElementBase {
|
|
|
778
778
|
try {
|
|
779
779
|
// if the user has a license then we CAN hide the needle logo
|
|
780
780
|
// calling this method immediately will cause an issue with vite bundling tho
|
|
781
|
-
window.requestAnimationFrame(() =>
|
|
782
|
-
if (res == true &&
|
|
781
|
+
window.requestAnimationFrame(() => qWc(res => {
|
|
782
|
+
if (res == true && Jkdun() && !debugNonCommercial) {
|
|
783
783
|
let visible = this._userRequestedLogoVisible;
|
|
784
784
|
if (visible === undefined) visible = false;
|
|
785
785
|
this.___onSetLogoVisible(visible);
|
|
@@ -830,7 +830,7 @@ export class NeedleMenuElement extends HTMLElementBase {
|
|
|
830
830
|
// ensure the menu is not hidden or removed
|
|
831
831
|
const requiredParent = this?.parentNode;
|
|
832
832
|
if (this.style.display != "flex" || this.style.visibility != "visible" || this.style.opacity != "1" || requiredParent != this._domElement?.shadowRoot) {
|
|
833
|
-
if (
|
|
833
|
+
if (!Jkdun()) {
|
|
834
834
|
const change = changeEventCounter++;
|
|
835
835
|
// if a user doesn't have a local pro license *but* for development the menu is hidden then we show a warning
|
|
836
836
|
if (isLocalNetwork() && this._userRequestedMenuVisible === false) {
|
|
@@ -918,7 +918,7 @@ export class NeedleMenuElement extends HTMLElementBase {
|
|
|
918
918
|
showNeedleLogo(visible: boolean) {
|
|
919
919
|
this._userRequestedLogoVisible = visible;
|
|
920
920
|
if (!visible) {
|
|
921
|
-
if (
|
|
921
|
+
if (!Jkdun() || debugNonCommercial) {
|
|
922
922
|
console.warn("[Needle Engine] You need a commercial license to hide the Needle Engine logo in production.");
|
|
923
923
|
const localNetwork = isLocalNetwork()
|
|
924
924
|
if (!localNetwork) return;
|
|
@@ -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 { Jkdun, _hyjgv, xWfUNf } 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 = _hyjgv();
|
|
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 = Jkdun();
|
|
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() && xWfUNf) {
|
|
397
397
|
// if (debugLicense) console.log("Waiting for runtime license check");
|
|
398
|
-
// await
|
|
399
|
-
// commercialLicense =
|
|
398
|
+
// await xWfUNf;
|
|
399
|
+
// commercialLicense = Jkdun();
|
|
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 { Jkdun, 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 = Jkdun();
|
|
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", {
|
|
@@ -676,6 +676,7 @@ export class NeedleEngineWebComponent extends HTMLElementBase implements INeedle
|
|
|
676
676
|
if (currentHash !== null && currentHash !== undefined)
|
|
677
677
|
context.hash = currentHash;
|
|
678
678
|
context.alias = alias;
|
|
679
|
+
const loadStartTime = performance.now();
|
|
679
680
|
this._createContextPromise = context.create(args);
|
|
680
681
|
const success = await this._createContextPromise;
|
|
681
682
|
this.applyAttributes();
|
|
@@ -694,6 +695,8 @@ export class NeedleEngineWebComponent extends HTMLElementBase implements INeedle
|
|
|
694
695
|
if (useDefaultLoading && success) {
|
|
695
696
|
this._loadingView?.onLoadingUpdate(1, "creating scene");
|
|
696
697
|
}
|
|
698
|
+
// capture before mutating _didFullyLoad below: was a scene already loaded (src swap) vs cold start?
|
|
699
|
+
const isReload = this._didFullyLoad === true;
|
|
697
700
|
this._didFullyLoad = true;
|
|
698
701
|
this.classList.remove("loading");
|
|
699
702
|
this.classList.add("loading-finished");
|
|
@@ -704,6 +707,32 @@ export class NeedleEngineWebComponent extends HTMLElementBase implements INeedle
|
|
|
704
707
|
loadedFiles: loadedFiles,
|
|
705
708
|
}
|
|
706
709
|
}));
|
|
710
|
+
|
|
711
|
+
// Telemetry: report the load outcome. On success we wait for the first rendered frame so we
|
|
712
|
+
// can include time-to-first-frame (and non-identifying device capabilities, collected there).
|
|
713
|
+
const loadDurationMs = performance.now() - loadStartTime;
|
|
714
|
+
if (success) {
|
|
715
|
+
this.addEventListener("ready", () => {
|
|
716
|
+
// ignore if a newer load superseded this one before its first frame arrived
|
|
717
|
+
if (this._loadId !== loadId) return;
|
|
718
|
+
Telemetry.reportLoad(this._context, {
|
|
719
|
+
success: true,
|
|
720
|
+
files: loadedFiles.length,
|
|
721
|
+
is_reload: isReload,
|
|
722
|
+
load_ms: loadDurationMs,
|
|
723
|
+
first_frame_ms: performance.now() - loadStartTime,
|
|
724
|
+
since_navigation_ms: performance.now(),
|
|
725
|
+
});
|
|
726
|
+
}, { once: true });
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
Telemetry.reportLoad(this._context, {
|
|
730
|
+
success: false,
|
|
731
|
+
files: loadedFiles.length,
|
|
732
|
+
is_reload: isReload,
|
|
733
|
+
load_ms: loadDurationMs,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
707
736
|
}
|
|
708
737
|
|
|
709
738
|
// #region applyAttributes
|
|
@@ -23,6 +23,28 @@ import type { IXRRig } from "./XRRig.js";
|
|
|
23
23
|
const measure_SessionStartedMarker = "NeedleXRSession onStart";
|
|
24
24
|
const measure_SessionEndedMarker = "NeedleXRSession onEnd";
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Whether the `xr-spatial-tracking` permission policy is allowed in the current document.
|
|
28
|
+
* WebXR APIs (isSessionSupported / requestSession) require it; an embedded page/iframe must
|
|
29
|
+
* be granted it via the iframe `allow` attribute (delegated from a parent that has it) and/or
|
|
30
|
+
* a `Permissions-Policy` response header. Returns true when the policy API is unavailable
|
|
31
|
+
* (can't determine → don't block, preserve prior behavior).
|
|
32
|
+
* @link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#allow
|
|
33
|
+
*/
|
|
34
|
+
function isXrSpatialTrackingAllowed(): boolean {
|
|
35
|
+
try {
|
|
36
|
+
const doc = globalThis.document as (Document & { permissionsPolicy?: { allowsFeature(f: string): boolean }, featurePolicy?: { allowsFeature(f: string): boolean } }) | undefined;
|
|
37
|
+
const policy = doc?.permissionsPolicy ?? doc?.featurePolicy;
|
|
38
|
+
if (policy && typeof policy.allowsFeature === "function") {
|
|
39
|
+
return policy.allowsFeature("xr-spatial-tracking");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* ignore — fall through to default */
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
|
|
27
49
|
/** @link https://developer.mozilla.org/en-US/docs/Web/API/XRFrame/fillPoses */
|
|
28
50
|
declare type FillPosesFunction = (spaces: IterableIterator<XRJointSpace>, referenceSpace: XRSpace, targetArray: Float32Array) => void;
|
|
@@ -85,6 +107,10 @@ async function handleSessionGranted() {
|
|
|
85
107
|
let defaultMode: XRSessionMode = "immersive-vr";
|
|
86
108
|
|
|
87
109
|
try {
|
|
110
|
+
// If xr-spatial-tracking isn't allowed in this document (e.g. an embed whose page/iframe
|
|
111
|
+
// wasn't granted it), XR can't run here — skip the probe so we don't log a
|
|
112
|
+
// Permissions-Policy violation on load.
|
|
113
|
+
if (!isXrSpatialTrackingAllowed()) return;
|
|
88
114
|
// In app clips we default to AR
|
|
89
115
|
if (DeviceUtilities.isNeedleAppClip()) {
|
|
90
116
|
defaultMode = "immersive-ar";
|
|
@@ -304,7 +330,9 @@ export class NeedleXRSession implements INeedleXRSession {
|
|
|
304
330
|
*
|
|
305
331
|
* **IMPORTANT**: the resulting URL must exactly match an App Clip experience URL you have registered (with custom card metadata).
|
|
306
332
|
* For this reason the URL is opened as-is, without appending any query parameters.
|
|
307
|
-
*
|
|
333
|
+
*
|
|
334
|
+
* Note: custom App Clip branding requires an active PRO subscription. See the iOS WebXR App Clip docs (Custom Branding for iOS AR).
|
|
335
|
+
* @link https://engine.needle.tools/docs/how-to-guides/xr/ios-webxr-app-clip#custom-branding-for-ios-ar
|
|
308
336
|
*/
|
|
309
337
|
static get appClipUrl(): string | null { return this._appClipUrl; }
|
|
310
338
|
static set appClipUrl(value: string | null) {
|
|
@@ -367,19 +395,49 @@ export class NeedleXRSession implements INeedleXRSession {
|
|
|
367
395
|
* @param mode The XRSessionMode to check if it is supported
|
|
368
396
|
* @returns true if the browser supports the given XRSessionMode
|
|
369
397
|
*/
|
|
370
|
-
static isSessionSupported(mode: XRSessionMode) {
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
398
|
+
static isSessionSupported(mode: XRSessionMode): Promise<boolean> {
|
|
399
|
+
// Re-probe when XR devices change (e.g. a headset connects).
|
|
400
|
+
this.ensureDeviceChangeInvalidation();
|
|
401
|
+
|
|
402
|
+
// Cache AND de-duplicate the check by storing the in-flight PROMISE (not just the
|
|
403
|
+
// resolved value). A burst of callers in the same tick (XR buttons, offer-session,
|
|
404
|
+
// support checks) then shares ONE underlying navigator.xr.isSessionSupported() call
|
|
405
|
+
// per mode. Each call otherwise repeats the `xr-spatial-tracking` Permissions-Policy
|
|
406
|
+
// check and floods the console. It also keeps user-input onclick checks fast.
|
|
407
|
+
const cached = this._sessionSupportedCache[mode];
|
|
408
|
+
if (cached !== undefined) return cached;
|
|
409
|
+
|
|
410
|
+
let result: Promise<boolean>;
|
|
411
|
+
// isSessionSupported() requires the `xr-spatial-tracking` permission policy to be
|
|
412
|
+
// allowed in this document. In an embed whose page/iframe wasn't granted it, calling
|
|
413
|
+
// the API logs a Permissions-Policy violation and can't succeed — so skip the call
|
|
414
|
+
// and report "unsupported" instead of spamming the console.
|
|
415
|
+
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#allow
|
|
416
|
+
if (!isXrSpatialTrackingAllowed()) {
|
|
417
|
+
if (debug) console.warn("[WebXR] xr-spatial-tracking is not allowed in this document — reporting XR as unsupported. The embedding page/iframe must grant the policy.");
|
|
418
|
+
result = Promise.resolve(false);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
result = (this.xrSystem?.isSessionSupported(mode) ?? Promise.resolve(false))
|
|
422
|
+
.catch(err => { if (debug) console.error(err); return false; });
|
|
423
|
+
}
|
|
424
|
+
this._sessionSupportedCache[mode] = result;
|
|
425
|
+
return result;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private static _sessionSupportedCache: { [mode in XRSessionMode]?: Promise<boolean> } = {};
|
|
429
|
+
private static _deviceChangeHooked = false;
|
|
430
|
+
/** Invalidate the support cache when the set of XR devices changes, so a newly
|
|
431
|
+
* connected headset is picked up by the next isSessionSupported() call. */
|
|
432
|
+
private static ensureDeviceChangeInvalidation() {
|
|
433
|
+
if (this._deviceChangeHooked) return;
|
|
434
|
+
this._deviceChangeHooked = true;
|
|
435
|
+
try {
|
|
436
|
+
this.xrSystem?.addEventListener?.("devicechange", () => { this._sessionSupportedCache = {}; });
|
|
437
|
+
}
|
|
438
|
+
catch { /* xrSystem may not support events; cache simply won't be invalidated */ }
|
|
379
439
|
}
|
|
380
440
|
|
|
381
|
-
private static _sessionSupportedCache: { [mode in XRSessionMode]?: boolean } = {};
|
|
382
|
-
|
|
383
441
|
private static _currentSessionRequest?: Promise<XRSession>;
|
|
384
442
|
private static _activeSession: NeedleXRSession | null;
|
|
385
443
|
|
|
@@ -650,7 +708,7 @@ export class NeedleXRSession implements INeedleXRSession {
|
|
|
650
708
|
// Setup VR initialization parameters
|
|
651
709
|
case "immersive-ar":
|
|
652
710
|
{
|
|
653
|
-
const supported = await
|
|
711
|
+
const supported = await NeedleXRSession.isSessionSupported('immersive-ar')
|
|
654
712
|
if (supported !== true) {
|
|
655
713
|
console.error(mode + ' is not supported by this browser.');
|
|
656
714
|
return null;
|
|
@@ -671,7 +729,7 @@ export class NeedleXRSession implements INeedleXRSession {
|
|
|
671
729
|
// Setup AR initialization parameters
|
|
672
730
|
case "immersive-vr":
|
|
673
731
|
{
|
|
674
|
-
const supported = await
|
|
732
|
+
const supported = await NeedleXRSession.isSessionSupported('immersive-vr')
|
|
675
733
|
if (supported !== true) {
|
|
676
734
|
console.error(mode + ' is not supported by this browser.');
|
|
677
735
|
return null;
|
|
@@ -53,6 +53,19 @@ class XRUserState {
|
|
|
53
53
|
if (!found) {
|
|
54
54
|
this.controllerStates.push(state);
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
// When switching input type (e.g. hand -> controller), the new input source gets a new index.
|
|
58
|
+
// The old controller's state lingers here (we only ever add/update by index), and a stale
|
|
59
|
+
// non-tracking entry for the same handedness would otherwise mask the new tracked one. Once a
|
|
60
|
+
// tracking state arrives for a handedness, drop any other (stale) states for that handedness.
|
|
61
|
+
if (state.isTracking) {
|
|
62
|
+
for (let i = this.controllerStates.length - 1; i >= 0; i--) {
|
|
63
|
+
const other = this.controllerStates[i];
|
|
64
|
+
if (other.index !== state.index && other.handedness === state.handedness) {
|
|
65
|
+
this.controllerStates.splice(i, 1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
update(session: NeedleXRSession) {
|
|
@@ -89,8 +102,11 @@ class XRUserState {
|
|
|
89
102
|
|
|
90
103
|
private sendControllerRemoved(state: XRControllerState) {
|
|
91
104
|
state.isTracking = false;
|
|
92
|
-
state
|
|
105
|
+
// broadcast the not-tracking state to currently connected peers so they hide the hand immediately
|
|
93
106
|
this.context.connection.send(this.userStateEvtName, state);
|
|
107
|
+
// and delete the persisted room state using the *correct* guid so newly joining peers don't
|
|
108
|
+
// replay a stale (still-tracking) controller state. Must use the real guid - clearing it first
|
|
109
|
+
// would leave the persisted entry untouched on the server.
|
|
94
110
|
this.context.connection.sendDeleteRemoteState(state.guid);
|
|
95
111
|
}
|
|
96
112
|
|
|
@@ -135,8 +151,10 @@ export class NeedleXRSync {
|
|
|
135
151
|
if (!userId) return undefined;
|
|
136
152
|
const user = this._states.get(userId);
|
|
137
153
|
if (!user) return undefined;
|
|
138
|
-
|
|
139
|
-
|
|
154
|
+
// Consider it tracked if ANY controller state for this handedness is tracking. After switching
|
|
155
|
+
// input type a stale non-tracking entry can still exist for a frame before it's pruned - this
|
|
156
|
+
// makes sure it doesn't mask the new tracked controller/hand.
|
|
157
|
+
return user.controllerStates.some(x => x.handedness === handedness && x.isTracking);
|
|
140
158
|
}
|
|
141
159
|
|
|
142
160
|
/** Is it hand tracking or a controller */
|
|
@@ -144,7 +162,9 @@ export class NeedleXRSync {
|
|
|
144
162
|
if (!userId) return undefined;
|
|
145
163
|
const user = this._states.get(userId);
|
|
146
164
|
if (!user) return undefined;
|
|
147
|
-
|
|
165
|
+
// prefer the currently tracking controller for this handedness over any stale entry
|
|
166
|
+
const ctrl = user.controllerStates.find(x => x.handedness === handedness && x.isTracking)
|
|
167
|
+
?? user.controllerStates.find(x => x.handedness === handedness);
|
|
148
168
|
return ctrl?.type || "unknown";
|
|
149
169
|
}
|
|
150
170
|
|
|
@@ -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 { Jkdun } 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 (Jkdun()) {
|
|
219
219
|
const htmlComponent = document.querySelector("needle-engine");
|
|
220
220
|
if (htmlComponent) {
|
|
221
221
|
const licenseLogo = htmlComponent.getAttribute("logo-src");
|
|
@@ -5,7 +5,7 @@ import { InstancingUtil } from "../engine/engine_instancing.js";
|
|
|
5
5
|
import { onUpdate } from '../engine/engine_lifecycle_api.js';
|
|
6
6
|
import { OwnershipModel, RoomEvents } from "../engine/engine_networking.js"
|
|
7
7
|
import { sendDestroyed } from '../engine/engine_networking_instantiate.js';
|
|
8
|
-
import { setWorldEuler } from '../engine/engine_three_utils.js';
|
|
8
|
+
import { getTempVector, setWorldEuler } from '../engine/engine_three_utils.js';
|
|
9
9
|
import * as utils from "../engine/engine_utils.js"
|
|
10
10
|
import { registerBinaryType } from '../engine-schemes/schemes.js';
|
|
11
11
|
import { SyncedTransformModel } from '../engine-schemes/synced-transform-model.js';
|
|
@@ -34,9 +34,11 @@ export function createTransformModel(guid: string, b: Behaviour, fast: boolean =
|
|
|
34
34
|
SyncedTransformModel.addFast(builder, fast);
|
|
35
35
|
const p = b.worldPosition;
|
|
36
36
|
const r = b.worldEuler;
|
|
37
|
-
const s = b.gameObject.
|
|
37
|
+
const s = b.gameObject.worldScale;
|
|
38
|
+
|
|
38
39
|
// console.log(p, r, s);
|
|
39
40
|
SyncedTransformModel.addTransform(builder, Transform.createTransform(builder, p.x, p.y, p.z, r.x, r.y, r.z, s.x, s.y, s.z));
|
|
41
|
+
SyncedTransformModel.addVersion(builder, 1);
|
|
40
42
|
const res = SyncedTransformModel.endSyncedTransformModel(builder);
|
|
41
43
|
// SyncedTransformModel.finishSyncedTransformModelBuffer(builder, res);
|
|
42
44
|
builder.finish(res, SyncedTransformIdentifier);
|
|
@@ -111,6 +113,9 @@ export class SyncedTransform extends Behaviour {
|
|
|
111
113
|
/** Whether to smoothly interpolate rotation changes when receiving updates */
|
|
112
114
|
public interpolateRotation: boolean = true;
|
|
113
115
|
|
|
116
|
+
/** Whether to smoothly interpolate scale changes when receiving updates */
|
|
117
|
+
public interpolateScale: boolean = true;
|
|
118
|
+
|
|
114
119
|
/** When true, sends updates at a higher frequency, useful for fast-moving objects */
|
|
115
120
|
public fastMode: boolean = false;
|
|
116
121
|
|
|
@@ -120,12 +125,14 @@ export class SyncedTransform extends Behaviour {
|
|
|
120
125
|
// private _state!: SyncedTransformModel;
|
|
121
126
|
private _model: OwnershipModel | null = null;
|
|
122
127
|
private _needsUpdate: boolean = true;
|
|
128
|
+
private _hadOwnershipBeforeDisable: boolean = false;
|
|
123
129
|
private rb: Rigidbody | null = null;
|
|
124
130
|
private _wasKinematic: boolean | undefined = false;
|
|
125
131
|
private _receivedDataBefore: boolean = false;
|
|
126
132
|
|
|
127
133
|
private _targetPosition!: Vector3;
|
|
128
134
|
private _targetRotation!: Quaternion;
|
|
135
|
+
private _targetScale!: Vector3;
|
|
129
136
|
|
|
130
137
|
private _receivedFastUpdate: boolean = false;
|
|
131
138
|
private _shouldRequestOwnership: boolean = false;
|
|
@@ -193,6 +200,7 @@ export class SyncedTransform extends Behaviour {
|
|
|
193
200
|
this._receivedDataBefore = false;
|
|
194
201
|
this._targetPosition = new Vector3();
|
|
195
202
|
this._targetRotation = new Quaternion();
|
|
203
|
+
this._targetScale = new Vector3();
|
|
196
204
|
|
|
197
205
|
// sync instantiate issue was because they shared the same last pos vector!
|
|
198
206
|
this.lastPosition = new Vector3();
|
|
@@ -249,6 +257,7 @@ export class SyncedTransform extends Behaviour {
|
|
|
249
257
|
this.receivedUpdate = true;
|
|
250
258
|
this._receivedFastUpdate = data.fast();
|
|
251
259
|
const transform = data.transform();
|
|
260
|
+
const dataVersion = data.version();
|
|
252
261
|
if (transform) {
|
|
253
262
|
InstancingUtil.markDirty(this.gameObject, true);
|
|
254
263
|
const position = transform.position();
|
|
@@ -271,7 +280,19 @@ export class SyncedTransform extends Behaviour {
|
|
|
271
280
|
|
|
272
281
|
const scale = transform.scale();
|
|
273
282
|
if (scale) {
|
|
274
|
-
|
|
283
|
+
// before dataVersion 1, local scale was sent; from version 1 on world scale is
|
|
284
|
+
// sent (see createTransformModel) and applied in world space so the receiver's
|
|
285
|
+
// local scale is derived relative to its own parent hierarchy
|
|
286
|
+
if (dataVersion === 0) {
|
|
287
|
+
// legacy: local scale, applied directly (no interpolation)
|
|
288
|
+
this.gameObject.scale.set(scale.x(), scale.y(), scale.z());
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
if (this.interpolateScale)
|
|
292
|
+
this._targetScale.set(scale.x(), scale.y(), scale.z());
|
|
293
|
+
if (!this.interpolateScale || !this._receivedDataBefore)
|
|
294
|
+
this.gameObject.worldScale = getTempVector(scale.x(), scale.y(), scale.z());
|
|
295
|
+
}
|
|
275
296
|
}
|
|
276
297
|
}
|
|
277
298
|
this._receivedDataBefore = true;
|
|
@@ -284,26 +305,37 @@ export class SyncedTransform extends Behaviour {
|
|
|
284
305
|
|
|
285
306
|
/**
|
|
286
307
|
* @internal
|
|
287
|
-
* Initializes tracking of position and
|
|
308
|
+
* Initializes tracking of position,rotation and scale when component is enabled
|
|
288
309
|
*/
|
|
289
310
|
onEnable(): void {
|
|
290
311
|
this.lastPosition.copy(this.worldPosition);
|
|
291
312
|
this.lastRotation.copy(this.worldQuaternion);
|
|
292
|
-
this.lastScale.copy(this.gameObject.
|
|
313
|
+
this.lastScale.copy(this.gameObject.worldScale);
|
|
293
314
|
this._needsUpdate = true;
|
|
294
315
|
// console.log("ENABLE", this.guid, this.gameObject.guid, this.lastWorldPos);
|
|
295
316
|
if (this._model) {
|
|
296
317
|
this._model.updateIsOwned();
|
|
318
|
+
// If we owned this before being (transiently) disabled - e.g. the object or an ancestor such
|
|
319
|
+
// as an XRRig got deactivated - reclaim ownership so syncing resumes automatically. We use
|
|
320
|
+
// requestOwnershipIfNotOwned so we only reclaim while it is still free, never stealing it from
|
|
321
|
+
// someone who took it while we were inactive.
|
|
322
|
+
if (this._hadOwnershipBeforeDisable) {
|
|
323
|
+
this._hadOwnershipBeforeDisable = false;
|
|
324
|
+
this._model.requestOwnershipIfNotOwned();
|
|
325
|
+
}
|
|
297
326
|
}
|
|
298
327
|
}
|
|
299
328
|
|
|
300
|
-
/**
|
|
301
|
-
* @internal
|
|
329
|
+
/**
|
|
330
|
+
* @internal
|
|
302
331
|
* Releases ownership when component is disabled
|
|
303
332
|
*/
|
|
304
333
|
onDisable(): void {
|
|
305
|
-
if (this._model)
|
|
334
|
+
if (this._model) {
|
|
335
|
+
// remember whether we were the owner so we can reclaim it on re-enable (see onEnable)
|
|
336
|
+
this._hadOwnershipBeforeDisable = this._model.hasOwnership === true;
|
|
306
337
|
this._model.freeOwnership();
|
|
338
|
+
}
|
|
307
339
|
}
|
|
308
340
|
|
|
309
341
|
|
|
@@ -334,7 +366,7 @@ export class SyncedTransform extends Behaviour {
|
|
|
334
366
|
|
|
335
367
|
const pos = this.worldPosition;
|
|
336
368
|
const rot = this.worldQuaternion;
|
|
337
|
-
const scale = this.gameObject.
|
|
369
|
+
const scale = this.gameObject.worldScale;
|
|
338
370
|
if (this._model.isOwned && !this.receivedUpdate) {
|
|
339
371
|
const threshold = this._model.hasOwnership || this.fastMode ? .0001 : .001;
|
|
340
372
|
if (pos.distanceTo(this.lastPosition) > threshold ||
|
|
@@ -352,7 +384,8 @@ export class SyncedTransform extends Behaviour {
|
|
|
352
384
|
this.worldQuaternion = this.lastRotation;
|
|
353
385
|
rot.copy(this.lastRotation);
|
|
354
386
|
|
|
355
|
-
this.gameObject.
|
|
387
|
+
this.gameObject.worldScale = this.lastScale;
|
|
388
|
+
scale.copy(this.lastScale);
|
|
356
389
|
|
|
357
390
|
InstancingUtil.markDirty(this.gameObject, true);
|
|
358
391
|
this._needsUpdate = false;
|
|
@@ -386,6 +419,12 @@ export class SyncedTransform extends Behaviour {
|
|
|
386
419
|
this.worldQuaternion = rot;
|
|
387
420
|
requireMarkDirty = true;
|
|
388
421
|
}
|
|
422
|
+
if (this.interpolateScale && this._targetScale) {
|
|
423
|
+
const scale = this.gameObject.worldScale;
|
|
424
|
+
scale.lerp(this._targetScale, t);
|
|
425
|
+
this.gameObject.worldScale = scale;
|
|
426
|
+
requireMarkDirty = true;
|
|
427
|
+
}
|
|
389
428
|
if (requireMarkDirty)
|
|
390
429
|
InstancingUtil.markDirty(this.gameObject, true);
|
|
391
430
|
}
|
|
@@ -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 { _hyjgv } 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 (!_hyjgv()) {
|
|
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 (!_hyjgv()) {
|
|
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 (!_hyjgv()) {
|
|
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";
|