@needle-tools/engine 5.1.0-alpha.8 → 5.1.0-beta
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.
- package/CHANGELOG.md +17 -0
- package/components.needle.json +1 -1
- package/dist/{materialx-CE2sUv2B.umd.cjs → materialx-B47Bz-xs.umd.cjs} +1 -1
- package/dist/{materialx-u1EqYrhu.min.js → materialx-B85WjP7E.min.js} +1 -1
- package/dist/{materialx-vyB2Zbt4.js → materialx-jkHmVPez.js} +1 -1
- package/dist/{needle-engine.bundle-3LOy9tj8.js → needle-engine.bundle-BWkDSRyF.js} +6248 -6001
- package/dist/{needle-engine.bundle-BuI2pjv1.min.js → needle-engine.bundle-BvgS8xMP.min.js} +171 -171
- package/dist/{needle-engine.bundle-DQy9vQdJ.umd.cjs → needle-engine.bundle-CdVr_iIe.umd.cjs} +142 -142
- package/dist/needle-engine.d.ts +359 -46
- package/dist/needle-engine.js +618 -606
- package/dist/needle-engine.min.js +1 -1
- package/dist/needle-engine.umd.cjs +1 -1
- package/dist/{vendor-ButPLzor.umd.cjs → vendor-1UvpPPSB.umd.cjs} +2 -2
- package/dist/{vendor-DqZC4Is7.min.js → vendor-BQ2Vuntm.min.js} +4 -4
- package/dist/{vendor-DkWSNjMV.js → vendor-BslSKZPo.js} +113 -106
- package/lib/engine/api.d.ts +1 -1
- package/lib/engine/api.js +1 -1
- package/lib/engine/api.js.map +1 -1
- package/lib/engine/engine_audio.d.ts +30 -1
- package/lib/engine/engine_audio.js +57 -13
- package/lib/engine/engine_audio.js.map +1 -1
- package/lib/engine/engine_init.js +2 -2
- package/lib/engine/engine_init.js.map +1 -1
- package/lib/engine/engine_license.d.ts +7 -7
- package/lib/engine/engine_license.js +72 -72
- package/lib/engine/engine_license.js.map +1 -1
- package/lib/engine/engine_math.d.ts +1 -1
- package/lib/engine/engine_math.js +10 -4
- package/lib/engine/engine_math.js.map +1 -1
- package/lib/engine/engine_networking_blob.js +3 -3
- package/lib/engine/engine_networking_blob.js.map +1 -1
- package/lib/engine/engine_scenedata.js +7 -0
- package/lib/engine/engine_scenedata.js.map +1 -1
- package/lib/engine/engine_serialization_core.js +7 -2
- package/lib/engine/engine_serialization_core.js.map +1 -1
- package/lib/engine/engine_time_utils.d.ts +10 -6
- package/lib/engine/engine_time_utils.js +10 -6
- package/lib/engine/engine_time_utils.js.map +1 -1
- package/lib/engine/engine_utils_qrcode.js +2 -2
- package/lib/engine/engine_utils_qrcode.js.map +1 -1
- package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
- package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
- package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.js +2 -2
- package/lib/engine/webcomponents/needle-engine.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
- package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
- package/lib/engine/xr/TempXRContext.js +2 -2
- package/lib/engine/xr/TempXRContext.js.map +1 -1
- package/lib/engine-components/AudioSource.d.ts +46 -2
- package/lib/engine-components/AudioSource.js +268 -60
- package/lib/engine-components/AudioSource.js.map +1 -1
- package/lib/engine-components/Component.js +2 -1
- package/lib/engine-components/Component.js.map +1 -1
- package/lib/engine-components/DragControls.js +2 -2
- package/lib/engine-components/DragControls.js.map +1 -1
- package/lib/engine-components/DragControlsConstraints.d.ts +0 -15
- package/lib/engine-components/DragControlsConstraints.js +14 -8
- package/lib/engine-components/DragControlsConstraints.js.map +1 -1
- package/lib/engine-components/EventList.js +1 -1
- package/lib/engine-components/EventList.js.map +1 -1
- package/lib/engine-components/SceneSwitcher.d.ts +2 -0
- package/lib/engine-components/SceneSwitcher.js +43 -16
- package/lib/engine-components/SceneSwitcher.js.map +1 -1
- package/lib/engine-components/SeeThrough.d.ts +39 -0
- package/lib/engine-components/SeeThrough.js +69 -1
- package/lib/engine-components/SeeThrough.js.map +1 -1
- package/lib/engine-components/api.d.ts +1 -0
- package/lib/engine-components/api.js +1 -0
- package/lib/engine-components/api.js.map +1 -1
- package/lib/engine-components/codegen/components.d.ts +10 -0
- package/lib/engine-components/codegen/components.js +10 -0
- package/lib/engine-components/codegen/components.js.map +1 -1
- package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
- package/lib/engine-components/timeline/PlayableDirector.js +1 -1
- package/lib/engine-components/timeline/PlayableDirector.js.map +1 -1
- package/lib/engine-components/web/ScrollFollow.d.ts +33 -0
- package/lib/engine-components/web/ScrollFollow.js +164 -22
- package/lib/engine-components/web/ScrollFollow.js.map +1 -1
- package/lib/needle-app.d.ts +18 -0
- package/lib/needle-app.js +19 -0
- package/lib/needle-app.js.map +1 -0
- package/package.json +3 -2
- package/plugins/common/license.js +25 -4
- package/plugins/common/needle-engine.js +5 -2
- package/plugins/types/userconfig.d.ts +22 -0
- package/plugins/vite/dts-generator.d.ts +24 -0
- package/plugins/vite/dts-generator.js +40 -7
- package/plugins/vite/index.d.ts +2 -0
- package/plugins/vite/index.js +17 -0
- package/plugins/vite/license.js +4 -4
- package/plugins/vite/needle-app.js +78 -5
- package/plugins/vite/remote.d.ts +28 -0
- package/plugins/vite/remote.js +124 -0
- package/src/engine/api.ts +1 -1
- package/src/engine/engine_audio.ts +68 -15
- package/src/engine/engine_gameobject.ts +1 -1
- package/src/engine/engine_init.ts +2 -2
- package/src/engine/engine_license.ts +69 -69
- package/src/engine/engine_math.ts +11 -4
- package/src/engine/engine_networking_blob.ts +3 -3
- package/src/engine/engine_scenedata.ts +7 -2
- package/src/engine/engine_serialization_core.ts +7 -2
- package/src/engine/engine_time_utils.ts +10 -6
- package/src/engine/engine_utils_qrcode.ts +2 -2
- package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
- package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
- package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
- package/src/engine/webcomponents/needle-engine.ts +2 -2
- package/src/engine/xr/TempXRContext.ts +2 -2
- package/src/engine-components/AudioSource.ts +256 -55
- package/src/engine-components/Component.ts +2 -1
- package/src/engine-components/DragControls.ts +3 -2
- package/src/engine-components/DragControlsConstraints.ts +16 -8
- package/src/engine-components/EventList.ts +1 -1
- package/src/engine-components/SceneSwitcher.ts +37 -15
- package/src/engine-components/SeeThrough.ts +75 -1
- package/src/engine-components/api.ts +1 -0
- package/src/engine-components/codegen/components.ts +10 -0
- package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
- package/src/engine-components/timeline/PlayableDirector.ts +1 -1
- package/src/engine-components/web/ScrollFollow.ts +168 -22
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { showBalloonMessage } from "../../debug/debug.js";
|
|
2
2
|
import type { Context } from "../../engine_context.js";
|
|
3
|
-
import {
|
|
3
|
+
import { $fwGZrGe, $BWnhWzrW, Telemetry } from "../../engine_license.js";
|
|
4
4
|
import { isLocalNetwork } from "../../engine_networking_utils.js";
|
|
5
5
|
import { HTMLElementBase } from "../../engine_ssr.js";
|
|
6
6
|
import { DeviceUtilities, getParam } from "../../engine_utils.js";
|
|
@@ -778,8 +778,8 @@ export class NeedleMenuElement extends HTMLElementBase {
|
|
|
778
778
|
try {
|
|
779
779
|
// if the user has a license then we CAN hide the needle logo
|
|
780
780
|
// calling this method immediately will cause an issue with vite bundling tho
|
|
781
|
-
window.requestAnimationFrame(() => $
|
|
782
|
-
if (res == true &&
|
|
781
|
+
window.requestAnimationFrame(() => $BWnhWzrW(res => {
|
|
782
|
+
if (res == true && $fwGZrGe() && !debugNonCommercial) {
|
|
783
783
|
let visible = this._userRequestedLogoVisible;
|
|
784
784
|
if (visible === undefined) visible = false;
|
|
785
785
|
this.___onSetLogoVisible(visible);
|
|
@@ -830,7 +830,7 @@ export class NeedleMenuElement extends HTMLElementBase {
|
|
|
830
830
|
// ensure the menu is not hidden or removed
|
|
831
831
|
const requiredParent = this?.parentNode;
|
|
832
832
|
if (this.style.display != "flex" || this.style.visibility != "visible" || this.style.opacity != "1" || requiredParent != this._domElement?.shadowRoot) {
|
|
833
|
-
if (
|
|
833
|
+
if (!$fwGZrGe()) {
|
|
834
834
|
const change = changeEventCounter++;
|
|
835
835
|
// if a user doesn't have a local pro license *but* for development the menu is hidden then we show a warning
|
|
836
836
|
if (isLocalNetwork() && this._userRequestedMenuVisible === false) {
|
|
@@ -918,7 +918,7 @@ export class NeedleMenuElement extends HTMLElementBase {
|
|
|
918
918
|
showNeedleLogo(visible: boolean) {
|
|
919
919
|
this._userRequestedLogoVisible = visible;
|
|
920
920
|
if (!visible) {
|
|
921
|
-
if (
|
|
921
|
+
if (!$fwGZrGe() || debugNonCommercial) {
|
|
922
922
|
console.warn("[Needle Engine] You need a PRO license to hide the Needle Engine logo in production.");
|
|
923
923
|
const localNetwork = isLocalNetwork()
|
|
924
924
|
if (!localNetwork) return;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { needleLogoOnlySVG } from "../assets/index.js"
|
|
2
2
|
import { isDevEnvironment, showBalloonWarning } from "../debug/index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { $fwGZrGe, _XtEQ, ICD } from "../engine_license.js";
|
|
4
4
|
import { Mathf } from "../engine_math.js";
|
|
5
5
|
import { LoadingProgressArgs } from "../engine_setup.js";
|
|
6
6
|
import { getParam } from "../engine_utils.js";
|
|
@@ -205,7 +205,7 @@ export class EngineLoadingView implements ILoadingViewHandler {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
|
|
208
|
-
const hasLicense =
|
|
208
|
+
const hasLicense = _XtEQ();
|
|
209
209
|
if (!existing) {
|
|
210
210
|
this._loadingElement.style.position = "absolute";
|
|
211
211
|
this._loadingElement.style.width = "100%";
|
|
@@ -373,7 +373,7 @@ export class EngineLoadingView implements ILoadingViewHandler {
|
|
|
373
373
|
|
|
374
374
|
// private async handleRuntimeLicense(loadingElement: HTMLElement) {
|
|
375
375
|
// // First check if we have a commercial license
|
|
376
|
-
// let commercialLicense =
|
|
376
|
+
// let commercialLicense = $fwGZrGe();
|
|
377
377
|
// // if it's the case then we don't need to perform a runtime check
|
|
378
378
|
// if (commercialLicense) return;
|
|
379
379
|
|
|
@@ -393,10 +393,10 @@ export class EngineLoadingView implements ILoadingViewHandler {
|
|
|
393
393
|
// loadingElement.appendChild(nonCommercialContainer);
|
|
394
394
|
|
|
395
395
|
// // Use the runtime license check
|
|
396
|
-
// if (!isDevEnvironment() &&
|
|
396
|
+
// if (!isDevEnvironment() && ICD) {
|
|
397
397
|
// if (debugLicense) console.log("Waiting for runtime license check");
|
|
398
|
-
// await
|
|
399
|
-
// commercialLicense =
|
|
398
|
+
// await ICD;
|
|
399
|
+
// commercialLicense = $fwGZrGe();
|
|
400
400
|
// }
|
|
401
401
|
// if (commercialLicense) return;
|
|
402
402
|
// nonCommercialContainer.style.transition = "opacity .5s ease-in-out";
|
|
@@ -3,7 +3,7 @@ import type { Texture } from "three";
|
|
|
3
3
|
import { isDevEnvironment, showBalloonWarning } from "../debug/index.js";
|
|
4
4
|
import { PUBLIC_KEY, VERSION } from "../engine_constants.js";
|
|
5
5
|
import { ContextEvent, ContextRegistry } from "../engine_context_registry.js";
|
|
6
|
-
import {
|
|
6
|
+
import { $fwGZrGe } from "../engine_license.js";
|
|
7
7
|
import { onStart } from "../engine_lifecycle_api.js";
|
|
8
8
|
import { setDracoDecoderPath, setDracoDecoderType, setKtx2TranscoderPath } from "../engine_loaders.gltf.js";
|
|
9
9
|
import { Context, ContextCreateArgs } from "../engine_setup.js";
|
|
@@ -579,7 +579,7 @@ export class NeedleEngineWebComponent extends HTMLElementBase implements INeedle
|
|
|
579
579
|
|
|
580
580
|
|
|
581
581
|
// Loading start events
|
|
582
|
-
const allowOverridingDefaultLoading =
|
|
582
|
+
const allowOverridingDefaultLoading = $fwGZrGe();
|
|
583
583
|
// default loading can be overriden by calling preventDefault in the onload start event
|
|
584
584
|
this.ensureLoadStartIsRegistered();
|
|
585
585
|
let useDefaultLoading = this.dispatchEvent(new CustomEvent("loadstart", {
|
|
@@ -3,7 +3,7 @@ import { ArrayCamera, AxesHelper, Camera, Color, DirectionalLight, Fog, GridHelp
|
|
|
3
3
|
import { needleLogoOnlySVG } from "../assets/index.js";
|
|
4
4
|
import { isDevEnvironment } from "../debug/index.js";
|
|
5
5
|
import { ObjectUtils, PrimitiveType } from "../engine_create_objects.js";
|
|
6
|
-
import {
|
|
6
|
+
import { $fwGZrGe } from "../engine_license.js";
|
|
7
7
|
import { Mathf } from "../engine_math.js";
|
|
8
8
|
import { delay, DeviceUtilities } from "../engine_utils.js";
|
|
9
9
|
|
|
@@ -215,7 +215,7 @@ export class TemporaryXRContext {
|
|
|
215
215
|
this._scene.background = new Color(0x000000);
|
|
216
216
|
|
|
217
217
|
let logoSrc = needleLogoOnlySVG;
|
|
218
|
-
if (
|
|
218
|
+
if ($fwGZrGe()) {
|
|
219
219
|
const htmlComponent = document.querySelector("needle-engine");
|
|
220
220
|
if (htmlComponent) {
|
|
221
221
|
const licenseLogo = htmlComponent.getAttribute("logo-src");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
149
|
-
|
|
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.
|
|
152
|
-
return this.
|
|
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
|
|
168
|
-
return this.
|
|
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
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
401
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
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
|
|
629
|
-
if (this.sound && !this.
|
|
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
|
-
//
|
|
637
|
-
//
|
|
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
|
-
|
|
653
|
-
|
|
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
|
-
|
|
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 }));
|
|
@@ -1107,7 +1107,8 @@ export abstract class Component implements IComponent, EventTarget,
|
|
|
1107
1107
|
this.__didCompleteStart = false;
|
|
1108
1108
|
this.__inEnableOrDisableCallback = false;
|
|
1109
1109
|
this.__didEnable = false;
|
|
1110
|
-
|
|
1110
|
+
// Preserve copied/constructor-assigned enabled state while resetting lifecycle flags.
|
|
1111
|
+
// Cloned disabled components must not fall back to the default enabled=true state.
|
|
1111
1112
|
this.__destroyed = false;
|
|
1112
1113
|
this._internalInit(init);
|
|
1113
1114
|
return 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.
|
|
801
|
-
private readonly _scaleLimitConstraint: ScaleLimitConstraint = new ScaleLimitConstraint(0.
|
|
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();
|