@needle-tools/engine 4.12.0-next.fb75c78 → 4.12.1

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 (71) hide show
  1. package/CHANGELOG.md +11 -5
  2. package/README.md +4 -2
  3. package/components.needle.json +1 -1
  4. package/dist/{gltf-progressive-Rs-ojtXy.umd.cjs → gltf-progressive-Bfpfaz84.umd.cjs} +1 -1
  5. package/dist/{gltf-progressive-DnLBuGK5.js → gltf-progressive-DPunMlEM.js} +1 -1
  6. package/dist/{gltf-progressive-BmSygnAC.min.js → gltf-progressive-hFPACYio.min.js} +1 -1
  7. package/dist/{needle-engine.bundle-CJSpoHVo.min.js → needle-engine.bundle-BPNuWGjI.min.js} +146 -145
  8. package/dist/{needle-engine.bundle-Cb3SBLtg.umd.cjs → needle-engine.bundle-BV9DUnnS.umd.cjs} +143 -142
  9. package/dist/{needle-engine.bundle-C5pBHUhB.js → needle-engine.bundle-C4PyE3dQ.js} +7984 -7801
  10. package/dist/needle-engine.d.ts +15 -8
  11. package/dist/needle-engine.js +3 -3
  12. package/dist/needle-engine.min.js +1 -1
  13. package/dist/needle-engine.umd.cjs +1 -1
  14. package/dist/{postprocessing-DZtb9Nnn.umd.cjs → postprocessing-BHQvwehB.umd.cjs} +1 -1
  15. package/dist/{postprocessing-B5ksn9-G.min.js → postprocessing-ClLv0reO.min.js} +1 -1
  16. package/dist/{postprocessing-__7s9wON.js → postprocessing-DLI2N3LL.js} +1 -1
  17. package/dist/{three-examples-y2GeYlze.js → three-examples-D4rE49Ui.js} +10 -2
  18. package/dist/{three-examples-MsJjauyk.min.js → three-examples-DB5Uoja4.min.js} +2 -2
  19. package/dist/{three-examples-Dho7cuu4.umd.cjs → three-examples-Djbk6WA4.umd.cjs} +2 -2
  20. package/lib/engine/engine_context.js +2 -0
  21. package/lib/engine/engine_context.js.map +1 -1
  22. package/lib/engine/engine_license.d.ts +18 -0
  23. package/lib/engine/engine_license.js +161 -9
  24. package/lib/engine/engine_license.js.map +1 -1
  25. package/lib/engine/engine_networking.js +15 -0
  26. package/lib/engine/engine_networking.js.map +1 -1
  27. package/lib/engine/engine_serialization_builtin_serializer.js +1 -1
  28. package/lib/engine/engine_serialization_builtin_serializer.js.map +1 -1
  29. package/lib/engine/engine_three_utils.js +2 -2
  30. package/lib/engine/engine_three_utils.js.map +1 -1
  31. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -1
  32. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
  33. package/lib/engine/webcomponents/needle menu/needle-menu.d.ts +2 -0
  34. package/lib/engine/webcomponents/needle menu/needle-menu.js +41 -2
  35. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  36. package/lib/engine/xr/NeedleXRSession.js +45 -7
  37. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  38. package/lib/engine-components/DragControls.js +1 -1
  39. package/lib/engine-components/DragControls.js.map +1 -1
  40. package/lib/engine-components/DropListener.d.ts +1 -0
  41. package/lib/engine-components/DropListener.js +26 -8
  42. package/lib/engine-components/DropListener.js.map +1 -1
  43. package/lib/engine-components/EventList.js +4 -1
  44. package/lib/engine-components/EventList.js.map +1 -1
  45. package/lib/engine-components/SceneSwitcher.d.ts +3 -2
  46. package/lib/engine-components/SceneSwitcher.js +24 -11
  47. package/lib/engine-components/SceneSwitcher.js.map +1 -1
  48. package/lib/engine-components/export/usdz/extensions/behavior/BehaviourComponents.js +8 -0
  49. package/lib/engine-components/export/usdz/extensions/behavior/BehaviourComponents.js.map +1 -1
  50. package/lib/engine-components/webxr/WebARSessionRoot.d.ts +5 -2
  51. package/lib/engine-components/webxr/WebARSessionRoot.js +5 -2
  52. package/lib/engine-components/webxr/WebARSessionRoot.js.map +1 -1
  53. package/lib/engine-components/webxr/WebXR.d.ts +3 -1
  54. package/lib/engine-components/webxr/WebXR.js +3 -1
  55. package/lib/engine-components/webxr/WebXR.js.map +1 -1
  56. package/package.json +3 -3
  57. package/src/engine/engine_context.ts +2 -0
  58. package/src/engine/engine_license.ts +178 -9
  59. package/src/engine/engine_networking.ts +15 -0
  60. package/src/engine/engine_serialization_builtin_serializer.ts +1 -1
  61. package/src/engine/engine_three_utils.ts +4 -2
  62. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -1
  63. package/src/engine/webcomponents/needle menu/needle-menu.ts +44 -3
  64. package/src/engine/xr/NeedleXRSession.ts +53 -11
  65. package/src/engine-components/DragControls.ts +1 -1
  66. package/src/engine-components/DropListener.ts +29 -8
  67. package/src/engine-components/EventList.ts +5 -1
  68. package/src/engine-components/SceneSwitcher.ts +26 -13
  69. package/src/engine-components/export/usdz/extensions/behavior/BehaviourComponents.ts +11 -0
  70. package/src/engine-components/webxr/WebARSessionRoot.ts +7 -3
  71. package/src/engine-components/webxr/WebXR.ts +4 -2
@@ -27,6 +27,7 @@ import { ObjectUtils } from "./engine_create_objects.js";
27
27
  import { destroy, foreachComponent } from './engine_gameobject.js';
28
28
  import { getLoader } from './engine_gltf.js';
29
29
  import { Input } from './engine_input.js';
30
+ import { Telemetry } from './engine_license.js';
30
31
  import { invokeLifecycleFunctions } from './engine_lifecycle_functions_internal.js';
31
32
  import { type ILightDataRegistry, LightDataRegistry } from './engine_lightdata.js';
32
33
  import { LODsManager } from "./engine_lods.js";
@@ -1403,6 +1404,7 @@ export class Context implements IContext {
1403
1404
  if (this._renderlooperrors >= 3) {
1404
1405
  console.warn("Stopping render loop due to error")
1405
1406
  this.renderer.setAnimationLoop(null);
1407
+ Telemetry.sendError(Context.Current, "renderloop", err instanceof Error ? err : new Error(String(err)) );
1406
1408
  }
1407
1409
  this.domElement.dispatchEvent(new CustomEvent("error", { detail: err }));
1408
1410
  }
@@ -1,9 +1,14 @@
1
+ import { dof } from "three/src/nodes/TSL.js";
2
+
1
3
  import { isDevEnvironment } from "./debug/index.js";
2
4
  import { BUILD_TIME, GENERATOR, PUBLIC_KEY, VERSION } from "./engine_constants.js";
3
5
  import { ContextEvent, ContextRegistry } from "./engine_context_registry.js";
6
+ import { onInitialized } from "./engine_lifecycle_api.js";
7
+ import { isLocalNetwork } from "./engine_networking_utils.js";
4
8
  import { Context } from "./engine_setup.js";
5
9
  import type { IContext } from "./engine_types.js";
6
10
  import { getParam } from "./engine_utils.js";
11
+ import { InternalAttributeUtils } from "./engine_utils_attributes.js";
7
12
 
8
13
  const debug = getParam("debuglicense");
9
14
 
@@ -67,6 +72,175 @@ function invokeLicenseCheckResultChanged(result: boolean) {
67
72
  }
68
73
  }
69
74
 
75
+
76
+
77
+ // #region Telemetry
78
+ export namespace Telemetry {
79
+
80
+ window.addEventListener("error", (event: ErrorEvent) => {
81
+ sendError(Context.Current, "unhandled_error", event);
82
+ });
83
+ window.addEventListener("unhandledrejection", (event: PromiseRejectionEvent) => {
84
+ sendError(Context.Current, "unhandled_promise_rejection", {
85
+ message: event.reason?.message,
86
+ stack: event.reason?.stack,
87
+ timestamp: Date.now(),
88
+ });
89
+ });
90
+
91
+ onInitialized((ctx => sendPageViewEvent(ctx)), { once: true });
92
+
93
+ function sendPageViewEvent(ctx: IContext): Promise<void> | void {
94
+ if (!isAllowed(ctx)) {
95
+ if (debug) console.debug("Telemetry is disabled via no-telemetry attribute");
96
+ return;
97
+ }
98
+ return doFetch({
99
+ site_id: "dabb8317376f",
100
+ type: "pageview",
101
+ pathname: window.location.pathname,
102
+ hostname: window.location.hostname,
103
+ page_title: document.title,
104
+ referrer: document.referrer,
105
+ user_agent: navigator.userAgent,
106
+ querystring: window.location.search,
107
+ language: navigator.language,
108
+ screenWidth: window.screen.width,
109
+ screenHeight: window.screen.height,
110
+ event_name: "page_view"
111
+ }).then(res => {
112
+ if (res instanceof Response && res.ok && isLocalNetwork()) {
113
+ const src = ctx.domElement?.getAttribute("src") || "";
114
+ const sessionKey = src + VERSION + GENERATOR + BUILD_TIME + PUBLIC_KEY;
115
+ if (window.sessionStorage.getItem("session_key") !== sessionKey) {
116
+ window.sessionStorage.setItem("session_key", sessionKey);
117
+ sendEvent(ctx, "info", {
118
+ src: ctx.domElement?.getAttribute("src") || "",
119
+ version: VERSION,
120
+ generator: GENERATOR,
121
+ build_time: BUILD_TIME,
122
+ public_key: PUBLIC_KEY,
123
+ });
124
+ }
125
+ }
126
+ return;
127
+ })
128
+ }
129
+
130
+ export function isAllowed(context: IContext | null | undefined): boolean {
131
+ let domElement = context?.domElement as HTMLElement | null;
132
+ if (!domElement) domElement = document.querySelector<HTMLElement>("needle-engine");
133
+ if (!domElement && !context) return false;
134
+
135
+ const attribute = domElement?.getAttribute("no-telemetry");
136
+ if (attribute === "" || attribute === "true" || attribute === "1") {
137
+ if (NEEDLE_ENGINE_LICENSE_TYPE === "pro" || NEEDLE_ENGINE_LICENSE_TYPE === "enterprise") {
138
+ if (debug) console.debug("Telemetry is disabled via no-telemetry attribute");
139
+ return false;
140
+ }
141
+ }
142
+ return true;
143
+ }
144
+
145
+ const id = "dabb8317376f";
146
+
147
+ /**
148
+ * Sends a telemetry event
149
+ */
150
+ export async function sendEvent(context: IContext | null | undefined, eventName: string, properties?: Record<string, any>) {
151
+ if (!isAllowed(context)) {
152
+ if (debug) console.debug("Telemetry is disabled");
153
+ return;
154
+ }
155
+ const body = {
156
+ site_id: id,
157
+ type: "custom_event",
158
+ pathname: window.location.pathname,
159
+ event_name: eventName,
160
+ properties: properties ? JSON.stringify(properties) : undefined,
161
+ }
162
+ return doFetch(body);
163
+ }
164
+
165
+ type ErrorData = {
166
+ message?: string;
167
+ stack?: string;
168
+ filename?: string;
169
+ lineno?: number;
170
+ colno?: number;
171
+ timestamp?: number;
172
+ }
173
+
174
+ export async function sendError(context: IContext, errorName: string, error: ErrorData | ErrorEvent | Error) {
175
+
176
+ if (!isAllowed(context)) {
177
+ if (debug) console.debug("Telemetry is disabled");
178
+ return;
179
+ }
180
+
181
+ if (error instanceof ErrorEvent) {
182
+ error = {
183
+ message: error.message,
184
+ stack: error.error?.stack,
185
+ filename: error.filename,
186
+ lineno: error.lineno,
187
+ colno: error.colno,
188
+ timestamp: error.timeStamp || Date.now(),
189
+
190
+ };
191
+ }
192
+ else if (error instanceof Error) {
193
+ error = {
194
+ message: error.message,
195
+ stack: error.stack,
196
+ timestamp: Date.now(),
197
+ };
198
+ }
199
+ const body = {
200
+ site_id: id,
201
+ type: "error",
202
+ event_name: errorName || "error",
203
+ properties: JSON.stringify({
204
+ error_name: errorName,
205
+ message: error.message,
206
+ stack: error.stack,
207
+ filename: error.filename,
208
+ lineno: error.lineno,
209
+ colno: error.colno,
210
+ timestamp: error.timestamp,
211
+ })
212
+ }
213
+ return doFetch(body);
214
+ }
215
+
216
+ function doFetch(body: Record<string, any>) {
217
+ try {
218
+ const url = "https://needle.tools/api/v1/rum/t";
219
+ return fetch(url, {
220
+ method: "POST",
221
+ body: JSON.stringify(body),
222
+ headers: {
223
+ 'Content-Type': 'application/json'
224
+ },
225
+ // Ensures request completes even if page unloads
226
+ keepalive: true,
227
+ // Allow CORS requests
228
+ mode: 'cors',
229
+ // Low priority to avoid blocking other requests
230
+ // @ts-ignore
231
+ priority: 'low',
232
+ }).catch(e => {
233
+ if (debug) console.error("Failed to send telemetry", e);
234
+ })
235
+ }
236
+ catch (err) {
237
+ if (debug) console.error(err);
238
+ }
239
+ return Promise.resolve();
240
+ }
241
+ }
242
+
243
+
70
244
  ContextRegistry.registerCallback(ContextEvent.ContextRegistered, evt => {
71
245
  showLicenseInfo(evt.context);
72
246
  handleForbidden(evt.context);
@@ -341,14 +515,9 @@ async function sendUsageMessageToAnalyticsBackend(context: IContext) {
341
515
  // We can't send beacons from cross-origin isolated pages
342
516
  if (window.crossOriginIsolated) return;
343
517
 
344
- const licenseType = NEEDLE_ENGINE_LICENSE_TYPE;
345
- if (licenseType === "pro" || licenseType === "enterprise") {
346
- const attribute = context?.domElement?.getAttribute("no-telemetry");
347
- if (attribute === "" || attribute === "true" || attribute === "1") {
348
- if (debug) console.debug("Telemetry is disabled");
349
- return;
350
- }
351
- if (debug) console.debug("Telemetry attribute: " + attribute);
518
+ if (!Telemetry.isAllowed(context)) {
519
+ if (debug) console.debug("Telemetry is disabled via no-telemetry attribute");
520
+ return;
352
521
  }
353
522
 
354
523
  try {
@@ -379,4 +548,4 @@ async function sendUsageMessageToAnalyticsBackend(context: IContext) {
379
548
  if (debug)
380
549
  console.log("Failed to send non-commercial usage message to analytics backend", err);
381
550
  }
382
- }
551
+ }
@@ -6,6 +6,7 @@ import { type Websocket } from 'websocket-ts';
6
6
 
7
7
  import * as schemes from "../engine-schemes/schemes.js";
8
8
  import { isDevEnvironment } from './debug/index.js';
9
+ import { Telemetry } from './engine_license.js';
9
10
  import { PeerNetworking } from './engine_networking_peer.js';
10
11
  import { type IModel, type INetworkConnection, SendQueue } from './engine_networking_types.js';
11
12
  import { isHostedOnGlitch } from './engine_networking_utils.js';
@@ -658,6 +659,9 @@ export class NetworkConnection implements INetworkConnection {
658
659
  .onError((_e) => {
659
660
  console.error("Websocket connection failed...");
660
661
  resolve(false);
662
+ Telemetry.sendEvent(this.context, "networking", {
663
+ event: "connection_error",
664
+ });
661
665
  })
662
666
  .onRetry(() => { console.log("Retry connecting to networking websocket") })
663
667
  .build();
@@ -727,6 +731,9 @@ export class NetworkConnection implements INetworkConnection {
727
731
  "server did not send connection id", connection.id);
728
732
  console.debug("Your id is: " + connection.id, this.context.alias ?? "");
729
733
  this._connectionId = connection.id;
734
+ Telemetry.sendEvent(this.context, "networking", {
735
+ event: "connected",
736
+ });
730
737
  }
731
738
  }
732
739
  else console.warn("Expected connection id in " + message.key);
@@ -754,6 +761,10 @@ export class NetworkConnection implements INetworkConnection {
754
761
  }
755
762
 
756
763
  this.onSendQueued(SendQueue.OnRoomJoin);
764
+ Telemetry.sendEvent(this.context, "networking", {
765
+ event: "joined_room",
766
+ room: this._currentRoomName,
767
+ });
757
768
  break;
758
769
 
759
770
  case RoomEvents.LeftRoom:
@@ -765,6 +776,10 @@ export class NetworkConnection implements INetworkConnection {
765
776
  this._currentInRoom.length = 0;
766
777
  if (debugnetBin || isDevEnvironment()) console.debug("Left Needle Engine Room: " + model.room);
767
778
  }
779
+ Telemetry.sendEvent(this.context, "networking", {
780
+ event: "left_room",
781
+ room: model.room,
782
+ });
768
783
  break;
769
784
  case RoomEvents.UserJoinedRoom:
770
785
  if (message.data) {
@@ -337,7 +337,7 @@ class EventListSerializer extends TypeSerializer {
337
337
  args = call.arguments.map(deserializeArgument);
338
338
  }
339
339
  const method = target[call.method];
340
- if (!method) {
340
+ if (method === undefined) {
341
341
  console.warn(`EventList method not found: \"${call.method}\" on ${target?.name}`);
342
342
  }
343
343
  else {
@@ -242,8 +242,10 @@ export function setWorldPosition(obj: Object3D, val: Vector3): Object3D {
242
242
  const wp = _worldPositions.get();
243
243
  if (val !== wp)
244
244
  wp.copy(val);
245
- const obj2 = obj?.parent ?? obj;
246
- obj2.worldToLocal(wp);
245
+
246
+ if (obj.parent !== null)
247
+ obj.parent.worldToLocal(wp);
248
+
247
249
  obj.position.set(wp.x, wp.y, wp.z);
248
250
  return obj;
249
251
  }
@@ -104,7 +104,8 @@ export class NeedleSpatialMenu {
104
104
  }
105
105
 
106
106
  const xr = this._context.xr;
107
- if (!xr?.running) {
107
+ const isImmersiveXR = xr?.running && (xr?.isPassThrough || xr?.isVR)
108
+ if (!isImmersiveXR) {
108
109
  if (this._wasInXR) {
109
110
  this._wasInXR = false;
110
111
  this.onExitXR();
@@ -1,5 +1,6 @@
1
+ import { showBalloonMessage } from "../../debug/debug.js";
1
2
  import type { Context } from "../../engine_context.js";
2
- import { hasCommercialLicense, onLicenseCheckResultChanged } from "../../engine_license.js";
3
+ import { hasCommercialLicense, onLicenseCheckResultChanged, Telemetry } from "../../engine_license.js";
3
4
  import { isLocalNetwork } from "../../engine_networking_utils.js";
4
5
  import { DeviceUtilities, getParam } from "../../engine_utils.js";
5
6
  import { onXRSessionStart, XRSessionEventArgs } from "../../xr/events.js";
@@ -149,6 +150,9 @@ export class NeedleMenu {
149
150
  else console.error("NeedleMenu: onclick is not a valid link", buttoninfo.onclick);
150
151
  }
151
152
  }
153
+ Telemetry.sendEvent(this._context, "needle-menu", {
154
+ action: "button_added_via_postmessage",
155
+ });
152
156
  this._menu.appendChild(button);
153
157
  }
154
158
  else if (debug) console.error("NeedleMenu: unknown postMessage event", data);
@@ -935,12 +939,39 @@ export class NeedleMenuElement extends HTMLElement {
935
939
  /** @private foldout container used in compact mode */
936
940
  private readonly foldout: HTMLDivElement;
937
941
 
942
+
943
+ private readonly trackedElements: WeakSet<Node> = new WeakSet();
944
+ private trackElement(el: Node) {
945
+ if (this.trackedElements.has(el)) return;
946
+ this.trackedElements.add(el);
947
+ el.addEventListener("click", (evt) => {
948
+ Telemetry.sendEvent(this._context, "needle-menu", {
949
+ action: "button_clicked",
950
+ element: evt.target instanceof Node ? evt.target.nodeName : el.nodeName,
951
+ label: el.textContent,
952
+ title: (el instanceof HTMLElement) ? el.title : undefined,
953
+ pointerid: (evt instanceof PointerEvent) ? evt.pointerId : undefined,
954
+ });
955
+ });
956
+ // el.addEventListener("pointerenter", (evt) => {
957
+ // Telemetry.sendEvent(this._context, "needle-menu", {
958
+ // action: "button_hovered",
959
+ // element: evt.target instanceof Node ? evt.target.nodeName : el.nodeName,
960
+ // label: el.textContent,
961
+ // title: (el instanceof HTMLElement) ? el.title : undefined,
962
+ // pointerid: (evt instanceof PointerEvent) ? evt.pointerId : undefined,
963
+ // });
964
+ // });
965
+ }
966
+
938
967
  append(...nodes: (string | Node)[]): void {
939
968
  for (const node of nodes) {
940
969
  if (typeof node === "string") {
941
970
  const element = document.createTextNode(node);
971
+ this.trackElement(element);
942
972
  this.options.appendChild(element);
943
973
  } else {
974
+ this.trackElement(node);
944
975
  this.options.appendChild(node);
945
976
  }
946
977
  }
@@ -987,9 +1018,10 @@ export class NeedleMenuElement extends HTMLElement {
987
1018
  if (node.class) {
988
1019
  button.classList.add(node.class);
989
1020
  }
1021
+
990
1022
  node = button as unknown as T;
991
1023
  }
992
-
1024
+ this.trackElement(node);
993
1025
  const res = this.options.appendChild(node);
994
1026
  return res;
995
1027
  }
@@ -997,8 +1029,10 @@ export class NeedleMenuElement extends HTMLElement {
997
1029
  for (const node of nodes) {
998
1030
  if (typeof node === "string") {
999
1031
  const element = document.createTextNode(node);
1032
+ this.trackElement(element);
1000
1033
  this.options.prepend(element);
1001
1034
  } else {
1035
+ this.trackElement(node);
1002
1036
  this.options.prepend(node);
1003
1037
  }
1004
1038
  }
@@ -1142,8 +1176,15 @@ export class NeedleMenuElement extends HTMLElement {
1142
1176
  const getCurrentWidth = () => {
1143
1177
  return this.options.clientWidth + this.logoContainer.clientWidth;
1144
1178
  }
1179
+
1180
+ let lastSpaceLeft = -1;
1145
1181
  const getSpaceLeft = () => {
1146
- return availableWidth - getCurrentWidth();
1182
+ const spaceLeft = availableWidth - getCurrentWidth();
1183
+ if (debug && spaceLeft !== lastSpaceLeft) {
1184
+ lastSpaceLeft = spaceLeft;
1185
+ showBalloonMessage(`Menu space left: ${spaceLeft.toFixed(0)}px`);
1186
+ }
1187
+ return spaceLeft;
1147
1188
  }
1148
1189
  }
1149
1190
 
@@ -6,6 +6,7 @@ import { Context, FrameEvent } from "../engine_context.js";
6
6
  import { ContextEvent, ContextRegistry } from "../engine_context_registry.js";
7
7
  import { isDestroyed } from "../engine_gameobject.js";
8
8
  import { Gizmos } from "../engine_gizmos.js";
9
+ import { Telemetry } from "../engine_license.js";
9
10
  import { registerFrameEventCallback, unregisterFrameEventCallback } from "../engine_lifecycle_functions_internal.js";
10
11
  import { getBoundingBox, getTempQuaternion, getTempVector, getWorldPosition, getWorldQuaternion, getWorldScale, setWorldPosition, setWorldQuaternion, setWorldScale } from "../engine_three_utils.js";
11
12
  import type { ICamera, IComponent, INeedleXRSession } from "../engine_types.js";
@@ -98,12 +99,12 @@ async function handleSessionGranted() {
98
99
  }
99
100
  // Check if AR is supported, otherwise we can't do anything
100
101
  if (!(await navigator.xr?.isSessionSupported("immersive-ar")) && defaultMode === "immersive-ar") {
101
- console.warn("[NeedleXRSession:granted] Neither VR nor AR supported, aborting session start.");
102
+ // console.warn("[NeedleXRSession:granted] Neither VR nor AR supported, aborting session start.");
102
103
  // showBalloonMessage("NeidleXRSession: Neither VR nor AR supported, aborting session start.");
103
104
  return;
104
105
  }
105
106
  } catch (e) {
106
- console.error("[NeedleXRSession:granted] Error while checking XR support:", e);
107
+ console.debug("[NeedleXRSession:granted] Error while checking XR support:", e);
107
108
  // showBalloonWarning("NeedleXRSession: Error while checking XR support: " + (e as Error).message);
108
109
  return;
109
110
  }
@@ -148,6 +149,7 @@ async function handleSessionGranted() {
148
149
  navigator.xr?.addEventListener('sessiongranted', async () => {
149
150
  // enableSpatialConsole(true);
150
151
 
152
+
151
153
  const lastSessionMode = sessionStorage.getItem("needle_xr_session_mode") as XRSessionMode;
152
154
  const lastSessionInit = sessionStorage.getItem("needle_xr_session_init") ?? null;
153
155
  const init = lastSessionInit ? JSON.parse(lastSessionInit) : null;
@@ -459,27 +461,55 @@ export class NeedleXRSession implements INeedleXRSession {
459
461
  if (DeviceUtilities.isiOS()) {
460
462
 
461
463
  const arSupported = await this.isARSupported().catch(() => false);
462
-
464
+
463
465
  // On VisionOS, we use QuickLook for AR experiences; no AppClip support for now.
464
466
  if (DeviceUtilities.isVisionOS() && !arSupported && (mode === "ar" || mode === "immersive-ar")) {
465
467
  mode = "quicklook";
466
468
  }
467
469
 
468
470
  if (mode === "quicklook") {
471
+ Telemetry.sendEvent(Context.Current, "xr", {
472
+ action: "quicklook_export",
473
+ source: "NeedleXRSession.start",
474
+ });
469
475
  InternalUSDZRegistry.exportAndOpen();
470
476
  return null;
471
477
  }
472
-
478
+
473
479
  if (!arSupported && (mode === "immersive-ar" || mode === "ar")) {
474
480
  // const debugAppClip = getParam("debugappclip")
475
481
  // Forward to the AppClip experience (Using the apple.com url the appclip overlay shows immediately)
476
- const url = new URL("https://appclip.apple.com/id?p=tools.needle.launch-app.Clip");
477
- url.searchParams.set("url", location.href);
478
482
  // const url =`https://appclip.needle.tools/ar?url=${(location.href)}`;
479
- console.debug("iOS device detected - opening Needle App Clip for AR experience", { mode, init, url });
480
- // navigate to app clip url but keep the current url in history, open in same tab
481
- // eslint-disable-next-line xss/no-location-href-assign
482
- window.location.href = encodeURI(url.toString());
483
+ const url = new URL("https://appclip.apple.com/id?p=tools.needle.launch-app.Clip");
484
+ url.searchParams.set("url", location.href);
485
+
486
+ const urlStr = url.toString();
487
+
488
+ Telemetry.sendEvent(Context.Current, "xr", {
489
+ action: "app_clip_launch",
490
+ source: "NeedleXRSession.start",
491
+ url: urlStr,
492
+ });
493
+
494
+ // if we are in an iframe, we need to navigate the top window
495
+ const topWindow = window.top || window;
496
+ try {
497
+ console.debug("iOS device detected - opening Needle App Clip for AR experience", { mode, init, url });
498
+ // navigate to app clip url but keep the current url in history, open in same tab
499
+ // eslint-disable-next-line xss/no-location-href-assign
500
+ topWindow.location.href = urlStr;
501
+ }
502
+ catch (e) {
503
+ console.warn("Error navigating to AppClip " + urlStr + "\n", e);
504
+ // if top window navigation fails and we are in an iframe, we try to navigate the top window directly
505
+ const weAreInIframe = window !== window.top;
506
+ if (weAreInIframe) {
507
+ // we can try to open a new tab as a fallback
508
+ window.open(urlStr, "_blank");
509
+ }
510
+ // eslint-disable-next-line xss/no-location-href-assign
511
+ else window.location.href = urlStr;
512
+ }
483
513
 
484
514
  return null;
485
515
  }
@@ -489,7 +519,7 @@ export class NeedleXRSession implements INeedleXRSession {
489
519
  console.warn("QuickLook mode is only supported on iOS devices");
490
520
  return null;
491
521
  }
492
-
522
+
493
523
  // Since we now know we are not on iOS, ar mode becomes "immersive-ar"
494
524
  if (mode == "ar") {
495
525
  mode = "immersive-ar";
@@ -589,6 +619,12 @@ export class NeedleXRSession implements INeedleXRSession {
589
619
  listener({ mode, init });
590
620
  }
591
621
  if (debug) showBalloonMessage("Requesting " + mode + " session (" + Date.now() + ")");
622
+ Telemetry.sendEvent(Context.Current, "xr", {
623
+ action: "session_request",
624
+ mode: mode,
625
+ features: ((init.requiredFeatures ?? []).concat(init.optionalFeatures ?? [])).join(","),
626
+ source: "NeedleXRSession.start",
627
+ });
592
628
  this._currentSessionRequest = navigator?.xr?.requestSession(mode, init);
593
629
  this._currentSessionRequestMode = mode;
594
630
  /**@type {XRSystem} */
@@ -1165,6 +1201,12 @@ export class NeedleXRSession implements INeedleXRSession {
1165
1201
 
1166
1202
  console.debug("XR Session ended");
1167
1203
 
1204
+ Telemetry.sendEvent(Context.Current, "xr", {
1205
+ action: "session_end",
1206
+ mode: this.mode,
1207
+ source: "NeedleXRSession.onEnd",
1208
+ });
1209
+
1168
1210
  deleteSessionInfo();
1169
1211
 
1170
1212
  this.onAfterRender();
@@ -447,7 +447,7 @@ export class DragControls extends Behaviour implements IPointerEventHandler {
447
447
  if (!this || !this._isDragging) return;
448
448
  this._isDragging = false;
449
449
  for (const rb of this._draggingRigidbodies) {
450
- rb.setVelocity(rb.smoothedVelocity);
450
+ rb.setVelocity(rb.smoothedVelocity.multiplyScalar(this.context.time.deltaTime));
451
451
  }
452
452
  this._draggingRigidbodies.length = 0;
453
453
  this._targetObject = null;
@@ -10,7 +10,7 @@ import { BlobStorage } from "../engine/engine_networking_blob.js";
10
10
  import { PreviewHelper } from "../engine/engine_networking_files.js";
11
11
  import { InstantiateIdProvider } from "../engine/engine_networking_instantiate.js";
12
12
  import { serializable } from "../engine/engine_serialization_decorator.js";
13
- import { fitObjectIntoVolume, getBoundingBox, placeOnSurface } from "../engine/engine_three_utils.js";
13
+ import { fitObjectIntoVolume, getBoundingBox, getWorldScale, placeOnSurface } from "../engine/engine_three_utils.js";
14
14
  import { Model, Vec3 } from "../engine/engine_types.js";
15
15
  import { getParam, setParamWithoutReload } from "../engine/engine_utils.js";
16
16
  import { determineMimeTypeFromExtension } from "../engine/engine_utils_format.js";
@@ -111,6 +111,7 @@ const blobKeyName = "blob";
111
111
 
112
112
  /** The DropListener component is used to listen for drag and drop events in the browser and add the dropped files to the scene
113
113
  * It can be used to allow users to drag and drop glTF files into the scene to add new objects.
114
+ * Existing child objects will behave like placeholders and will be removed when new files are dropped.
114
115
  *
115
116
  * If {@link useNetworking} is enabled, the DropListener will automatically synchronize dropped files to other connected clients.
116
117
  * Enable {@link fitIntoVolume} to automatically scale dropped objects to fit within the volume defined by {@link fitVolumeSize}.
@@ -522,22 +523,42 @@ export class DropListener extends Behaviour {
522
523
 
523
524
  const obj = model.scene;
524
525
 
525
- // use attach to ignore the DropListener scale (e.g. if the parent object scale is not uniform)
526
- this.gameObject.attach(obj);
527
- obj.position.set(0, 0, 0);
528
- obj.quaternion.identity();
526
+ obj.position.copy(this.gameObject.worldPosition);
527
+ const scale = getWorldScale(this.gameObject);
529
528
 
530
- this._addedObjects.push(obj);
531
- this._addedModels.push(model);
529
+ let localPos = new Vector3(0,0,0);
530
+ scale.x = Math.abs(scale.x);
531
+ scale.y = Math.abs(scale.y);
532
+ scale.z = Math.abs(scale.z);
533
+ let localScale =obj.scale.clone();
534
+
535
+ // TODOs: handle rotation when Gizmos APIs has changed to support it
532
536
 
533
- const volume = new Box3().setFromCenterAndSize(new Vector3(0, this.fitVolumeSize.y * .5, 0).add(this.gameObject.worldPosition), this.fitVolumeSize);
537
+ const volume = new Box3().setFromCenterAndSize(new Vector3(0, this.fitVolumeSize.y * scale.y * .5, 0).add(this.gameObject.worldPosition), this.fitVolumeSize.clone().multiply(scale));
534
538
  if (debug) Gizmos.DrawWireBox3(volume, 0x0000ff, 5);
535
539
  if (this.fitIntoVolume) {
540
+
536
541
  fitObjectIntoVolume(obj, volume, {
537
542
  position: !this.placeAtHitPosition
538
543
  });
544
+
545
+ // to match parent scale later, divide by it
546
+ localScale = obj.scale.clone().divide(scale);
547
+ // just take the computed offset from fitting
548
+ localPos = obj.worldPosition.clone().sub(this.gameObject.worldPosition).divide(scale);
549
+ if (debug) Gizmos.DrawSphere(localPos, 0.1, 0xff0000, 5);
539
550
  }
540
551
 
552
+ // use attach to ignore the DropListener scale (e.g. if the parent object scale is not uniform)
553
+ this.gameObject.attach(obj);
554
+ obj.position.copy(localPos);
555
+ obj.quaternion.identity();
556
+ obj.scale.copy(localScale);
557
+ if (debug) Gizmos.DrawArrow(this.gameObject.worldPosition, obj.getWorldPosition(new Vector3()), 0x00ff00, 5);
558
+
559
+ this._addedObjects.push(obj);
560
+ this._addedModels.push(model);
561
+
541
562
  if (this.placeAtHitPosition && ctx && ctx.screenposition) {
542
563
  obj.visible = false; // < don't raycast on the placed object
543
564
  const rc = this.context.physics.raycast({ screenPoint: this.context.input.convertScreenspaceToRaycastSpace(ctx.screenposition.clone()) });
@@ -78,7 +78,11 @@ export class CallInfo {
78
78
  // If the target is a property
79
79
  else {
80
80
  if (this.arguments) {
81
- this.target[this.methodName] = this.arguments[0] || args[0];
81
+
82
+ if (args !== undefined && args.length > 0)
83
+ this.target[this.methodName] = args[0];
84
+ else
85
+ this.target[this.methodName] = this.arguments[0];
82
86
  }
83
87
  else {
84
88
  this.target[this.methodName] = args[0];