@needle-tools/engine 4.16.10 → 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 (90) hide show
  1. package/CHANGELOG.md +9 -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-a0Pw5htf.js → needle-engine.bundle-BfsnYTeP.js} +7329 -7105
  7. package/dist/{needle-engine.bundle-D_nuVSFb.umd.cjs → needle-engine.bundle-COr8D1vc.umd.cjs} +132 -132
  8. package/dist/{needle-engine.bundle-DlyYXu9v.min.js → needle-engine.bundle-DG1sGkxn.min.js} +131 -131
  9. package/dist/needle-engine.d.ts +60 -3
  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/debug/debug_spector.js +1 -1
  20. package/lib/engine/debug/debug_spector.js.map +1 -1
  21. package/lib/engine/engine_addressables.d.ts +32 -1
  22. package/lib/engine/engine_addressables.js +71 -9
  23. package/lib/engine/engine_addressables.js.map +1 -1
  24. package/lib/engine/engine_application.d.ts +2 -0
  25. package/lib/engine/engine_application.js +7 -3
  26. package/lib/engine/engine_application.js.map +1 -1
  27. package/lib/engine/engine_audio.d.ts +30 -1
  28. package/lib/engine/engine_audio.js +57 -13
  29. package/lib/engine/engine_audio.js.map +1 -1
  30. package/lib/engine/engine_context.js +25 -1
  31. package/lib/engine/engine_context.js.map +1 -1
  32. package/lib/engine/engine_context_registry.js +1 -1
  33. package/lib/engine/engine_context_registry.js.map +1 -1
  34. package/lib/engine/engine_input.js +1 -1
  35. package/lib/engine/engine_input.js.map +1 -1
  36. package/lib/engine/engine_license.d.ts +1 -1
  37. package/lib/engine/engine_license.js +31 -5
  38. package/lib/engine/engine_license.js.map +1 -1
  39. package/lib/engine/engine_lifecycle_functions_internal.js +20 -2
  40. package/lib/engine/engine_lifecycle_functions_internal.js.map +1 -1
  41. package/lib/engine/engine_networking_streams.d.ts +5 -1
  42. package/lib/engine/engine_networking_streams.js +64 -17
  43. package/lib/engine/engine_networking_streams.js.map +1 -1
  44. package/lib/engine/engine_shims.js +1 -1
  45. package/lib/engine/engine_shims.js.map +1 -1
  46. package/lib/engine/physics/workers/mesh-bvh/GenerateMeshBVHWorker.js +5 -0
  47. package/lib/engine/physics/workers/mesh-bvh/GenerateMeshBVHWorker.js.map +1 -1
  48. package/lib/engine/webcomponents/fonts.d.ts +1 -1
  49. package/lib/engine/webcomponents/fonts.js +7 -0
  50. package/lib/engine/webcomponents/fonts.js.map +1 -1
  51. package/lib/engine/webcomponents/icons.js +8 -1
  52. package/lib/engine/webcomponents/icons.js.map +1 -1
  53. package/lib/engine/webcomponents/needle menu/needle-menu.d.ts +5 -0
  54. package/lib/engine/webcomponents/needle menu/needle-menu.js +31 -13
  55. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  56. package/lib/engine/webcomponents/needle-engine.d.ts +1 -0
  57. package/lib/engine/webcomponents/needle-engine.js +16 -4
  58. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  59. package/lib/engine/webcomponents/needle-engine.loading.d.ts +2 -0
  60. package/lib/engine/webcomponents/needle-engine.loading.js +10 -0
  61. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  62. package/lib/engine-components/AudioSource.d.ts +49 -2
  63. package/lib/engine-components/AudioSource.js +261 -66
  64. package/lib/engine-components/AudioSource.js.map +1 -1
  65. package/lib/engine-components/CameraUtils.js +1 -1
  66. package/lib/engine-components/CameraUtils.js.map +1 -1
  67. package/lib/engine-components/postprocessing/Effects/Tonemapping.utils.d.ts +1 -1
  68. package/package.json +2 -2
  69. package/src/engine/api.ts +3 -1
  70. package/src/engine/debug/debug_spector.ts +1 -1
  71. package/src/engine/engine_addressables.ts +75 -9
  72. package/src/engine/engine_application.ts +7 -2
  73. package/src/engine/engine_audio.ts +68 -15
  74. package/src/engine/engine_context.ts +25 -1
  75. package/src/engine/engine_context_registry.ts +1 -1
  76. package/src/engine/engine_input.ts +2 -2
  77. package/src/engine/engine_license.ts +28 -5
  78. package/src/engine/engine_lifecycle_functions_internal.ts +21 -2
  79. package/src/engine/engine_networking_streams.ts +64 -19
  80. package/src/engine/engine_shims.ts +1 -1
  81. package/src/engine/physics/workers/mesh-bvh/GenerateMeshBVHWorker.js +8 -1
  82. package/src/engine/webcomponents/fonts.ts +6 -0
  83. package/src/engine/webcomponents/icons.ts +9 -2
  84. package/src/engine/webcomponents/needle menu/needle-menu.ts +30 -12
  85. package/src/engine/webcomponents/needle-engine.loading.ts +13 -1
  86. package/src/engine/webcomponents/needle-engine.ts +15 -5
  87. package/src/engine-components/AudioSource.ts +244 -63
  88. package/src/engine-components/CameraUtils.ts +1 -1
  89. package/dist/gltf-progressive-BacJPTD6.umd.cjs +0 -10
  90. package/dist/gltf-progressive-BmXoz_HR.min.js +0 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/engine",
3
- "version": "4.16.10",
3
+ "version": "4.16.12",
4
4
  "description": "Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.",
5
5
  "main": "dist/needle-engine.min.js",
6
6
  "exports": {
@@ -114,7 +114,7 @@
114
114
  ],
115
115
  "dependencies": {
116
116
  "@dimforge/rapier3d-compat": "0.19.x",
117
- "@needle-tools/gltf-progressive": "3.4.0-beta.2",
117
+ "@needle-tools/gltf-progressive": "3.4.0-beta.4",
118
118
  "@needle-tools/materialx": "1.4.6",
119
119
  "@needle-tools/three-animation-pointer": "1.0.7",
120
120
  "@webxr-input-profiles/motion-controllers": "^1.0.0",
package/src/engine/api.ts CHANGED
@@ -109,7 +109,9 @@ export * from "./debug/index.js";
109
109
  // ============================================================================
110
110
 
111
111
  /** Addressable asset system for loading assets by address/label */
112
- export * from "./engine_addressables.js";
112
+ // Keep cache disposal internal: closing shared bitmaps can invalidate active consumers.
113
+ export type { ProgressCallback } from "./engine_addressables.js";
114
+ export { Addressables, AssetReference, FileReference, FileReferenceSerializer, ImageReference, ImageReferenceSerializer } from "./engine_addressables.js";
113
115
 
114
116
  /** Animation playback and control utilities */
115
117
  export { AnimationUtils } from "./engine_animation.js";
@@ -18,7 +18,7 @@ declare global {
18
18
 
19
19
 
20
20
  export function initSpectorIfAvailable(_context: Context, canvas: HTMLCanvasElement): void {
21
- if (typeof window !== undefined && window.SPECTOR) {
21
+ if (typeof window !== "undefined" && window.SPECTOR) {
22
22
  console.log(window.SPECTOR);
23
23
  const params = new URLSearchParams(window.location.search);
24
24
  if (params.has("spector")) {
@@ -619,14 +619,36 @@ export class ImageReference {
619
619
 
620
620
  private _bitmap?: Promise<ImageBitmap | null>;
621
621
  private _bitmapObject?: ImageBitmap;
622
+ private _bitmapGeneration = 0;
622
623
 
623
624
  dispose() {
625
+ // Invalidate image loads and decodes that may complete after teardown.
626
+ this._bitmapGeneration++;
624
627
  if (this._bitmapObject) {
625
628
  this._bitmapObject.close();
629
+ // drop the reference too: close() frees the underlying image data, but keeping the
630
+ // object alive keeps the wrapper (and anything reachable from it) in the heap.
631
+ this._bitmapObject = undefined;
626
632
  }
627
633
  this._bitmap = undefined;
628
634
  }
629
635
 
636
+ /**
637
+ * @internal Only used during final context teardown; see {@link clearAssetCaches}.
638
+ * Dispose every cached {@link ImageReference} and empty the cache.
639
+ *
640
+ * The cache is static, so it outlives any {@link Context}: without this, images loaded by a
641
+ * page that is later torn down (e.g. navigating between routes in a SPA) stay in memory for
642
+ * the lifetime of the document, and the heap grows with every navigation.
643
+ * Called automatically once the last context is disposed — see {@link clearAssetCaches}.
644
+ */
645
+ static clearCache() {
646
+ for (const ref of ImageReference.imageReferences.values()) {
647
+ ref.dispose();
648
+ }
649
+ ImageReference.imageReferences.clear();
650
+ }
651
+
630
652
  createHTMLImage(): HTMLImageElement {
631
653
  const img = new Image();
632
654
  img.src = this.url;
@@ -659,22 +681,33 @@ export class ImageReference {
659
681
  // });
660
682
  }
661
683
 
662
- /** Loads the bitmap data of the image */
684
+ /** Loads the bitmap data of the image. Returns null if disposed before loading completes. */
663
685
  getBitmap(): Promise<ImageBitmap | null> {
664
686
  if (this._bitmap) return this._bitmap;
665
- this._bitmap = new Promise((res, _) => {
687
+ const generation = this._bitmapGeneration;
688
+ this._bitmap = new Promise((resolve, reject) => {
666
689
  const imageElement = document.createElement("img") as HTMLImageElement;
667
690
  imageElement.addEventListener("load", () => {
668
- this._bitmap = createImageBitmap(imageElement).then((bitmap) => {
691
+ if (generation !== this._bitmapGeneration) {
692
+ resolve(null);
693
+ return;
694
+ }
695
+ createImageBitmap(imageElement).then(bitmap => {
696
+ if (generation !== this._bitmapGeneration) {
697
+ // The cache no longer owns this request. Do not revive disposed state
698
+ // or overwrite a newer load on a reference that has been reused.
699
+ bitmap.close();
700
+ resolve(null);
701
+ return;
702
+ }
669
703
  this._bitmapObject = bitmap;
670
- res(bitmap);
671
- return bitmap;
672
- })
673
- });
704
+ resolve(bitmap);
705
+ }, reject);
706
+ }, { once: true });
674
707
  imageElement.addEventListener("error", err => {
675
708
  console.error("Failed to load image:" + this.url, err);
676
- res(null);
677
- });
709
+ resolve(null);
710
+ }, { once: true });
678
711
  imageElement.src = this.url;
679
712
  });
680
713
  return this._bitmap;
@@ -748,6 +781,39 @@ export class FileReference {
748
781
  constructor(url: string) {
749
782
  this.url = url;
750
783
  }
784
+
785
+ /** Drop the cached response so its body can be garbage collected. A subsequent load re-fetches. */
786
+ dispose() {
787
+ this.res = undefined;
788
+ }
789
+
790
+ /**
791
+ * @internal Only used during final context teardown; see {@link clearAssetCaches}.
792
+ * Dispose every cached {@link FileReference} and empty the cache.
793
+ *
794
+ * The cache is static, so it outlives any {@link Context}: each entry holds the fetch response
795
+ * for its url, which keeps the file's binary data alive for the lifetime of the document.
796
+ * Called automatically once the last context is disposed — see {@link clearAssetCaches}.
797
+ */
798
+ static clearCache() {
799
+ for (const ref of FileReference.cache.values()) {
800
+ ref.dispose();
801
+ }
802
+ FileReference.cache.clear();
803
+ }
804
+ }
805
+
806
+ /**
807
+ * @internal Clear the shared file and image caches after the last context is disposed.
808
+ *
809
+ * Intentionally not exposed through the public engine API: clearing these caches closes
810
+ * ImageBitmaps already returned to callers. It does not destroy scenes or remove consumers,
811
+ * so calling it while a context is alive can invalidate assets that are still in use.
812
+ * A public API needs resource ownership tracking and defined handling of pending loads first.
813
+ */
814
+ export function clearAssetCaches() {
815
+ FileReference.clearCache();
816
+ ImageReference.clearCache();
751
817
  }
752
818
 
753
819
 
@@ -102,10 +102,15 @@ export class Application extends EventTarget {
102
102
  constructor(context: Context) {
103
103
  super();
104
104
  this.context = context;
105
- window.addEventListener("visibilitychange", this.onVisiblityChanged.bind(this), false);
105
+ window.addEventListener("visibilitychange", this.onVisiblityChanged, false);
106
106
  }
107
107
 
108
- private onVisiblityChanged(evt) {
108
+ /** @internal Remove the global listener when the owning context is disposed. */
109
+ dispose() {
110
+ window.removeEventListener("visibilitychange", this.onVisiblityChanged, false);
111
+ }
112
+
113
+ private onVisiblityChanged = (evt) => {
109
114
  // console.log(evt.target.visibilityState)
110
115
  switch (evt.target.visibilityState) {
111
116
  case "hidden":
@@ -1,25 +1,78 @@
1
1
  import { AudioContext } from "three";
2
2
 
3
3
  import { Application } from "./engine_application.js";
4
+ import { getParam } from "./engine_utils.js";
4
5
 
5
- /**
6
- * @internal
7
- * Ensure the audio context is resumed if it gets suspended or interrupted */
6
+ /** Verbose audio logging, opt-in via `?debugaudio` (same flag AudioSource uses). */
7
+ const debug = getParam("debugaudio");
8
+
9
+ /**
10
+ * iOS Safari exposes a non-standard `"interrupted"` state in addition to the
11
+ * spec'd `AudioContextState`s when the audio session is taken away (e.g. the
12
+ * device is locked or another app starts playing audio).
13
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/state#resuming_interrupted_play_states_in_ios_safari
14
+ */
15
+ export type InterruptibleAudioContextState = AudioContextState | "interrupted";
16
+
17
+ /**
18
+ * @internal
19
+ * Pure policy deciding whether `AudioContext.resume()` should be attempted right now.
20
+ *
21
+ * The context needs resuming when it is `"suspended"` or (on iOS) `"interrupted"`.
22
+ * But resuming is only allowed while the document is **visible**: on iOS Safari,
23
+ * calling `resume()` while the page is backgrounded or the device is locked fails
24
+ * with `InvalidStateError: Failed to start the audio device`, because the audio
25
+ * hardware cannot be started in the background. In that case the resume must be
26
+ * deferred until the page becomes visible again.
27
+ *
28
+ * @param state The current (possibly iOS-interrupted) audio context state.
29
+ * @param visibility The current `document.visibilityState`.
30
+ * @returns `true` if `resume()` should be called now, `false` if it should be deferred.
31
+ */
32
+ export function shouldResumeAudioContext(
33
+ state: InterruptibleAudioContextState,
34
+ visibility: DocumentVisibilityState,
35
+ ): boolean {
36
+ // Resuming while hidden throws InvalidStateError on iOS — defer until visible.
37
+ if (visibility !== "visible") return false;
38
+ return state === "suspended" || state === "interrupted";
39
+ }
40
+
41
+ /** Resume the shared AudioContext if it is suspended/interrupted and the page is visible. */
42
+ function recoverAudioContext() {
43
+ const ctx = AudioContext.getContext();
44
+ const state = ctx.state as InterruptibleAudioContextState;
45
+ const visibility = typeof document !== "undefined" ? document.visibilityState : "visible";
46
+ if (!shouldResumeAudioContext(state, visibility)) return;
47
+ ctx.resume()
48
+ .then(() => { if (debug) console.log("AudioContext resumed successfully"); })
49
+ .catch(e => { if (debug) console.warn("Failed to resume AudioContext: " + e); });
50
+ }
51
+
52
+ /**
53
+ * @internal
54
+ * Keep the shared AudioContext alive across suspends/interruptions (tab switch, device lock, audio
55
+ * session interruption). On returning to the foreground we simply resume() the EXISTING context —
56
+ * the same approach three.js uses, which recovers cleanly because AudioSource plays clips through a
57
+ * MediaElementAudioSourceNode (an <audio> element), whose media session iOS keeps alive across a
58
+ * lock. We deliberately do NOT close/recreate the context: on-device testing showed that makes iOS
59
+ * worse (a recreated context's output stays dead) and it is unnecessary.
60
+ */
8
61
  export function ensureAudioContextIsResumed() {
9
62
  Application.registerWaitForInteraction(() => {
10
- // this is a fix for https://github.com/mrdoob/three.js/issues/27779 & https://linear.app/needle/issue/NE-4257
63
+ // https://github.com/mrdoob/three.js/issues/27779 & https://linear.app/needle/issue/NE-4257
11
64
  const ctx = AudioContext.getContext();
12
- ctx.addEventListener("statechange", () => {
13
- setTimeout(() => {
14
- // on iOS the audiocontext can be interrupted: https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/state#resuming_interrupted_play_states_in_ios_safari
15
- const state = ctx.state as AudioContextState | "interrupted";
16
- if (state === "suspended" || state === "interrupted") {
17
- ctx.resume()
18
- .then(() => { console.log("AudioContext resumed successfully"); })
19
- .catch((e) => { console.log("Failed to resume AudioContext: " + e); });
20
- }
21
- }, 500);
22
- });
65
+ ctx.addEventListener("statechange", () => setTimeout(recoverAudioContext, 300));
66
+
67
+ // iOS frequently rejects the first resume() right after foregrounding with InvalidStateError,
68
+ // then accepts one a few hundred ms later with no follow-up statechange — so retry with a
69
+ // short backoff. Each attempt is a no-op once running.
70
+ if (typeof document !== "undefined") {
71
+ document.addEventListener("visibilitychange", () => {
72
+ if (document.visibilityState !== "visible") return;
73
+ for (const delay of [0, 250, 600, 1200, 2000]) setTimeout(recoverAudioContext, delay);
74
+ });
75
+ }
23
76
  });
24
77
  }
25
78
  setTimeout(ensureAudioContextIsResumed, 1000);
@@ -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;
@@ -1,6 +1,6 @@
1
1
  import { type IComponent, type IContext, type LoadedModel } from "./engine_types.js";
2
2
 
3
- const debug = typeof window !== undefined ? window.location.search.includes("debugcontext") : false;
3
+ const debug = typeof window !== "undefined" ? window.location.search.includes("debugcontext") : false;
4
4
 
5
5
  /** The various events that can be dispatched by a Needle Engine {@link IContext} instance
6
6
  */
@@ -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;
@@ -10,7 +10,7 @@ export declare type OffscreenCanvasExt = OffscreenCanvas & {
10
10
 
11
11
  // REMOVE once usage of browsers that don't support OffscreenCanvas is low
12
12
  // Shim for missing OffscreenCanvas in iOS 16.x
13
- if (typeof globalThis !== undefined && !('OffscreenCanvas' in globalThis)) {
13
+ if (typeof globalThis !== "undefined" && !('OffscreenCanvas' in globalThis)) {
14
14
  // @ts-ignore
15
15
  globalThis['OffscreenCanvas'] = class OffscreenCanvas {
16
16
  canvas: HTMLCanvasElement;
@@ -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