@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/dist/{needle-engine.bundle-5oIL49YP.js → needle-engine.bundle-BAUfRyBA.js} +7692 -7506
  3. package/dist/{needle-engine.bundle-DvOrfXws.umd.cjs → needle-engine.bundle-BL2wG5Te.umd.cjs} +159 -159
  4. package/dist/{needle-engine.bundle-DAAfj8Cs.min.js → needle-engine.bundle-BpNRkY3s.min.js} +184 -184
  5. package/dist/needle-engine.d.ts +25 -7
  6. package/dist/needle-engine.js +533 -533
  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 +45 -7
  15. package/lib/engine/engine_license.js +276 -77
  16. package/lib/engine/engine_license.js.map +1 -1
  17. package/lib/engine/engine_networking.js +6 -0
  18. package/lib/engine/engine_networking.js.map +1 -1
  19. package/lib/engine/engine_networking_blob.js +3 -3
  20. package/lib/engine/engine_networking_blob.js.map +1 -1
  21. package/lib/engine/engine_networking_peer.js +19 -13
  22. package/lib/engine/engine_networking_peer.js.map +1 -1
  23. package/lib/engine/engine_utils_qrcode.js +2 -2
  24. package/lib/engine/engine_utils_qrcode.js.map +1 -1
  25. package/lib/engine/webcomponents/WebXRButtons.js +5 -6
  26. package/lib/engine/webcomponents/WebXRButtons.js.map +1 -1
  27. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  28. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
  29. package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
  30. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  31. package/lib/engine/webcomponents/needle-engine.js +31 -2
  32. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  33. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  34. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  35. package/lib/engine/xr/NeedleXRSession.d.ts +7 -1
  36. package/lib/engine/xr/NeedleXRSession.js +70 -11
  37. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  38. package/lib/engine/xr/NeedleXRSync.js +23 -4
  39. package/lib/engine/xr/NeedleXRSync.js.map +1 -1
  40. package/lib/engine/xr/TempXRContext.js +2 -2
  41. package/lib/engine/xr/TempXRContext.js.map +1 -1
  42. package/lib/engine-components/SyncedTransform.d.ts +5 -1
  43. package/lib/engine-components/SyncedTransform.js +45 -8
  44. package/lib/engine-components/SyncedTransform.js.map +1 -1
  45. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  46. package/lib/engine-components/webxr/Avatar.d.ts +8 -0
  47. package/lib/engine-components/webxr/Avatar.js +60 -32
  48. package/lib/engine-components/webxr/Avatar.js.map +1 -1
  49. package/lib/engine-components/webxr/WebXR.js +25 -14
  50. package/lib/engine-components/webxr/WebXR.js.map +1 -1
  51. package/lib/engine-schemes/synced-transform-model.d.ts +2 -0
  52. package/lib/engine-schemes/synced-transform-model.js +8 -1
  53. package/lib/engine-schemes/synced-transform-model.js.map +1 -1
  54. package/package.json +2 -2
  55. package/plugins/common/license.js +4 -4
  56. package/plugins/types/userconfig.d.ts +4 -3
  57. package/plugins/vite/build-pipeline.d.ts +3 -1
  58. package/plugins/vite/build-pipeline.js +19 -9
  59. package/plugins/vite/license.js +4 -4
  60. package/src/engine/api.ts +1 -1
  61. package/src/engine/engine_init.ts +2 -2
  62. package/src/engine/engine_license.ts +263 -73
  63. package/src/engine/engine_networking.ts +5 -0
  64. package/src/engine/engine_networking_blob.ts +3 -3
  65. package/src/engine/engine_networking_peer.ts +12 -13
  66. package/src/engine/engine_utils_qrcode.ts +2 -2
  67. package/src/engine/webcomponents/WebXRButtons.ts +5 -6
  68. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  69. package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
  70. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  71. package/src/engine/webcomponents/needle-engine.ts +31 -2
  72. package/src/engine/xr/NeedleXRSession.ts +72 -14
  73. package/src/engine/xr/NeedleXRSync.ts +24 -4
  74. package/src/engine/xr/TempXRContext.ts +2 -2
  75. package/src/engine-components/SyncedTransform.ts +49 -10
  76. package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
  77. package/src/engine-components/webxr/Avatar.ts +60 -33
  78. package/src/engine-components/webxr/WebXR.ts +23 -15
  79. package/src/engine-schemes/synced-transform-model.ts +10 -1
  80. 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 { $iujBhf } from "./engine_license.js";
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 = $iujBhf();
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
- if (navigator.xr?.isSessionSupported("immersive-vr")) {
221
- button.style.display = "none";
222
- }
223
- else {
224
- button.style.display = "";
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 { $iujBhf } from "../../engine_license.js";
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 && $iujBhf()) {
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 { $iujBhf, _$GTZ, Telemetry } from "../../engine_license.js";
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(() => _$GTZ(res => {
782
- if (res == true && $iujBhf() && !debugNonCommercial) {
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 (!$iujBhf()) {
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 (!$iujBhf() || debugNonCommercial) {
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 { $iujBhf, rXELCf, $rKLNDMS } from "../engine_license.js";
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 = rXELCf();
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 = $iujBhf();
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() && $rKLNDMS) {
396
+ // if (!isDevEnvironment() && xWfUNf) {
397
397
  // if (debugLicense) console.log("Waiting for runtime license check");
398
- // await $rKLNDMS;
399
- // commercialLicense = $iujBhf();
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 { $iujBhf } from "../engine_license.js";
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 = $iujBhf();
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
- * @link https://appclip.needle.tools
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
- // We cache the result of the session support check to avoid unnecessary calls to isSessionSupported. This is especially important for user input events that internally run session support checks during onclick where the event must be resolved as fast as possible.
372
- if (this._sessionSupportedCache[mode] !== undefined) {
373
- return Promise.resolve(this._sessionSupportedCache[mode]);
374
- }
375
- return this.xrSystem?.isSessionSupported(mode).then(supported => {
376
- this._sessionSupportedCache[mode] = supported;
377
- return supported;
378
- }).catch(err => { if (debug) console.error(err); return false }) ?? Promise.resolve(false);
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 this.xrSystem?.isSessionSupported('immersive-ar')
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 this.xrSystem?.isSessionSupported('immersive-vr')
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.guid = "";
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
- const ctrl = user.controllerStates.find(x => x.handedness === handedness);
139
- return ctrl?.isTracking || false;
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
- const ctrl = user.controllerStates.find(x => x.handedness === handedness);
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 { $iujBhf } from "../engine_license.js";
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 ($iujBhf()) {
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.scale; // todo: world scale
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
- this.gameObject.scale.set(scale.x(), scale.y(), scale.z());
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 rotation when component is enabled
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.scale);
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.scale;
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.scale.copy(this.lastScale);
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 { rXELCf } from "../../../engine/engine_license.js";
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 (!rXELCf()) {
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 (!rXELCf()) {
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 (!rXELCf()) {
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";