@needle-tools/engine 5.1.1 → 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 (68) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/{needle-engine.bundle-Dv-p5m4z.js → needle-engine.bundle-BAUfRyBA.js} +2861 -2708
  3. package/dist/{needle-engine.bundle-Cb39lTJn.umd.cjs → needle-engine.bundle-BL2wG5Te.umd.cjs} +101 -101
  4. package/dist/{needle-engine.bundle-BUT4ua7U.min.js → needle-engine.bundle-BpNRkY3s.min.js} +148 -148
  5. package/dist/needle-engine.d.ts +18 -6
  6. package/dist/needle-engine.js +401 -401
  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_utils_qrcode.js +2 -2
  22. package/lib/engine/engine_utils_qrcode.js.map +1 -1
  23. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  24. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
  25. package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
  26. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  27. package/lib/engine/webcomponents/needle-engine.js +31 -2
  28. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  29. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  30. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  31. package/lib/engine/xr/NeedleXRSync.js +23 -4
  32. package/lib/engine/xr/NeedleXRSync.js.map +1 -1
  33. package/lib/engine/xr/TempXRContext.js +2 -2
  34. package/lib/engine/xr/TempXRContext.js.map +1 -1
  35. package/lib/engine-components/SyncedTransform.d.ts +5 -1
  36. package/lib/engine-components/SyncedTransform.js +45 -8
  37. package/lib/engine-components/SyncedTransform.js.map +1 -1
  38. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  39. package/lib/engine-components/export/usdz/USDZExporter.js.map +1 -1
  40. package/lib/engine-components/webxr/Avatar.d.ts +8 -0
  41. package/lib/engine-components/webxr/Avatar.js +60 -32
  42. package/lib/engine-components/webxr/Avatar.js.map +1 -1
  43. package/lib/engine-schemes/synced-transform-model.d.ts +2 -0
  44. package/lib/engine-schemes/synced-transform-model.js +8 -1
  45. package/lib/engine-schemes/synced-transform-model.js.map +1 -1
  46. package/package.json +1 -1
  47. package/plugins/common/license.js +4 -4
  48. package/plugins/types/userconfig.d.ts +4 -3
  49. package/plugins/vite/build-pipeline.d.ts +3 -1
  50. package/plugins/vite/build-pipeline.js +19 -9
  51. package/plugins/vite/license.js +4 -4
  52. package/src/engine/api.ts +1 -1
  53. package/src/engine/engine_init.ts +2 -2
  54. package/src/engine/engine_license.ts +263 -73
  55. package/src/engine/engine_networking.ts +5 -0
  56. package/src/engine/engine_networking_blob.ts +3 -3
  57. package/src/engine/engine_utils_qrcode.ts +2 -2
  58. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  59. package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
  60. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  61. package/src/engine/webcomponents/needle-engine.ts +31 -2
  62. package/src/engine/xr/NeedleXRSync.ts +24 -4
  63. package/src/engine/xr/TempXRContext.ts +2 -2
  64. package/src/engine-components/SyncedTransform.ts +49 -10
  65. package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
  66. package/src/engine-components/webxr/Avatar.ts +60 -33
  67. package/src/engine-schemes/synced-transform-model.ts +10 -1
  68. 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 { $Mhcqc } 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 = $Mhcqc();
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)
@@ -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 { $Mhcqc } 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 && $Mhcqc()) {
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 { $Mhcqc, _ynyIW, 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(() => _ynyIW(res => {
782
- if (res == true && $Mhcqc() && !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 (!$Mhcqc()) {
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 (!$Mhcqc() || 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 { $Mhcqc, __dSkNu, __gpBcwS } 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 = __dSkNu();
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 = $Mhcqc();
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() && __gpBcwS) {
396
+ // if (!isDevEnvironment() && xWfUNf) {
397
397
  // if (debugLicense) console.log("Waiting for runtime license check");
398
- // await __gpBcwS;
399
- // commercialLicense = $Mhcqc();
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 { $Mhcqc } 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 = $Mhcqc();
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
@@ -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 { $Mhcqc } 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 ($Mhcqc()) {
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 { __dSkNu } 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 (!__dSkNu()) {
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 (!__dSkNu()) {
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 (!__dSkNu()) {
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";
@@ -42,6 +42,10 @@ export class Avatar extends Behaviour {
42
42
  private _leftHandMeshes?: Mesh[];
43
43
  private _rightHandMeshes?: Mesh[];
44
44
 
45
+ // meshes of remote avatar hands - hidden render-only so the hand root stays active and keeps syncing
46
+ private _remoteLeftHandMeshes?: Mesh[];
47
+ private _remoteRightHandMeshes?: Mesh[];
48
+
45
49
  private _syncTransforms?: SyncedTransform[];
46
50
 
47
51
  async onEnterXR(_args: NeedleXREventArgs) {
@@ -120,46 +124,54 @@ export class Avatar extends Behaviour {
120
124
  }
121
125
 
122
126
  // synchronize hands
127
+ // IMPORTANT: never toggle `.visible` on the hand object itself - it carries the SyncedTransform,
128
+ // and setting it invisible deactivates the component (onDisable -> freeOwnership), which means the
129
+ // owner loses ownership and stops sending (the remote hand then stays frozen). The head syncs fine
130
+ // precisely because its synced root is never deactivated (only a child mesh is hidden). So we keep
131
+ // the hand object active and hide only the rendered meshes (render-only, via setCustomVisibility).
123
132
  const leftCtrl = args.xr.leftController;
124
133
  const leftObj = this.leftHand?.asset as Object3D;
125
- if (leftCtrl && leftObj) {
126
- leftObj.position.copy(leftCtrl.gripPosition);
127
- leftObj.quaternion.copy(leftCtrl.gripQuaternion);
128
- leftObj.quaternion.multiply(flipForwardQuaternion);
129
- leftObj.visible = leftCtrl.isTracking;
134
+ if (leftObj) {
135
+ if (leftCtrl) {
136
+ leftObj.position.copy(leftCtrl.gripPosition);
137
+ leftObj.quaternion.copy(leftCtrl.gripQuaternion);
138
+ leftObj.quaternion.multiply(flipForwardQuaternion);
139
+ }
140
+ leftObj.visible = true;
130
141
  this.updateHandVisibility(leftCtrl, leftObj, this._leftHandMeshes);
131
142
  }
132
- else if (leftObj && leftObj.visible) {
133
- leftObj.visible = false;
134
- }
135
143
 
136
144
  const right = args.xr.rightController;
137
145
  const rightObj = this.rightHand?.asset as Object3D;
138
- if (right && rightObj) {
139
- rightObj.position.copy(right.gripPosition);
140
- rightObj.quaternion.copy(right.gripQuaternion);
141
- rightObj.quaternion.multiply(flipForwardQuaternion);
142
- rightObj.visible = right.isTracking;
146
+ if (rightObj) {
147
+ if (right) {
148
+ rightObj.position.copy(right.gripPosition);
149
+ rightObj.quaternion.copy(right.gripQuaternion);
150
+ rightObj.quaternion.multiply(flipForwardQuaternion);
151
+ }
152
+ rightObj.visible = true;
143
153
  this.updateHandVisibility(right, rightObj, this._rightHandMeshes);
144
154
  }
145
- else if (rightObj && rightObj.visible) {
146
- rightObj.visible = false;
147
- }
148
155
  }
149
156
 
150
157
  onBeforeRender(): void {
151
- if (this.context.xr) {
152
- if (this.context.time.frame % 10 === 0)
153
- this.updateRemoteAvatarVisibility();
154
- }
158
+ // Remote avatar visibility depends on the *owner's* tracking state, not on whether THIS client is
159
+ // in XR: other users' avatars are syncInstantiated onto non-XR clients too (spectators / lobby),
160
+ // and must hide/show their hands correctly there as well. So this runs regardless of the local XR
161
+ // state. updateRemoteAvatarVisibility() early-outs for the local player and when not connected.
162
+ if (this.context.time.frame % 10 === 0)
163
+ this.updateRemoteAvatarVisibility();
155
164
  }
156
165
 
157
- private updateHandVisibility(controller: NeedleXRController, avatarHand: Object3D, meshes: Mesh[] | undefined) {
166
+ private updateHandVisibility(controller: NeedleXRController | null | undefined, avatarHand: Object3D, meshes: Mesh[] | undefined) {
158
167
  if (meshes) {
159
- // Hide the hand meshes for the local user if another model (e.g. the controller model) is being rendered
160
- // We don't set the visible flag here because it would also disable SyncedTransforms networking
161
- const hasOtherRenderingModel = controller.model && controller.model.visible && controller.model !== avatarHand;
162
- meshes.forEach(mesh => { setCustomVisibility(mesh, !hasOtherRenderingModel); });
168
+ // Hide the hand meshes (render-only) when the hand is not tracked, or when another model
169
+ // (e.g. the controller/hand model) is being rendered in its place. We hide the meshes rather
170
+ // than the object's `visible` flag because that would disable SyncedTransform networking.
171
+ const isTracking = controller?.isTracking === true;
172
+ const hasOtherRenderingModel = !!controller?.model && controller.model.visible && controller.model !== avatarHand;
173
+ const render = isTracking && !hasOtherRenderingModel;
174
+ meshes.forEach(mesh => { setCustomVisibility(mesh, render); });
163
175
  }
164
176
  }
165
177
 
@@ -173,14 +185,13 @@ export class Avatar extends Behaviour {
173
185
  if (sync.hasState(state.owner)) {
174
186
  this.tryFindAvatarObjectsIfMissing();
175
187
 
176
- const leftObj = this.leftHand?.asset as Object3D;
177
- if (leftObj) {
178
- leftObj.visible = sync?.isTracking(state.owner, "left") ?? false;
179
- }
180
- const rightObj = this.rightHand?.asset as Object3D;
181
- if (rightObj) {
182
- rightObj.visible = sync?.isTracking(state.owner, "right") ?? false;
183
- }
188
+ // Keep the hand object active (don't toggle `.visible`) so its SyncedTransform keeps
189
+ // applying updates and a non-owner freeOwnership can't strip the owner's ownership.
190
+ // Hide/show the rendered meshes based on the owner's tracking state instead.
191
+ const leftRendered = sync.isTracking(state.owner, "left") ?? false;
192
+ this._remoteLeftHandMeshes = this.setRemoteHandRendered(this.leftHand?.asset as Object3D, this._remoteLeftHandMeshes, leftRendered);
193
+ const rightRendered = sync.isTracking(state.owner, "right") ?? false;
194
+ this._remoteRightHandMeshes = this.setRemoteHandRendered(this.rightHand?.asset as Object3D, this._remoteRightHandMeshes, rightRendered);
184
195
  }
185
196
  }
186
197
 
@@ -198,6 +209,22 @@ export class Avatar extends Behaviour {
198
209
 
199
210
 
200
211
 
212
+ /**
213
+ * Show/hide a remote avatar hand render-only. The hand object stays active so its SyncedTransform keeps
214
+ * applying networked updates (toggling `.visible` would deactivate it and could free the owner's ownership).
215
+ * The mesh list is gathered lazily and cached, and returned so the caller can keep it.
216
+ */
217
+ private setRemoteHandRendered(obj: Object3D | undefined, meshes: Mesh[] | undefined, rendered: boolean): Mesh[] | undefined {
218
+ if (!obj) return meshes;
219
+ obj.visible = true;
220
+ if (!meshes || meshes.length === 0) {
221
+ meshes = [];
222
+ obj.traverse(o => { if ((o as Mesh).isMesh) meshes!.push(o as Mesh); });
223
+ }
224
+ for (const mesh of meshes) setCustomVisibility(mesh, rendered);
225
+ return meshes;
226
+ }
227
+
201
228
  private tryFindAvatarObjectsIfMissing() {
202
229
  // if no avatar objects are set, try to find them
203
230
  if (!this.head || !this.leftHand || !this.rightHand) {
@@ -54,8 +54,13 @@ dontSave():boolean {
54
54
  return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
55
55
  }
56
56
 
57
+ version():number {
58
+ const offset = this.bb!.__offset(this.bb_pos, 12);
59
+ return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
60
+ }
61
+
57
62
  static startSyncedTransformModel(builder:flatbuffers.Builder) {
58
- builder.startObject(4);
63
+ builder.startObject(5);
59
64
  }
60
65
 
61
66
  static addGuid(builder:flatbuffers.Builder, guidOffset:flatbuffers.Offset) {
@@ -74,6 +79,10 @@ static addDontSave(builder:flatbuffers.Builder, dontSave:boolean) {
74
79
  builder.addFieldInt8(3, +dontSave, +false);
75
80
  }
76
81
 
82
+ static addVersion(builder:flatbuffers.Builder, version:number) {
83
+ builder.addFieldInt32(4, version, 0);
84
+ }
85
+
77
86
  static endSyncedTransformModel(builder:flatbuffers.Builder):flatbuffers.Offset {
78
87
  const offset = builder.endObject();
79
88
  return offset;
@@ -20,6 +20,7 @@ table SyncedTransformModel {
20
20
  transform: Transform;
21
21
  /// if the server should not save this info
22
22
  dont_save:bool;
23
+ version : int;
23
24
  }
24
25
 
25
26
  // file_identifier "STRS";