@needle-tools/engine 4.16.11 → 4.16.12

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 (70) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/SKILL.md +64 -4
  3. package/dist/gltf-progressive-DLDvZ8Xm.umd.cjs +10 -0
  4. package/dist/{gltf-progressive-CzxjNmG6.js → gltf-progressive-DMK8kKVB.js} +352 -346
  5. package/dist/gltf-progressive-RPgYR6rv.min.js +10 -0
  6. package/dist/{needle-engine.bundle-Du3vf5L1.js → needle-engine.bundle-BfsnYTeP.js} +6960 -6823
  7. package/dist/{needle-engine.bundle-DOChN3At.umd.cjs → needle-engine.bundle-COr8D1vc.umd.cjs} +130 -130
  8. package/dist/{needle-engine.bundle-fP5tOMI1.min.js → needle-engine.bundle-DG1sGkxn.min.js} +131 -131
  9. package/dist/needle-engine.d.ts +11 -1
  10. package/dist/needle-engine.js +3 -3
  11. package/dist/needle-engine.min.js +1 -1
  12. package/dist/needle-engine.umd.cjs +1 -1
  13. package/dist/three-examples.js +52 -52
  14. package/dist/three-examples.min.js +4 -4
  15. package/dist/three-examples.umd.cjs +2 -2
  16. package/lib/engine/api.d.ts +2 -1
  17. package/lib/engine/api.js +1 -6
  18. package/lib/engine/api.js.map +1 -1
  19. package/lib/engine/engine_addressables.d.ts +32 -1
  20. package/lib/engine/engine_addressables.js +71 -9
  21. package/lib/engine/engine_addressables.js.map +1 -1
  22. package/lib/engine/engine_application.d.ts +2 -0
  23. package/lib/engine/engine_application.js +7 -3
  24. package/lib/engine/engine_application.js.map +1 -1
  25. package/lib/engine/engine_context.js +25 -1
  26. package/lib/engine/engine_context.js.map +1 -1
  27. package/lib/engine/engine_input.js +1 -1
  28. package/lib/engine/engine_input.js.map +1 -1
  29. package/lib/engine/engine_license.d.ts +1 -1
  30. package/lib/engine/engine_license.js +31 -5
  31. package/lib/engine/engine_license.js.map +1 -1
  32. package/lib/engine/engine_lifecycle_functions_internal.js +20 -2
  33. package/lib/engine/engine_lifecycle_functions_internal.js.map +1 -1
  34. package/lib/engine/engine_networking_streams.d.ts +5 -1
  35. package/lib/engine/engine_networking_streams.js +64 -17
  36. package/lib/engine/engine_networking_streams.js.map +1 -1
  37. package/lib/engine/physics/workers/mesh-bvh/GenerateMeshBVHWorker.js +5 -0
  38. package/lib/engine/physics/workers/mesh-bvh/GenerateMeshBVHWorker.js.map +1 -1
  39. package/lib/engine/webcomponents/fonts.d.ts +1 -1
  40. package/lib/engine/webcomponents/fonts.js +7 -0
  41. package/lib/engine/webcomponents/fonts.js.map +1 -1
  42. package/lib/engine/webcomponents/icons.js +8 -1
  43. package/lib/engine/webcomponents/icons.js.map +1 -1
  44. package/lib/engine/webcomponents/needle menu/needle-menu.d.ts +5 -0
  45. package/lib/engine/webcomponents/needle menu/needle-menu.js +31 -13
  46. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  47. package/lib/engine/webcomponents/needle-engine.d.ts +1 -0
  48. package/lib/engine/webcomponents/needle-engine.js +16 -4
  49. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  50. package/lib/engine/webcomponents/needle-engine.loading.d.ts +2 -0
  51. package/lib/engine/webcomponents/needle-engine.loading.js +10 -0
  52. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  53. package/lib/engine-components/postprocessing/Effects/Tonemapping.utils.d.ts +1 -1
  54. package/package.json +2 -2
  55. package/src/engine/api.ts +3 -1
  56. package/src/engine/engine_addressables.ts +75 -9
  57. package/src/engine/engine_application.ts +7 -2
  58. package/src/engine/engine_context.ts +25 -1
  59. package/src/engine/engine_input.ts +2 -2
  60. package/src/engine/engine_license.ts +28 -5
  61. package/src/engine/engine_lifecycle_functions_internal.ts +21 -2
  62. package/src/engine/engine_networking_streams.ts +64 -19
  63. package/src/engine/physics/workers/mesh-bvh/GenerateMeshBVHWorker.js +8 -1
  64. package/src/engine/webcomponents/fonts.ts +6 -0
  65. package/src/engine/webcomponents/icons.ts +9 -2
  66. package/src/engine/webcomponents/needle menu/needle-menu.ts +30 -12
  67. package/src/engine/webcomponents/needle-engine.loading.ts +13 -1
  68. package/src/engine/webcomponents/needle-engine.ts +15 -5
  69. package/dist/gltf-progressive-BacJPTD6.umd.cjs +0 -10
  70. package/dist/gltf-progressive-BmXoz_HR.min.js +0 -10
@@ -1,5 +1,6 @@
1
1
  import 'three/examples/jsm/renderers/webgl-legacy/nodes/WebGLNodes.js';
2
2
 
3
+ import { NEEDLE_progressive } from '@needle-tools/gltf-progressive';
3
4
  import type { EffectComposer } from "postprocessing";
4
5
  import {
5
6
  BufferGeometry, Camera, Color, DepthTexture, Group,
@@ -16,7 +17,7 @@ import { nodeFrame } from "three/examples/jsm/renderers/webgl-legacy/nodes/WebGL
16
17
  import { initSpectorIfAvailable } from './debug/debug_spector.js';
17
18
  import { isDevEnvironment, LogType, showBalloonError, showBalloonMessage } from './debug/index.js';
18
19
  import { AccessibilityManager } from './engine_accessibility.js';
19
- import { Addressables } from './engine_addressables.js';
20
+ import { Addressables, clearAssetCaches } from './engine_addressables.js';
20
21
  import { AnimationsRegistry } from './engine_animation.js';
21
22
  import { Application } from './engine_application.js';
22
23
  import { AssetDatabase } from './engine_assetdatabase.js';
@@ -850,6 +851,19 @@ export class Context implements IContext {
850
851
  Context.Current = this;
851
852
  ContextRegistry.dispatchCallback(ContextEvent.ContextDestroying, this);
852
853
  invokeLifecycleFunctions(this, ContextEvent.ContextDestroying);
854
+ // The global legacy node frame must not retain this context's scene after teardown.
855
+ if (nodeFrame.camera === this._mainCamera || nodeFrame.camera === this._fallbackCamera
856
+ || nodeFrame.camera === this.mainCameraComponent?.threeCamera) {
857
+ nodeFrame.camera = null;
858
+ }
859
+ if (this.renderer && nodeFrame.renderer === this.renderer) {
860
+ nodeFrame.camera = null;
861
+ nodeFrame.renderer = null;
862
+ nodeFrame.object = null;
863
+ nodeFrame.material = null;
864
+ nodeFrame.scene = null;
865
+ nodeFrame.geometry = null;
866
+ }
853
867
  this.clear();
854
868
  this.renderer?.setAnimationLoop(null);
855
869
  if (this.renderer) {
@@ -862,6 +876,7 @@ export class Context implements IContext {
862
876
  }
863
877
  this.scene = null!;
864
878
  this.renderer = null!;
879
+ this.application.dispose();
865
880
  this.input.dispose();
866
881
  this.menu.onDestroy();
867
882
  this.animations.onDestroy();
@@ -880,6 +895,15 @@ export class Context implements IContext {
880
895
  ContextRegistry.dispatchCallback(ContextEvent.ContextDestroyed, this);
881
896
  invokeLifecycleFunctions(this, ContextEvent.ContextDestroyed);
882
897
  ContextRegistry.unregister(this);
898
+ // The asset caches (FileReference/ImageReference) are static and shared by all contexts, so
899
+ // they can only be released once nothing is left that could still be using them. Without
900
+ // this they are never emptied: a SPA that repeatedly creates and destroys <needle-engine>
901
+ // elements keeps every loaded file and image alive for the lifetime of the document.
902
+ if (ContextRegistry.Registered.length === 0) {
903
+ clearAssetCaches();
904
+ // Geometry promises in this shared cache retain resource users and their textures.
905
+ NEEDLE_progressive.dispose();
906
+ }
883
907
  if (Context.Current === this) {
884
908
  //@ts-ignore
885
909
  Context.Current = null;
@@ -942,7 +942,7 @@ export class Input implements IInput {
942
942
  window.removeEventListener('contextmenu', this.onContextMenu);
943
943
 
944
944
  this._htmlEventSource?.removeEventListener('pointerdown', this.onPointerDown);
945
- window.removeEventListener('pointermove', this.onPointerMove);
945
+ window.removeEventListener('pointermove', this.onPointerMove, true);
946
946
  window.removeEventListener('pointerup', this.onPointerUp);
947
947
  window.removeEventListener('pointercancel', this.onPointerCancel);
948
948
 
@@ -1597,4 +1597,4 @@ export declare type KeyCode =
1597
1597
  // OPEN_BRACKET = 219,
1598
1598
  // BACK_SLASH = 220,
1599
1599
  // CLOSE_BRACKET = 221,
1600
- // SINGLE_QUOTE = 222
1600
+ // SINGLE_QUOTE = 222
@@ -10,6 +10,23 @@ import type { IContext } from "./engine_types.js";
10
10
  import { getParam } from "./engine_utils.js";
11
11
  import { InternalAttributeUtils } from "./engine_utils_attributes.js";
12
12
 
13
+ // License UI polling must stop when its context goes away; otherwise timers retain scenes.
14
+ const licenseIntervals = new WeakMap<IContext, Set<ReturnType<typeof setInterval>>>();
15
+ const disposedLicenseContexts = new WeakSet<IContext>();
16
+ ContextRegistry.registerCallback(ContextEvent.ContextDestroying, ({ context }) => {
17
+ disposedLicenseContexts.add(context);
18
+ for (const timer of licenseIntervals.get(context) ?? []) clearInterval(timer);
19
+ licenseIntervals.delete(context);
20
+ });
21
+ function setLicenseInterval(ctx: IContext, callback: () => void, delay: number) {
22
+ if (disposedLicenseContexts.has(ctx)) return undefined;
23
+ let timers = licenseIntervals.get(ctx);
24
+ if (!timers) licenseIntervals.set(ctx, timers = new Set());
25
+ const timer = setInterval(callback, delay);
26
+ timers.add(timer);
27
+ return timer;
28
+ }
29
+
13
30
  const debug = getParam("debuglicense");
14
31
 
15
32
  const _licenseCheckResultChangedCallbacks: ((result: boolean) => void)[] = [];
@@ -56,9 +73,15 @@ export function hasCommercialLicense() {
56
73
 
57
74
  /** @internal */
58
75
  export function onLicenseCheckResultChanged(cb: (result: boolean) => void) {
59
- if (hasProLicense() || hasIndieLicense() || hasEduLicense())
60
- return cb(true);
76
+ if (hasProLicense() || hasIndieLicense() || hasEduLicense()) {
77
+ cb(true);
78
+ return () => {};
79
+ }
61
80
  _licenseCheckResultChangedCallbacks.push(cb);
81
+ return () => {
82
+ const index = _licenseCheckResultChangedCallbacks.indexOf(cb);
83
+ if (index >= 0) _licenseCheckResultChangedCallbacks.splice(index, 1);
84
+ };
62
85
  }
63
86
 
64
87
  function invokeLicenseCheckResultChanged(result: boolean) {
@@ -328,7 +351,7 @@ async function handleForbidden(ctx: IContext) {
328
351
  const expectedTextStyle = text.style.cssText;
329
352
  const forbiddenText = applicationForbiddenText?.length > 1 ? applicationForbiddenText : "This web application has been paused.<br/>You might be in violation of the Needle Engine terms of use.<br/>Please contact the Needle support if you think this is a mistake.";
330
353
  text.innerHTML = forbiddenText;
331
- setInterval(() => {
354
+ setLicenseInterval(ctx, () => {
332
355
  if (text.innerHTML !== forbiddenText) text.innerHTML = forbiddenText;
333
356
  if (text.parentNode !== div) div.appendChild(text);
334
357
  if (div.style.cssText !== expectedStyle) div.style.cssText = expectedStyle;
@@ -338,7 +361,7 @@ async function handleForbidden(ctx: IContext) {
338
361
  }
339
362
  let forbiddenElement = createForbiddenElement();
340
363
  const expectedCSS = forbiddenElement.style.cssText;
341
- setInterval(() => {
364
+ setLicenseInterval(ctx, () => {
342
365
  if (applicationIsForbidden === true) {
343
366
  if (forbiddenElement.style.cssText !== expectedCSS) forbiddenElement = createForbiddenElement();
344
367
  if (ctx.domElement.shadowRoot) {
@@ -452,7 +475,7 @@ function insertNonCommercialUseHint(ctx: IContext) {
452
475
  }, 100);
453
476
 
454
477
  // ensure the banner is always visible
455
- const interval = setInterval(() => {
478
+ const interval = setLicenseInterval(ctx, () => {
456
479
  const parent = ctx.domElement.shadowRoot || ctx.domElement;
457
480
  if (banner.parentNode !== parent) {
458
481
  parent.appendChild(banner);
@@ -141,13 +141,27 @@ function internalInvokeLifecycleFunctions(ctx: Context, evt: Event) {
141
141
  }
142
142
  }
143
143
 
144
- const bufferArray = new Array<RegisteredLifecycleMethod>();
144
+ const buffers: Array<Array<RegisteredLifecycleMethod>> = [];
145
+ let invocationDepth = 0;
145
146
  const hookContext: LifecycleHookContext = {
146
147
  context: null as any as Context
147
148
  };
148
149
 
149
150
  function invoke(ctx: Context, methods: Array<RegisteredLifecycleMethod>, invokeOnce: boolean) {
150
- bufferArray.length = 0;
151
+ // Reuse storage per nesting depth: hooks may synchronously invoke other hooks.
152
+ const bufferArray = buffers[invocationDepth] ?? (buffers[invocationDepth] = []);
153
+ invocationDepth++;
154
+ try {
155
+ invokeWithBuffer(ctx, methods, invokeOnce, bufferArray);
156
+ }
157
+ finally {
158
+ // One-time/unregistered callbacks can capture entire scenes as well.
159
+ bufferArray.length = 0;
160
+ invocationDepth--;
161
+ }
162
+ }
163
+
164
+ function invokeWithBuffer(ctx: Context, methods: Array<RegisteredLifecycleMethod>, invokeOnce: boolean, bufferArray: Array<RegisteredLifecycleMethod>) {
151
165
  for (let i = 0; i < methods.length; i++) {
152
166
  bufferArray.push(methods[i]);
153
167
  }
@@ -161,6 +175,7 @@ function invoke(ctx: Context, methods: Array<RegisteredLifecycleMethod>, invokeO
161
175
  }
162
176
 
163
177
  if (invoke) {
178
+ const previousContext = hookContext.context;
164
179
  try {
165
180
  hookContext.context = ctx;
166
181
  entry.method?.call(hookContext, ctx);
@@ -168,6 +183,10 @@ function invoke(ctx: Context, methods: Array<RegisteredLifecycleMethod>, invokeO
168
183
  catch (e) {
169
184
  console.error("Error in lifecycle method", e);
170
185
  }
186
+ finally {
187
+ // This shared receiver must not keep the last invoked context alive.
188
+ hookContext.context = previousContext;
189
+ }
171
190
  }
172
191
 
173
192
  // Remove the method if it's a one time call
@@ -1,4 +1,5 @@
1
- import Peer, { CallOption, MediaConnection } from "peerjs"
1
+ import type Peer from "peerjs";
2
+ import type { CallOption, MediaConnection } from "peerjs";
2
3
  import { EventDispatcher } from "three";
3
4
 
4
5
  import { RoomEvents } from "../engine/engine_networking.js";
@@ -7,6 +8,7 @@ import { getPeerjsInstance } from "../engine/engine_networking_peer.js";
7
8
  import { showBalloonMessage } from "./debug/index.js";
8
9
  import { Application } from "./engine_application.js";
9
10
  import { Context } from "./engine_context.js";
11
+ import { ContextEvent, ContextRegistry } from "./engine_context_registry.js";
10
12
  import type { IModel } from "./engine_networking_types.js";
11
13
  import { type IComponent, isComponent } from "./engine_types.js";
12
14
  import { getParam } from "./engine_utils.js";
@@ -120,17 +122,31 @@ function applySdpTransform(sdp) {
120
122
 
121
123
  export class PeerHandle extends EventDispatcher<any> {
122
124
 
123
- private static readonly instances: Map<string, PeerHandle> = new Map();
125
+ // Weak ownership prevents this cache from rooting a context and its scene indefinitely.
126
+ // IDs are scoped to contexts: separate engine elements may use the same stream ID.
127
+ private static readonly instances = new WeakMap<Context, Map<string, PeerHandle>>();
128
+
129
+ static {
130
+ ContextRegistry.registerCallback(ContextEvent.ContextDestroying, ({ context }) => {
131
+ const handles = PeerHandle.instances.get(context as Context);
132
+ if (!handles) return;
133
+ PeerHandle.instances.delete(context as Context);
134
+ for (const handle of handles.values()) handle.dispose();
135
+ handles.clear();
136
+ });
137
+ }
124
138
 
125
139
  static getOrCreate(context: Context, guid: string): PeerHandle {
126
- // if (id === undefined) {
127
- // // randomId
128
- // id = Math.random().toFixed(5);
129
- // }
130
- if (PeerHandle.instances.has(guid))
131
- return PeerHandle.instances.get(guid)!;
132
- const peer = new PeerHandle(context, guid);
133
- PeerHandle.instances.set(guid, peer);
140
+ let handles = PeerHandle.instances.get(context);
141
+ if (!handles) {
142
+ handles = new Map();
143
+ PeerHandle.instances.set(context, handles);
144
+ }
145
+ let peer = handles.get(guid);
146
+ if (!peer) {
147
+ peer = new PeerHandle(context, guid);
148
+ handles.set(guid, peer);
149
+ }
134
150
  return peer;
135
151
  }
136
152
 
@@ -150,6 +166,7 @@ export class PeerHandle extends EventDispatcher<any> {
150
166
  }
151
167
 
152
168
  makeCall(peerId: string, stream: MediaStream): CallHandle | undefined {
169
+ if (this._disposed) return undefined;
153
170
  if (!stream?.id) {
154
171
  if (debug) console.warn("Can not make a call: mediastream has no id or is undefined");
155
172
  else console.debug("Can not make a call: mediastream has no id or is undefined");
@@ -178,10 +195,8 @@ export class PeerHandle extends EventDispatcher<any> {
178
195
  }
179
196
 
180
197
  closeAll() {
181
- for (const call of this._incomingCalls) {
182
- call.close();
183
- }
184
- for (const call of this._outgoingCalls) {
198
+ // Closing a call synchronously removes it from the original array.
199
+ for (const call of [...this._incomingCalls, ...this._outgoingCalls]) {
185
200
  call.close();
186
201
  }
187
202
  this.updateCalls();
@@ -252,16 +267,33 @@ export class PeerHandle extends EventDispatcher<any> {
252
267
  }
253
268
  }
254
269
 
270
+ private _disposed = false;
271
+ private readonly _callIntervals = new Set<ReturnType<typeof setInterval>>();
272
+
273
+ private dispose() {
274
+ if (this._disposed) return;
275
+ this._disposed = true;
276
+ this.disable();
277
+ this.unsubscribePeerEvents();
278
+ for (const interval of this._callIntervals) clearInterval(interval);
279
+ this._callIntervals.clear();
280
+ this.closeAll();
281
+ this._incomingCalls.length = 0;
282
+ this._outgoingCalls.length = 0;
283
+ this._peer?.destroy();
284
+ this._peer = undefined;
285
+ }
286
+
255
287
  private _enabled: boolean = false;
256
288
  private _enabledPeer: boolean = false;
257
- private onConnectRoomFn: Function = this.onConnectRoom.bind(this);
289
+ private onConnectRoomFn: () => void = this.onConnectRoom.bind(this);
258
290
  // private onUserJoinedOrLeftRoomFn: Function = this.onUserJoinedOrLeftRoom.bind(this);
259
291
  // private onPeerConnectFn: (id) => void = this.onPeerConnect.bind(this);
260
292
  // private onPeerReceiveCallFn: (call) => void = this.onPeerReceivingCall.bind(this);
261
293
  // private _connectionPeerIdMap : Map<string, string> = new Map();
262
294
 
263
295
  enable() {
264
- if (this._enabled) return;
296
+ if (this._disposed || this._enabled) return;
265
297
  this._enabled = true;
266
298
  this.context.connection.beginListen(RoomEvents.JoinedRoom, this.onConnectRoomFn);
267
299
  // this.context.connection.beginListen(RoomEvents.UserJoinedRoom, this.onUserJoinedOrLeftRoomFn);
@@ -286,13 +318,19 @@ export class PeerHandle extends EventDispatcher<any> {
286
318
  // };
287
319
 
288
320
  private setupPeer() {
289
- if (!this.context.connection.connectionId) return;
321
+ if (this._disposed || !this.context.connection.connectionId) return;
290
322
  if (this._enabledPeer) return;
291
323
  this._enabledPeer = true;
292
324
  if (!this._peer) {
293
325
  const peerId = this.getMyPeerId();
294
- if (peerId)
295
- this._peer = getPeerjsInstance(peerId);
326
+ if (peerId) {
327
+ const peer = getPeerjsInstance(peerId);
328
+ if (this._disposed) {
329
+ peer.destroy();
330
+ return;
331
+ }
332
+ this._peer = peer;
333
+ }
296
334
  else console.error("Failed to setup peerjs because we dont have a connection id", this.context.connection.connectionId);
297
335
  }
298
336
  if (this._enabled)
@@ -414,6 +452,7 @@ export class PeerHandle extends EventDispatcher<any> {
414
452
  });
415
453
 
416
454
  call.on("stream", () => {
455
+ if (this._disposed) return;
417
456
  if (debug) console.log("Received stream for call", call.metadata);
418
457
  // workaround for https://github.com/peers/peerjs/issues/636
419
458
  let intervalCounter = 0;
@@ -423,9 +462,15 @@ export class PeerHandle extends EventDispatcher<any> {
423
462
  if (debug) console.warn("Close call because stream is not active", call.metadata);
424
463
  intervalCounter += 1;
425
464
  clearInterval(closeInterval);
465
+ this._callIntervals.delete(closeInterval);
426
466
  handle.close();
427
467
  }
428
468
  }, 2000);
469
+ this._callIntervals.add(closeInterval);
470
+ call.on("close", () => {
471
+ clearInterval(closeInterval);
472
+ this._callIntervals.delete(closeInterval);
473
+ });
429
474
  });
430
475
  }
431
476
  return handle;
@@ -120,7 +120,14 @@ export class GenerateMeshBVHWorker extends WorkerBase {
120
120
 
121
121
  }, transferable.map( arr => arr.buffer ).filter( v => ( typeof SharedArrayBuffer === 'undefined' ) || ! ( v instanceof SharedArrayBuffer ) ) );
122
122
 
123
- } );
123
+ } ).finally( () => {
124
+
125
+ // Pooled workers outlive each job. Both handlers capture the job scope,
126
+ // including its geometry, even after the promise has settled.
127
+ worker.onmessage = null;
128
+ worker.onerror = null;
129
+
130
+ } );
124
131
 
125
132
  }
126
133
 
@@ -27,6 +27,12 @@ export function loadFont(url: string, opts?: LoadFontOptions) {
27
27
  }
28
28
  }
29
29
  }
30
+ // The link lives in document.head and can outlive the component using this callback.
31
+ return () => {
32
+ if (opts?.loadedCallback) {
33
+ for (const link of elements) link.removeEventListener("load", opts.loadedCallback);
34
+ }
35
+ };
30
36
  }
31
37
 
32
38
 
@@ -20,7 +20,15 @@ export function getIconElement(str: string): HTMLElement {
20
20
  span.setAttribute("role", "img");
21
21
  span.setAttribute("aria-label", str + " icon");
22
22
  span.setAttribute("aria-hidden", "true");
23
+ updateIconWhenFontReady(new WeakRef(span), str);
24
+ return span;
25
+ }
26
+
27
+ // Pending font loads must not retain detached menu trees.
28
+ function updateIconWhenFontReady(reference: WeakRef<HTMLSpanElement>, str: string) {
23
29
  fontReady(fontname).then(res => {
30
+ const span = reference.deref();
31
+ if (!span) return;
24
32
  if (res) span.style.visibility = "";
25
33
  else {
26
34
  if (str === "more_vert") {
@@ -31,8 +39,7 @@ export function getIconElement(str: string): HTMLElement {
31
39
  span.style.display = "none";
32
40
  }
33
41
  }
34
- })
35
- return span;
42
+ });
36
43
  }
37
44
 
38
45
  /**@returns true if the element is an needle engine icon element */
@@ -3,7 +3,7 @@ import type { Context } from "../../engine_context.js";
3
3
  import { hasCommercialLicense, onLicenseCheckResultChanged, Telemetry } from "../../engine_license.js";
4
4
  import { isLocalNetwork } from "../../engine_networking_utils.js";
5
5
  import { DeviceUtilities, getParam } from "../../engine_utils.js";
6
- import { onXRSessionStart, XRSessionEventArgs } from "../../xr/events.js";
6
+ import { offXRSessionStart, onXRSessionStart, XRSessionEventArgs } from "../../xr/events.js";
7
7
  import { ButtonsFactory } from "../buttons.js";
8
8
  import { ensureFonts, iconFontUrl, loadFont } from "../fonts.js";
9
9
  import { getIconElement } from "../icons.js";
@@ -138,7 +138,9 @@ export class NeedleMenu {
138
138
 
139
139
  /** @ignore internal method */
140
140
  onDestroy() {
141
+ offXRSessionStart(this.onStartXR);
141
142
  window.removeEventListener("message", this.onPostMessage);
143
+ this._menu.dispose();
142
144
  this._menu.remove();
143
145
  this._spatialMenu.onDestroy();
144
146
  }
@@ -338,6 +340,19 @@ export class NeedleMenuElement extends HTMLElement {
338
340
 
339
341
  private _domElement: HTMLElement | null = null;
340
342
  private _context: Context | null = null;
343
+ private _unsubscribeFont?: () => void;
344
+ private _licenseFrame?: number;
345
+ private _unsubscribeLicense?: () => void;
346
+
347
+ /** @internal Release font and license subscriptions when the owning context is destroyed. */
348
+ dispose() {
349
+ this._unsubscribeFont?.();
350
+ this._unsubscribeFont = undefined;
351
+ if (this._licenseFrame !== undefined) window.cancelAnimationFrame(this._licenseFrame);
352
+ this._licenseFrame = undefined;
353
+ this._unsubscribeLicense?.();
354
+ this._unsubscribeLicense = undefined;
355
+ }
341
356
  private _didInitialize = false;
342
357
 
343
358
  constructor() {
@@ -748,7 +763,7 @@ export class NeedleMenuElement extends HTMLElement {
748
763
  // we need to add the icons to both the shadow dom as well as the HEAD to work
749
764
  // https://github.com/google/material-design-icons/issues/1165
750
765
  ensureFonts();
751
- loadFont(iconFontUrl, { loadedCallback: () => { this.handleSizeChange() } });
766
+ this._unsubscribeFont = loadFont(iconFontUrl, { loadedCallback: () => { this.handleSizeChange() } });
752
767
  loadFont(iconFontUrl, { element: shadow });
753
768
 
754
769
  const content = template.content.cloneNode(true) as DocumentFragment;
@@ -777,16 +792,19 @@ export class NeedleMenuElement extends HTMLElement {
777
792
  try {
778
793
  // if the user has a license then we CAN hide the needle logo
779
794
  // calling this method immediately will cause an issue with vite bundling tho
780
- window.requestAnimationFrame(() => onLicenseCheckResultChanged(res => {
781
- if (res == true && hasCommercialLicense() && !debugNonCommercial) {
782
- let visible = this._userRequestedLogoVisible;
783
- if (visible === undefined) visible = false;
784
- this.___onSetLogoVisible(visible);
785
- }
786
- else {
787
- this.___onSetLogoVisible(true);
788
- }
789
- }));
795
+ this._licenseFrame = window.requestAnimationFrame(() => {
796
+ this._licenseFrame = undefined;
797
+ this._unsubscribeLicense = onLicenseCheckResultChanged(res => {
798
+ if (res == true && hasCommercialLicense() && !debugNonCommercial) {
799
+ let visible = this._userRequestedLogoVisible;
800
+ if (visible === undefined) visible = false;
801
+ this.___onSetLogoVisible(visible);
802
+ }
803
+ else {
804
+ this.___onSetLogoVisible(true);
805
+ }
806
+ });
807
+ });
790
808
  } catch (e) {
791
809
  console.error("[Needle Menu] License check failed.", e);
792
810
  }
@@ -19,6 +19,7 @@ export class LoadingElementOptions {
19
19
 
20
20
  /** @internal */
21
21
  export interface ILoadingViewHandler {
22
+ dispose?(): void;
22
23
  onLoadingBegin(message?: string)
23
24
  onLoadingUpdate(progress: LoadingProgressArgs | number, message?: string);
24
25
  onLoadingFinished(message?: string);
@@ -79,6 +80,17 @@ export class EngineLoadingView implements ILoadingViewHandler {
79
80
  this._loadingElementOptions = opts;
80
81
  }
81
82
 
83
+ dispose() {
84
+ // Removal can happen before ready stops the progress interval.
85
+ clearInterval(this._progressLoop);
86
+ this._progressLoop = null;
87
+ this._loadingElement?.remove();
88
+ this._loadingElement = undefined;
89
+ this._loadingTextContainer = null;
90
+ this._loadingBar = null;
91
+ this._messageContainer = null;
92
+ }
93
+
82
94
  async onLoadingBegin(message?: string) {
83
95
  const _element = this._element.shadowRoot || this._element;
84
96
  if (debug) console.warn("Begin Loading")
@@ -402,4 +414,4 @@ export class EngineLoadingView implements ILoadingViewHandler {
402
414
  // nonCommercialContainer.style.transition = "opacity .5s ease-in-out";
403
415
  // nonCommercialContainer.style.opacity = "1";
404
416
  // }
405
- }
417
+ }
@@ -172,6 +172,7 @@ export class NeedleEngineWebComponent extends HTMLElement implements INeedleEngi
172
172
  private _overlay_ar!: AROverlayHandler;
173
173
  private _loadingProgress01: number = 0;
174
174
  private _loadingView?: ILoadingViewHandler;
175
+ private _cancelLoadingBlur?: () => void;
175
176
  private _previousSrc: string | null | string[] = null;
176
177
  /** @private set to true after <needle-engine> did load completely at least once. Set to false when < to false when <needle-engine> is removed from the document removed from the document */
177
178
  private _didFullyLoad: boolean = false;
@@ -335,6 +336,9 @@ export class NeedleEngineWebComponent extends HTMLElement implements INeedleEngi
335
336
  const dispose = keepAlive == undefined || (keepAlive?.length > 0 && keepAlive !== "true" && keepAlive !== "1");
336
337
  if (debug) console.warn("<needle-engine> disconnected, keep-alive: \"" + keepAlive + "\"", typeof keepAlive, "Dispose=", dispose);
337
338
  if (dispose) {
339
+ this._cancelLoadingBlur?.();
340
+ this._cancelLoadingBlur = undefined;
341
+ this._loadingView?.dispose?.();
338
342
  if (debug)
339
343
  console.warn("<needle-engine> dispose");
340
344
  this._context?.dispose();
@@ -583,7 +587,8 @@ export class NeedleEngineWebComponent extends HTMLElement implements INeedleEngi
583
587
 
584
588
 
585
589
  // Experimental loading blur
586
- handleLoadingBlur(this);
590
+ this._cancelLoadingBlur?.();
591
+ this._cancelLoadingBlur = handleLoadingBlur(this);
587
592
 
588
593
  const currentHash = this.getAttribute("hash");
589
594
  if (currentHash !== null && currentHash !== undefined)
@@ -1243,8 +1248,12 @@ function getDisplayName(str: string) {
1243
1248
 
1244
1249
 
1245
1250
  function handleLoadingBlur(needleEngineElement: NeedleEngineWebComponent) {
1246
- onStart((ctx) => {
1247
- const userBlurSetting = needleEngineElement.getAttribute("loading-blur");
1251
+ const userBlurSetting = needleEngineElement.getAttribute("loading-blur");
1252
+ if (userBlurSetting === null || userBlurSetting === "0") return undefined;
1253
+ const unregister = onStart((ctx) => {
1254
+ // A different context starting must not consume this element's callback.
1255
+ if (ctx.domElement !== needleEngineElement) return;
1256
+ unregister();
1248
1257
  if (userBlurSetting !== null && userBlurSetting !== "0") {
1249
1258
  if (ctx.domElement === needleEngineElement) {
1250
1259
 
@@ -1297,5 +1306,6 @@ function handleLoadingBlur(needleEngineElement: NeedleEngineWebComponent) {
1297
1306
  }
1298
1307
  }
1299
1308
  }
1300
- }, { once: true });
1301
- }
1309
+ });
1310
+ return unregister;
1311
+ }