@needle-tools/engine 5.1.0-alpha.8 → 5.1.0-alpha.9

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 (111) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/components.needle.json +1 -1
  3. package/dist/{materialx-CE2sUv2B.umd.cjs → materialx-B47Bz-xs.umd.cjs} +1 -1
  4. package/dist/{materialx-u1EqYrhu.min.js → materialx-B85WjP7E.min.js} +1 -1
  5. package/dist/{materialx-vyB2Zbt4.js → materialx-jkHmVPez.js} +1 -1
  6. package/dist/{needle-engine.bundle-BuI2pjv1.min.js → needle-engine.bundle-BotdnwVF.min.js} +172 -172
  7. package/dist/{needle-engine.bundle-DQy9vQdJ.umd.cjs → needle-engine.bundle-DagaNrZK.umd.cjs} +145 -145
  8. package/dist/{needle-engine.bundle-3LOy9tj8.js → needle-engine.bundle-mtqHZ45d.js} +6580 -6392
  9. package/dist/needle-engine.d.ts +295 -23
  10. package/dist/needle-engine.js +592 -582
  11. package/dist/needle-engine.min.js +1 -1
  12. package/dist/needle-engine.umd.cjs +1 -1
  13. package/dist/{vendor-ButPLzor.umd.cjs → vendor-1UvpPPSB.umd.cjs} +2 -2
  14. package/dist/{vendor-DqZC4Is7.min.js → vendor-BQ2Vuntm.min.js} +4 -4
  15. package/dist/{vendor-DkWSNjMV.js → vendor-BslSKZPo.js} +113 -106
  16. package/lib/engine/api.d.ts +1 -1
  17. package/lib/engine/api.js +1 -1
  18. package/lib/engine/api.js.map +1 -1
  19. package/lib/engine/engine_audio.d.ts +30 -1
  20. package/lib/engine/engine_audio.js +57 -13
  21. package/lib/engine/engine_audio.js.map +1 -1
  22. package/lib/engine/engine_init.js +2 -2
  23. package/lib/engine/engine_init.js.map +1 -1
  24. package/lib/engine/engine_license.d.ts +7 -7
  25. package/lib/engine/engine_license.js +72 -72
  26. package/lib/engine/engine_license.js.map +1 -1
  27. package/lib/engine/engine_math.d.ts +1 -1
  28. package/lib/engine/engine_math.js +10 -4
  29. package/lib/engine/engine_math.js.map +1 -1
  30. package/lib/engine/engine_networking_blob.js +3 -3
  31. package/lib/engine/engine_networking_blob.js.map +1 -1
  32. package/lib/engine/engine_scenedata.js +7 -0
  33. package/lib/engine/engine_scenedata.js.map +1 -1
  34. package/lib/engine/engine_serialization_core.js +7 -2
  35. package/lib/engine/engine_serialization_core.js.map +1 -1
  36. package/lib/engine/engine_time_utils.d.ts +10 -6
  37. package/lib/engine/engine_time_utils.js +10 -6
  38. package/lib/engine/engine_time_utils.js.map +1 -1
  39. package/lib/engine/engine_utils_qrcode.js +2 -2
  40. package/lib/engine/engine_utils_qrcode.js.map +1 -1
  41. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  42. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
  43. package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
  44. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  45. package/lib/engine/webcomponents/needle-engine.js +2 -2
  46. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  47. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  48. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  49. package/lib/engine/xr/TempXRContext.js +2 -2
  50. package/lib/engine/xr/TempXRContext.js.map +1 -1
  51. package/lib/engine-components/AudioSource.d.ts +46 -2
  52. package/lib/engine-components/AudioSource.js +268 -60
  53. package/lib/engine-components/AudioSource.js.map +1 -1
  54. package/lib/engine-components/DragControls.js +2 -2
  55. package/lib/engine-components/DragControls.js.map +1 -1
  56. package/lib/engine-components/DragControlsConstraints.d.ts +0 -15
  57. package/lib/engine-components/DragControlsConstraints.js +14 -8
  58. package/lib/engine-components/DragControlsConstraints.js.map +1 -1
  59. package/lib/engine-components/EventList.js +1 -1
  60. package/lib/engine-components/EventList.js.map +1 -1
  61. package/lib/engine-components/SceneSwitcher.js +17 -2
  62. package/lib/engine-components/SceneSwitcher.js.map +1 -1
  63. package/lib/engine-components/codegen/components.d.ts +10 -0
  64. package/lib/engine-components/codegen/components.js +10 -0
  65. package/lib/engine-components/codegen/components.js.map +1 -1
  66. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  67. package/lib/engine-components/export/usdz/USDZExporter.js.map +1 -1
  68. package/lib/engine-components/timeline/PlayableDirector.js +1 -1
  69. package/lib/engine-components/timeline/PlayableDirector.js.map +1 -1
  70. package/lib/engine-components/web/ScrollFollow.d.ts +33 -0
  71. package/lib/engine-components/web/ScrollFollow.js +164 -22
  72. package/lib/engine-components/web/ScrollFollow.js.map +1 -1
  73. package/lib/needle-app.d.ts +18 -0
  74. package/lib/needle-app.js +19 -0
  75. package/lib/needle-app.js.map +1 -0
  76. package/package.json +3 -2
  77. package/plugins/common/license.js +25 -4
  78. package/plugins/common/needle-engine.js +5 -2
  79. package/plugins/types/userconfig.d.ts +22 -0
  80. package/plugins/vite/dts-generator.d.ts +24 -0
  81. package/plugins/vite/dts-generator.js +40 -7
  82. package/plugins/vite/index.d.ts +2 -0
  83. package/plugins/vite/index.js +17 -0
  84. package/plugins/vite/license.js +4 -4
  85. package/plugins/vite/needle-app.js +78 -5
  86. package/plugins/vite/remote.d.ts +28 -0
  87. package/plugins/vite/remote.js +124 -0
  88. package/src/engine/api.ts +1 -1
  89. package/src/engine/engine_audio.ts +68 -15
  90. package/src/engine/engine_init.ts +2 -2
  91. package/src/engine/engine_license.ts +69 -69
  92. package/src/engine/engine_math.ts +11 -4
  93. package/src/engine/engine_networking_blob.ts +3 -3
  94. package/src/engine/engine_scenedata.ts +7 -2
  95. package/src/engine/engine_serialization_core.ts +7 -2
  96. package/src/engine/engine_time_utils.ts +10 -6
  97. package/src/engine/engine_utils_qrcode.ts +2 -2
  98. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  99. package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
  100. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  101. package/src/engine/webcomponents/needle-engine.ts +2 -2
  102. package/src/engine/xr/TempXRContext.ts +2 -2
  103. package/src/engine-components/AudioSource.ts +256 -55
  104. package/src/engine-components/DragControls.ts +3 -2
  105. package/src/engine-components/DragControlsConstraints.ts +16 -8
  106. package/src/engine-components/EventList.ts +1 -1
  107. package/src/engine-components/SceneSwitcher.ts +14 -2
  108. package/src/engine-components/codegen/components.ts +10 -0
  109. package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
  110. package/src/engine-components/timeline/PlayableDirector.ts +1 -1
  111. package/src/engine-components/web/ScrollFollow.ts +168 -22
@@ -1,4 +1,4 @@
1
- import { AudioLoader, PositionalAudio } from "three";
1
+ import { PositionalAudio } from "three";
2
2
  import { PositionalAudioHelper } from 'three/examples/jsm/helpers/PositionalAudioHelper.js';
3
3
 
4
4
  import { isDevEnvironment } from "../engine/debug/index.js";
@@ -136,7 +136,13 @@ export class AudioSource extends Behaviour {
136
136
  *
137
137
  * @returns True if the audio is playing, false otherwise
138
138
  */
139
- get isPlaying(): boolean { return this.sound?.isPlaying ?? false; }
139
+ get isPlaying(): boolean {
140
+ // For the media-element path (string clips and MediaStreams) three.js does NOT maintain
141
+ // sound.isPlaying (hasPlaybackControl is false once a media/stream source is set), so the
142
+ // <audio> element is the source of truth.
143
+ if (this._audioElement) return !this._audioElement.paused && !this._audioElement.ended;
144
+ return this.sound?.isPlaying ?? false;
145
+ }
140
146
 
141
147
  /**
142
148
  * The total duration of the currently loaded audio clip in seconds.
@@ -145,11 +151,14 @@ export class AudioSource extends Behaviour {
145
151
  * @remarks For MediaStream clips, duration is not directly available and will return undefined. If the audio clip has not started loading or is still loading, duration may also be undefined until the audio buffer is ready.
146
152
  */
147
153
  get duration() {
148
- if (this.sound?.buffer?.duration) {
149
- return this.sound.buffer.duration;
154
+ // Media-element path is the primary source of truth for string clips.
155
+ // (NaN/Infinity for MediaStream or before metadata is loaded → treated as "unknown".)
156
+ const elDuration = this._usesMediaElementSource ? this._audioElement?.duration : undefined;
157
+ if (elDuration && isFinite(elDuration)) {
158
+ return elDuration;
150
159
  }
151
- else if (this._audioElement?.duration) {
152
- return this._audioElement.duration;
160
+ else if (this.sound?.buffer?.duration) {
161
+ return this.sound.buffer.duration;
153
162
  }
154
163
  else if (this._loadedClip instanceof MediaStream) {
155
164
  // MediaStream duration is not directly available; return undefined or estimate if possible
@@ -164,14 +173,14 @@ export class AudioSource extends Behaviour {
164
173
  */
165
174
  get time01() {
166
175
  const duration = this.duration;
167
- if (duration && this.sound) {
168
- return this.sound?.context.currentTime / duration;
176
+ if (duration) {
177
+ return this.time / duration;
169
178
  }
170
179
  return 0;
171
180
  }
172
181
  set time01(val: number) {
173
182
  const duration = this.duration;
174
- if (duration && this.sound) {
183
+ if (duration) {
175
184
  this.time = val * duration;
176
185
  }
177
186
  }
@@ -180,8 +189,17 @@ export class AudioSource extends Behaviour {
180
189
  * The current playback position in seconds.
181
190
  * Can be set to seek to a specific time in the audio.
182
191
  */
183
- get time(): number { return this.sound?.source ? (this.sound.source?.context.currentTime - this._lastContextTime + this.sound.offset) : 0; }
192
+ get time(): number {
193
+ // Media-element path: the element's currentTime is authoritative.
194
+ if (this._usesMediaElementSource && this._audioElement) return this._audioElement.currentTime;
195
+ return this.sound?.source ? (this.sound.source?.context.currentTime - this._lastContextTime + this.sound.offset) : 0;
196
+ }
184
197
  set time(val: number) {
198
+ // Media-element path: seek directly on the element (sound.offset is not used for media nodes).
199
+ if (this._usesMediaElementSource && this._audioElement) {
200
+ this._audioElement.currentTime = val;
201
+ return;
202
+ }
185
203
  if (this.sound) {
186
204
  if (val === this.sound.offset) return;
187
205
  const wasPlaying = this.isPlaying;
@@ -199,12 +217,19 @@ export class AudioSource extends Behaviour {
199
217
  */
200
218
  @serializable()
201
219
  get loop(): boolean {
220
+ // Media-element path: the element holds the authoritative loop flag (sound.getLoop()
221
+ // warns and returns false once hasPlaybackControl is false).
222
+ if (this._usesMediaElementSource && this._audioElement) {
223
+ this._loop = this._audioElement.loop;
224
+ return this._loop;
225
+ }
202
226
  if (this.sound) this._loop = this.sound.getLoop();
203
227
  return this._loop;
204
228
  }
205
229
  set loop(val: boolean) {
206
230
  this._loop = val;
207
- if (this.sound) this.sound.setLoop(val);
231
+ if (this._usesMediaElementSource && this._audioElement) this._audioElement.loop = val;
232
+ else if (this.sound) this.sound.setLoop(val);
208
233
  }
209
234
 
210
235
  /**
@@ -279,9 +304,13 @@ export class AudioSource extends Behaviour {
279
304
  */
280
305
  @serializable()
281
306
  set pitch(val: number) {
282
- if (this.sound) this.sound.setPlaybackRate(val);
307
+ // Media-element path: playbackRate on the element (sound.setPlaybackRate warns and is a
308
+ // no-op once hasPlaybackControl is false).
309
+ if (this._usesMediaElementSource && this._audioElement) this._audioElement.playbackRate = val;
310
+ else if (this.sound) this.sound.setPlaybackRate(val);
283
311
  }
284
312
  get pitch(): number {
313
+ if (this._usesMediaElementSource && this._audioElement) return this._audioElement.playbackRate;
285
314
  return this.sound ? this.sound.getPlaybackRate() : 1;
286
315
  }
287
316
 
@@ -298,13 +327,20 @@ export class AudioSource extends Behaviour {
298
327
  private sound: PositionalAudio | null = null;
299
328
  private helper: PositionalAudioHelper | null = null;
300
329
  private wasPlaying = false;
301
- private audioLoader: AudioLoader | null = null;
302
330
  private shouldPlay: boolean = false;
303
331
  // set this from audio context time, used to set clip offset when setting "time" property
304
332
  // there is maybe a better way to set a audio clip current time?!
305
- private _lastClipStartedLoading: string | MediaStream | null = null;
306
333
  private _loadedClip: string | MediaStream | null = null;
307
334
  private _audioElement: HTMLAudioElement | null = null;
335
+ /**
336
+ * True when {@link _audioElement} is a file-URL element routed through
337
+ * `sound.setMediaElementSource()` (the iOS-survivable string-clip path). For this path ALL
338
+ * playback control (play/pause/stop/loop/pitch/time/isPlaying) goes through the element,
339
+ * because three.js sets `hasPlaybackControl = false` once a media source is attached.
340
+ * It is `false` for the distinct MediaStream path (which uses an element with `srcObject`
341
+ * plus `setMediaStreamSource()` and is controlled differently).
342
+ */
343
+ private _usesMediaElementSource: boolean = false;
308
344
 
309
345
  // Spatial blend dual-path audio nodes
310
346
  private _entryNode: GainNode | null = null;
@@ -352,18 +388,49 @@ export class AudioSource extends Behaviour {
352
388
  return this.sound?.context;
353
389
  }
354
390
 
391
+ /**
392
+ * Resumes the shared AudioContext if it is currently suspended/interrupted and the
393
+ * page is visible. Uses the same policy as the global resume handler so this never
394
+ * triggers the iOS `InvalidStateError: Failed to start the audio device` (which
395
+ * happens when resuming while the page is hidden). No-op when the context is already
396
+ * running or when resuming is not currently allowed.
397
+ */
398
+ private ensureContextResumed() {
399
+ const ctx = this.audioContext;
400
+ if (!ctx) return;
401
+ const visibility = typeof document !== "undefined" ? document.visibilityState : "visible";
402
+ // Only skip while hidden — resuming a hidden context throws iOS InvalidStateError.
403
+ if (visibility !== "visible") return;
404
+ // Resume even when ctx.state already reports "running". On iOS an auto-resume that
405
+ // happened OUTSIDE a user gesture (e.g. our visibilitychange retry) can leave the
406
+ // context reporting "running" while the audio HARDWARE stays off ("zombie" context).
407
+ // play() is user-initiated, so this resume() runs inside the gesture and re-enables
408
+ // actual output. It is a harmless no-op on an already-healthy context.
409
+ // (The global handler in engine_audio.ts still uses shouldResumeAudioContext, because
410
+ // it fires on timers/events with no gesture and must not spam resume on a running ctx.)
411
+ ctx.resume().catch(e => { if (debug) console.warn("[AudioSource] Failed to resume AudioContext:", e); });
412
+ }
413
+
355
414
  /** @internal */
356
415
  awake() {
357
416
  if (debug) console.log("[AudioSource]", this);
358
417
 
359
- this.audioLoader = new AudioLoader();
360
418
  if (this.playOnAwake) this.shouldPlay = true;
361
419
 
362
420
  if (this.preload) {
363
421
  if (typeof this.clip === "string" && this.clip.length > 0) {
364
- this.audioLoader.load(this.clip, this.createAudio, () => { }, e => {
365
- console.error(`[AudioSource] Error preloading audio clip "${this.clip}":`, e);
366
- });
422
+ // Preload = create the <audio> element and wire it into the Web Audio graph, but do
423
+ // NOT play. Wiring needs the PositionalAudio (Sound), which in turn needs an
424
+ // AudioListener that only exists after a user gesture — so defer when not ready.
425
+ if (this.Sound) {
426
+ this.createAudioFromElement();
427
+ }
428
+ else {
429
+ AudioSource.registerWaitForAllowAudio(() => {
430
+ if (this.destroyed || this.enabled === false) return;
431
+ if (typeof this.clip === "string" && this.clip.length > 0) this.createAudioFromElement();
432
+ });
433
+ }
367
434
  }
368
435
  }
369
436
  }
@@ -397,15 +464,23 @@ export class AudioSource extends Behaviour {
397
464
  switch (document.visibilityState) {
398
465
  case "hidden":
399
466
  if (this.playInBackground === false || DeviceUtilities.isMobileDevice()) {
400
- this.wasPlaying = this.isPlaying;
401
- if (this.isPlaying) {
467
+ const wasPlaying = this.isPlaying;
468
+ if (wasPlaying) {
402
469
  this.pause();
403
470
  }
471
+ // pause() clears wasPlaying so a *user-initiated* pause is not auto-resumed on
472
+ // the next foreground. This pause is automatic (page hidden / device locked), so
473
+ // re-assert the resume intent AFTER pause() — otherwise we never resume on unlock.
474
+ this.wasPlaying = wasPlaying;
404
475
  }
405
476
  break;
406
477
  case "visible":
407
478
  if (debug) console.log("visible", this.enabled, this.playOnAwake, !this.isPlaying, AudioSource.userInteractionRegistered, this.wasPlaying);
408
- if (this.enabled && this.playOnAwake && !this.isPlaying && AudioSource.userInteractionRegistered && this.wasPlaying) {
479
+ // Resume anything that was actively playing when the page was hidden — not
480
+ // just playOnAwake sources. `wasPlaying` is the correct signal: it is only
481
+ // set when this source was playing at hide time, so script-started clips
482
+ // resume too (previously they stayed silent until a manual play()).
483
+ if (this.enabled && !this.isPlaying && AudioSource.userInteractionRegistered && this.wasPlaying) {
409
484
  this.play();
410
485
  }
411
486
  break;
@@ -419,13 +494,89 @@ export class AudioSource extends Behaviour {
419
494
  this.sound?.setVolume(this.volume);
420
495
  }
421
496
 
422
- private createAudio = (buffer?: AudioBuffer) => {
497
+ /**
498
+ * Sets up playback of a string (file URL) clip through an `HTMLAudioElement` routed into the
499
+ * Web Audio graph via `sound.setMediaElementSource()`.
500
+ *
501
+ * Why a media element instead of a decoded `AudioBuffer`: on iOS Safari an
502
+ * `AudioBufferSourceNode` has its output torn down after a device lock / audio-session
503
+ * interruption and cannot be reliably revived. A `MediaElementAudioSourceNode` survives the
504
+ * lock because the playing `<audio>` element keeps iOS's media session alive, so the shared
505
+ * AudioContext is never torn down and resumes cleanly. This is the same approach three.js's own
506
+ * `webaudio_orientation` example uses.
507
+ *
508
+ * This does NOT start playback — it only creates/wires the element so a later `.play()` (driven
509
+ * by a user gesture) is cheap.
510
+ */
511
+ private createAudioFromElement() {
423
512
  if (this.destroyed) {
424
513
  if (debug) console.warn("AudioSource destroyed, not creating audio", this.name);
425
514
  return;
426
515
  }
516
+ const sound = this.Sound;
517
+ if (!sound) {
518
+ if (debug) console.warn("Failed getting sound?", this.name);
519
+ return;
520
+ }
521
+
522
+ // If the element is already wired for the CURRENT clip, there is nothing to (re)create —
523
+ // re-running setMediaElementSource on the same element throws InvalidStateError
524
+ // ("already connected to a MediaElementSourceNode"), so bail out early.
525
+ if (this._audioElement && this._usesMediaElementSource && this._loadedClip === this.clip) {
526
+ // keep volume/mute in sync, then we're done
527
+ if (this.context.application.muted) sound.setVolume(0);
528
+ else sound.setVolume(this.volume);
529
+ return;
530
+ }
531
+
532
+ // (re)create the element when the clip changed since the element was wired.
533
+ if (this._audioElement && this._loadedClip !== this.clip) {
534
+ this._audioElement.pause();
535
+ this._audioElement = null;
536
+ this._usesMediaElementSource = false;
537
+ }
538
+ const created = !this._audioElement;
539
+ if (created) {
540
+ const el = new Audio();
541
+ el.crossOrigin = "anonymous";
542
+ el.preload = "auto";
543
+ el.src = this.clip as string;
544
+ this._audioElement = el;
545
+ }
546
+ this._audioElement!.loop = this._loop;
547
+
548
+ // IMPORTANT: build the spatial-blend graph FIRST so getOutput() returns the entry node,
549
+ // THEN route the media element source into it. setMediaElementSource() immediately calls
550
+ // sound.connect() (source → getOutput()), so the entry node and the getOutput override
551
+ // must already exist. Order matters.
552
+ this.setupSpatialBlendNodes();
553
+ // Only connect once per element — createMediaElementSource() throws if the same
554
+ // HTMLMediaElement is passed to it twice.
555
+ if (!this._usesMediaElementSource) {
556
+ sound.setMediaElementSource(this._audioElement!);
557
+ this._usesMediaElementSource = true;
558
+ }
559
+ this._loadedClip = this.clip;
427
560
 
428
- if (debug) console.log("AudioBuffer finished loading", buffer);
561
+ if (this.context.application.muted) sound.setVolume(0);
562
+ else sound.setVolume(this.volume);
563
+ this.applySpatialDistanceSettings();
564
+
565
+ // Playback is started explicitly by onNewClip()/play()/onEnable — do NOT auto-play here.
566
+ // Registering __onAllowAudioCallback would (once the user has interacted) fire immediately
567
+ // and call play() a SECOND time, restarting the element → audible "clipping repeat".
568
+ }
569
+
570
+ /**
571
+ * Sets up the {@link PositionalAudio} graph for a MediaStream clip (used for the MediaStream
572
+ * path only — string clips go through {@link createAudioFromElement}). The actual stream node
573
+ * is attached in {@link play} via `setMediaStreamSource`.
574
+ */
575
+ private createAudio = () => {
576
+ if (this.destroyed) {
577
+ if (debug) console.warn("AudioSource destroyed, not creating audio", this.name);
578
+ return;
579
+ }
429
580
 
430
581
  const sound = this.Sound;
431
582
  if (!sound) {
@@ -433,21 +584,12 @@ export class AudioSource extends Behaviour {
433
584
  return;
434
585
  }
435
586
 
436
- if (sound.isPlaying)
437
- sound.stop();
438
-
439
- if (buffer) sound.setBuffer(buffer);
440
587
  this._loadedClip = this.clip;
441
- sound.loop = this._loop;
442
588
  if (this.context.application.muted) sound.setVolume(0);
443
589
  else sound.setVolume(this.volume);
444
- sound.autoplay = this.shouldPlay && AudioSource.userInteractionRegistered;
445
590
  this.setupSpatialBlendNodes();
446
591
  this.applySpatialDistanceSettings();
447
592
 
448
- if (sound.isPlaying)
449
- sound.stop();
450
-
451
593
  // make sure we only play the sound if the user has interacted with the page
452
594
  AudioSource.registerWaitForAllowAudio(this.__onAllowAudioCallback);
453
595
  }
@@ -564,23 +706,21 @@ export class AudioSource extends Behaviour {
564
706
  if (debug)
565
707
  console.log(clip);
566
708
  if (clip.endsWith(".mp3") || clip.endsWith(".wav") || clip.endsWith(".ogg") || clip.endsWith(".opus") || clip.endsWith(".flac") || clip.endsWith(".aac") || clip.endsWith(".webm")) {
567
- if (!this.audioLoader)
568
- this.audioLoader = new AudioLoader();
569
709
  this.shouldPlay = true;
570
- if (this._lastClipStartedLoading === clip) {
571
- if (debug) console.log("Is currently loading:", this._lastClipStartedLoading, this)
572
- return;
710
+ // Route the file URL through an <audio> element (survives iOS lock — see
711
+ // createAudioFromElement). This creates/wires the element without playing.
712
+ this.createAudioFromElement();
713
+ // Start playback through the element (NOT this.sound — hasPlaybackControl is false
714
+ // for a media-element source). __onAllowAudioCallback would also play once
715
+ // interaction is registered; this covers the already-interacted case directly.
716
+ if (this.shouldPlay && AudioSource.userInteractionRegistered) {
717
+ this.ensureContextResumed();
718
+ this._hasEnded = false;
719
+ this._audioElement?.play().catch(e => {
720
+ if (debug) console.warn(`[AudioSource] Failed to play audio clip "${clip}":`, e);
721
+ });
722
+ this.fadeEntryGain(1); // fade in (no-op on first play; undoes a prior pause() fade)
573
723
  }
574
- this._lastClipStartedLoading = clip;
575
- if (debug)
576
- console.log("load audio", clip);
577
- const buffer = await this.audioLoader.loadAsync(clip).catch(e => {
578
- console.error(`[AudioSource] Error loading audio clip "${clip}":`, e);
579
- return null;
580
- });
581
- if (this.destroyed) return;
582
- if (this._lastClipStartedLoading === clip) this._lastClipStartedLoading = null;
583
- if (buffer) this.createAudio(buffer);
584
724
  }
585
725
  else console.warn("Unsupported audio clip type", clip)
586
726
  }
@@ -612,9 +752,12 @@ export class AudioSource extends Behaviour {
612
752
 
613
753
  // Load if the sound hasn't been created yet or if the clip changed since last load
614
754
  let needsLoading = !this.sound || (clip && clip !== this._loadedClip);
615
- if (typeof clip === "string" && !this.audioLoader) needsLoading = true;
755
+ // For string clips the <audio> element must additionally be wired (createAudioFromElement);
756
+ // if it isn't yet (e.g. preload was off, or the element was torn down), force a (re)load.
757
+ if (typeof clip === "string" && (!this._audioElement || !this._usesMediaElementSource)) needsLoading = true;
616
758
  if (clip instanceof MediaStream || typeof clip === "string")
617
759
  this.clip = clip;
760
+
618
761
  if (needsLoading) {
619
762
  this.shouldPlay = true;
620
763
  return this.onNewClip(clip).then(() => true).catch(() => false);
@@ -625,16 +768,23 @@ export class AudioSource extends Behaviour {
625
768
 
626
769
  if (debug) console.log("[AudioSource] play", this.sound?.getVolume(), this.sound);
627
770
  // If a different clip was passed in, needsLoading above would be true and onNewClip handles it.
628
- // This guard prevents double-playing the same already-playing sound (which would throw in three.js).
629
- if (this.sound && !this.sound.isPlaying) {
771
+ // This guard prevents double-playing the same already-playing sound.
772
+ if (this.sound && !this.isPlaying) {
773
+ // On iOS the shared context can be suspended/interrupted (device locked, app
774
+ // backgrounded, audio session interrupted by a call) — starting playback on it produces
775
+ // no sound until it is resumed. Resume it here (we are inside a user gesture) so playback
776
+ // does not silently depend on the global resume handler running first. See engine_audio.ts.
777
+ this.ensureContextResumed();
630
778
  const muted = this.context.application.muted;
631
779
  if (muted) this.sound.setVolume(0);
632
780
  this.gameObject?.add(this.sound);
633
781
 
634
782
  if (this.clip instanceof MediaStream) {
635
783
 
636
- // We have to set the audio element source to the mediastream as well
637
- // otherwise it will not play for some reason...
784
+ // Distinct from the file-URL media-element path: a MediaStream uses
785
+ // setMediaStreamSource + an element holding srcObject (the element is only needed
786
+ // so the stream actually plays — playback is not controlled through it here).
787
+ this._usesMediaElementSource = false;
638
788
  this.sound.setMediaStreamSource(this.clip);
639
789
 
640
790
  if (!this._audioElement) {
@@ -649,8 +799,15 @@ export class AudioSource extends Behaviour {
649
799
 
650
800
  }
651
801
  else {
652
- if (this._audioElement) this._audioElement.remove();
653
- this.sound.play(muted ? .1 : 0);
802
+ // File-URL media-element path: playback control is on the element, not this.sound
803
+ // (hasPlaybackControl is false once setMediaElementSource was called). Volume/mute
804
+ // is still applied via sound.setVolume() above (the gain node still works).
805
+ // Resume the element from its (frozen) position, then fade the entry gain back in so
806
+ // the resume is click-free. No seek, no graph reconnect — both caused the stutter.
807
+ this._audioElement?.play().catch(e => {
808
+ if (debug) console.warn("[AudioSource] Failed to play audio element:", e);
809
+ });
810
+ this.fadeEntryGain(1);
654
811
  return true;
655
812
  }
656
813
  }
@@ -658,6 +815,23 @@ export class AudioSource extends Behaviour {
658
815
  return false;
659
816
  }
660
817
 
818
+ /**
819
+ * Smoothly ramp the spatial-graph entry-gain node to `target` (1 = audible, 0 = silent) over a
820
+ * few ms. A short *fade* — not a hard 0/1 gain change and not a connect/disconnect — is what
821
+ * avoids the click on pause/resume (an instant change or a graph (dis)connect steps the waveform
822
+ * → audible click). On iOS the MediaElementAudioSourceNode keeps feeding the graph after
823
+ * element.pause() following an interruption, so fading this gain to 0 is what actually silences a
824
+ * "paused" media clip; element.pause() only freezes its playback position.
825
+ */
826
+ private fadeEntryGain(target: number, seconds = 0.02) {
827
+ if (!this._entryNode || !this.sound) return;
828
+ const ctx = this.sound.context;
829
+ const g = this._entryNode.gain;
830
+ g.cancelScheduledValues(ctx.currentTime);
831
+ g.setValueAtTime(g.value, ctx.currentTime);
832
+ g.linearRampToValueAtTime(target, ctx.currentTime + seconds);
833
+ }
834
+
661
835
  /**
662
836
  * Pauses audio playback while maintaining the current position.
663
837
  * Use play() to resume from the paused position.
@@ -666,10 +840,24 @@ export class AudioSource extends Behaviour {
666
840
  if (debug) console.log("[AudioSource] pause", this);
667
841
  this._hasEnded = true;
668
842
  this.shouldPlay = false;
843
+ // A user-initiated pause must not be revived by the visibility handler on the next
844
+ // foreground (it replays sources whose `wasPlaying` is set).
845
+ this.wasPlaying = false;
846
+ if (this._usesMediaElementSource) {
847
+ // File-URL media-element path. On iOS the MediaElementAudioSourceNode keeps emitting into
848
+ // the Web Audio graph after element.pause() once the audio session has been interrupted
849
+ // (lock/unlock) — pausing the element does NOT silence it (confirmed on-device). So we
850
+ // fade the entry gain to 0 (click-free silence — the fade is what actually silences it),
851
+ // then freeze the element's position. play() fades it back in.
852
+ this.fadeEntryGain(0);
853
+ this._audioElement?.pause();
854
+ return;
855
+ }
669
856
  if (this.sound && this.sound.isPlaying && this.sound.source) {
670
857
  this._lastContextTime = this.sound?.context.currentTime;
671
858
  this.sound.pause();
672
859
  }
860
+ // MediaStream / legacy path: drop the stream element as before.
673
861
  this._audioElement?.remove();
674
862
  }
675
863
 
@@ -681,12 +869,22 @@ export class AudioSource extends Behaviour {
681
869
  if (debug) console.log("[AudioSource] stop", this);
682
870
  this._hasEnded = true;
683
871
  this.shouldPlay = false;
872
+ this.wasPlaying = false;
873
+ if (this._usesMediaElementSource) {
874
+ // True stop: detach the source from the graph (see pause() — element.pause() alone does
875
+ // not silence it on iOS after an interruption), pause, and rewind to the start.
876
+ this.fadeEntryGain(0);
877
+ this._audioElement?.pause();
878
+ if (this._audioElement) this._audioElement.currentTime = 0;
879
+ return;
880
+ }
684
881
  if (this.sound && this.sound.source) {
685
882
  this._lastContextTime = this.sound?.context.currentTime;
686
883
  if (debug)
687
884
  console.log("[AudioSource] lastContextTime", this._lastContextTime);
688
885
  this.sound.stop();
689
886
  }
887
+ // MediaStream / legacy path: drop the stream element as before.
690
888
  this._audioElement?.remove();
691
889
  }
692
890
 
@@ -707,7 +905,10 @@ export class AudioSource extends Behaviour {
707
905
  this.applySpatialDistanceSettings();
708
906
  }
709
907
 
710
- if (this.sound && !this.sound.isPlaying && this.shouldPlay && !this._hasEnded) {
908
+ // Use the element-aware `isPlaying` getter, NOT `sound.isPlaying`: for a media-element
909
+ // source three.js leaves `sound.isPlaying` permanently false (hasPlaybackControl=false),
910
+ // which would otherwise fire "ended" every frame during playback.
911
+ if (this.sound && !this.isPlaying && this.shouldPlay && !this._hasEnded) {
711
912
  this._hasEnded = true;
712
913
  if (debug) console.log("[AudioSource] Audio clip ended", this.clip);
713
914
  this.dispatchEvent(new CustomEvent("ended", { detail: this }));
@@ -113,6 +113,7 @@ export class DragProfile {
113
113
  * @see {@link SyncedTransform} for networked dragging
114
114
  * @see {@link ObjectRaycaster} for pointer detection
115
115
  */
116
+
116
117
  export class DragControls extends Behaviour implements IPointerEventHandler {
117
118
 
118
119
  /**
@@ -797,8 +798,8 @@ class MultiTouchDragHandler implements IDragHandler {
797
798
  private _currentScaleRatio: number = 1;
798
799
  /** World-scale of the dragged object captured at drag start. Used to apply scale directly. */
799
800
  private readonly _initialWorldScale: Vector3 = new Vector3(1, 1, 1);
800
- /** Clamps the dragged object's world scale during two-pointer scaling. Defaults to [0.01, 10000] relative to initial scale. */
801
- private readonly _scaleLimitConstraint: ScaleLimitConstraint = new ScaleLimitConstraint(0.01, 10000, true);
801
+ /** Clamps the dragged object's world scale during two-pointer scaling. */
802
+ private readonly _scaleLimitConstraint: ScaleLimitConstraint = new ScaleLimitConstraint(0.0001, 1_000_000, true);
802
803
 
803
804
  private _tempVec1: Vector3 = new Vector3();
804
805
  private _tempVec2: Vector3 = new Vector3();
@@ -163,6 +163,8 @@ export class KeepScaleConstraint implements IDragConstraint {
163
163
  * new ScaleLimitConstraint(0.01, 10000, true, object.worldScale.clone());
164
164
  * ```
165
165
  */
166
+ const _scaleLimitWs = new Vector3();
167
+
166
168
  export class ScaleLimitConstraint implements IDragConstraint {
167
169
  /**
168
170
  * @param min Lower bound. Relative mode: fraction of initial scale. Raw mode: absolute world-scale per axis.
@@ -184,27 +186,33 @@ export class ScaleLimitConstraint implements IDragConstraint {
184
186
  }
185
187
 
186
188
  apply(followObject: GameObject): void {
187
- const ws = followObject.worldScale;
189
+ // Read world scale directly from the matrix columns (col 0/1/2 lengths) rather than
190
+ // going through the worldScale getter, which internally calls updateWorldMatrix() again.
191
+ // We call it once here so the matrix is fresh, then read the elements directly.
192
+ const obj = followObject as unknown as Object3D;
193
+ obj.updateWorldMatrix(true, false);
194
+ const e = obj.matrixWorld.elements;
195
+ const sx = Math.sqrt(e[0] * e[0] + e[1] * e[1] + e[2] * e[2]);
196
+ const sy = Math.sqrt(e[4] * e[4] + e[5] * e[5] + e[6] * e[6]);
197
+ const sz = Math.sqrt(e[8] * e[8] + e[9] * e[9] + e[10] * e[10]);
198
+ _scaleLimitWs.set(sx, sy, sz);
199
+ const ws = _scaleLimitWs;
200
+
188
201
  if (this.relativeToInitialScale) {
189
- // Derive the uniform scale ratio from the magnitude of the current world scale
190
- // relative to the initial world scale magnitude.
191
202
  const initLen = this._initialWorldScale.length();
192
203
  if (initLen < 1e-10) return;
193
204
  const ratio = ws.length() / initLen;
194
205
  const clamped = Math.max(this.min, Math.min(this.max, ratio));
195
206
  if (Math.abs(clamped - ratio) > 1e-9) {
196
- ws.copy(this._initialWorldScale).multiplyScalar(clamped);
197
- followObject.worldScale = ws;
207
+ followObject.worldScale = ws.copy(this._initialWorldScale).multiplyScalar(clamped);
198
208
  followObject.updateMatrix();
199
209
  }
200
210
  } else {
201
- // Absolute per-axis clamp.
202
211
  const cx = Math.max(this.min, Math.min(this.max, ws.x));
203
212
  const cy = Math.max(this.min, Math.min(this.max, ws.y));
204
213
  const cz = Math.max(this.min, Math.min(this.max, ws.z));
205
214
  if (cx !== ws.x || cy !== ws.y || cz !== ws.z) {
206
- ws.set(cx, cy, cz);
207
- followObject.worldScale = ws;
215
+ followObject.worldScale = ws.set(cx, cy, cz);
208
216
  followObject.updateMatrix();
209
217
  }
210
218
  }
@@ -56,7 +56,7 @@ export class CallInfo {
56
56
  if (args !== undefined && args.length > 0)
57
57
  argumentsBuffer.push(...args);
58
58
  argumentsBuffer.push(...this.arguments);
59
- this.target(...this.arguments);
59
+ this.target(...argumentsBuffer);
60
60
  argumentsBuffer.length = 0;
61
61
  }
62
62
  else {
@@ -791,8 +791,20 @@ export class SceneSwitcher extends Behaviour {
791
791
  for (let i = 0; i < this.scenes.length; i++) {
792
792
  const scene = this.scenes[i];
793
793
  if (!scene) continue;
794
- const name = scene instanceof Object3D ? scene.name : sceneUriToName(scene.url);
795
- if (name.toLowerCase().includes(lowerCaseValue)) {
794
+ // Match against the url-derived name AND, for in-scene object
795
+ // references (which have an empty url), the referenced object's
796
+ // name. Without the latter, `?scene=<name>` never resolves for
797
+ // non-prefab/object scenes and the switcher falls back to the
798
+ // default scene (scenes is AssetReference[], so the
799
+ // `scene instanceof Object3D` branch alone never matched them).
800
+ const candidateNames: string[] = [];
801
+ if (scene instanceof Object3D) candidateNames.push(scene.name);
802
+ else {
803
+ if (scene.url) candidateNames.push(sceneUriToName(scene.url));
804
+ const asset = scene.asset;
805
+ if (asset instanceof Object3D && asset.name) candidateNames.push(asset.name);
806
+ }
807
+ if (candidateNames.some(n => n?.toLowerCase().includes(lowerCaseValue))) {
796
808
  return this.select(i);
797
809
  }
798
810
  }
@@ -25,7 +25,17 @@ export { LogStats } from "../debug/LogStats.js";
25
25
  export { DeleteBox } from "../DeleteBox.js";
26
26
  export { Deletable } from "../DeleteBox.js";
27
27
  export { DeviceFlag } from "../DeviceFlag.js";
28
+ export { DragProfile } from "../DragControls.js";
28
29
  export { DragControls } from "../DragControls.js";
30
+ export { GridSnapConstraint } from "../DragControlsConstraints.js";
31
+ export { GrabPointPlaneConstraint } from "../DragControlsConstraints.js";
32
+ export { KeepRotationConstraint } from "../DragControlsConstraints.js";
33
+ export { KeepScaleConstraint } from "../DragControlsConstraints.js";
34
+ export { ScaleLimitConstraint } from "../DragControlsConstraints.js";
35
+ export { FixedRotationAxesConstraint } from "../DragControlsConstraints.js";
36
+ export { AxisRotationConstraint } from "../DragControlsConstraints.js";
37
+ export { PlaneHeightLockConstraint } from "../DragControlsConstraints.js";
38
+ export { SnapToSurfaceConstraint } from "../DragControlsConstraints.js";
29
39
  export { DropListener } from "../DropListener.js";
30
40
  export { Duplicatable } from "../Duplicatable.js";
31
41
  export { CallInfo } from "../EventList.js";