@@ -215,7 +215,7 @@
|
|
215
215
|
// Ignore dropped images
|
216
216
|
const lowercaseUrl = url.toLowerCase();
|
217
217
|
if (lowercaseUrl.endsWith(".hdr") || lowercaseUrl.endsWith(".hdri") || lowercaseUrl.endsWith(".exr") || lowercaseUrl.endsWith(".png") || lowercaseUrl.endsWith(".jpg") || lowercaseUrl.endsWith(".jpeg")) {
|
218
|
-
return;
|
218
|
+
return null;
|
219
219
|
}
|
220
220
|
|
221
221
|
const res = await files.addFileFromUrl(new URL(url), this.context);
|
@@ -29,9 +29,16 @@
|
|
29
29
|
}
|
30
30
|
}
|
31
31
|
|
32
|
-
|
32
|
+
/**
|
33
|
+
* Used by the NeedleMenuElement to create a button at {@link NeedleMenuElement#appendChild}
|
34
|
+
*/
|
35
|
+
export declare type ButtonInfo = {
|
33
36
|
label: string,
|
34
37
|
onClick: (evt: Event) => void,
|
38
|
+
/** Material icon name: https://fonts.google.com/icons */
|
39
|
+
icon?: string,
|
40
|
+
/** Low priority is icon is on the left, high priority is icon is on the right. Default is undefined */
|
41
|
+
priority?: number;
|
35
42
|
}
|
36
43
|
|
37
44
|
export class NeedleMenu {
|
@@ -280,7 +287,7 @@
|
|
280
287
|
font-weight: 500;
|
281
288
|
font-weight: 200;
|
282
289
|
font-variation-settings: "wdth" 100;
|
283
|
-
color: rgb(
|
290
|
+
color: rgb(20,20,20);
|
284
291
|
}
|
285
292
|
|
286
293
|
a {
|
@@ -305,8 +312,17 @@
|
|
305
312
|
cursor: pointer;
|
306
313
|
color: black;
|
307
314
|
background: rgba(245, 245, 245, .8);
|
308
|
-
|
315
|
+
box-shadow: inset 0 0 1rem rgba(0,0,30,.2);
|
316
|
+
outline: rgba(0,0,0,.1) 1px solid;
|
309
317
|
}
|
318
|
+
:host .options > *:active, ::slotted(*:active) {
|
319
|
+
background: rgba(255, 255, 255, .8);
|
320
|
+
box-shadow: inset 0px 1px 1px rgba(255,255,255,.5), inset 0 0 2rem rgba(0,0,30,.2), inset 0px 2px 4px rgba(0,0,20,.5);
|
321
|
+
transition: all 0.05s linear;
|
322
|
+
}
|
323
|
+
:host .options > *:focus, ::slotted(*:focus) {
|
324
|
+
outline: rgba(255,255,255,.5) 1px solid;
|
325
|
+
}
|
310
326
|
|
311
327
|
:host .options > *:disabled, ::slotted(*:disabled) {
|
312
328
|
background: rgba(0,0,0,.05);
|
@@ -360,7 +376,7 @@
|
|
360
376
|
}
|
361
377
|
|
362
378
|
.compact .options {
|
363
|
-
max-height:
|
379
|
+
max-height: 18ch;
|
364
380
|
overflow: auto;
|
365
381
|
flex-direction: row;
|
366
382
|
flex-wrap: wrap;
|
@@ -622,6 +638,10 @@
|
|
622
638
|
const button = document.createElement("button");
|
623
639
|
button.textContent = node.label;
|
624
640
|
button.onclick = node.onClick;
|
641
|
+
button.setAttribute("priority", node.priority?.toString() ?? "0");
|
642
|
+
if (node.icon) {
|
643
|
+
button.prepend(getIconElement(node.icon));
|
644
|
+
}
|
625
645
|
node = button as unknown as T;
|
626
646
|
}
|
627
647
|
|
@@ -443,7 +443,7 @@
|
|
443
443
|
if (gripPositionRaw && gripQuaternionRaw) {
|
444
444
|
this._gripWorldPosition.copy(gripPositionRaw);
|
445
445
|
if (parent) this._gripWorldPosition.applyMatrix4(parent.matrixWorld);
|
446
|
-
|
446
|
+
|
447
447
|
this._gripWorldQuaternion.copy(gripQuaternionRaw);
|
448
448
|
// flip forward because we want +Z to be forward
|
449
449
|
this._gripWorldQuaternion.multiply(flipForwardQuaternion);
|
@@ -751,30 +751,34 @@
|
|
751
751
|
const indexTip = handObject.joints["index-finger-tip"];
|
752
752
|
const thumbTip = handObject.joints["thumb-tip"];
|
753
753
|
if (indexTip && thumbTip) {
|
754
|
-
const pinchThreshold = .02;
|
755
|
-
const pinchHysteresis = .01;
|
756
754
|
const distance = indexTip.position.distanceTo(thumbTip.position);
|
757
|
-
|
758
|
-
|
759
|
-
|
760
|
-
|
761
|
-
|
762
|
-
|
763
|
-
|
764
|
-
|
765
|
-
|
766
|
-
|
755
|
+
if(distance !== 0) { // ignore exactly 0 which happens when we switch from hands to controllers
|
756
|
+
const pinchThreshold = .02;
|
757
|
+
const pinchHysteresis = .01;
|
758
|
+
const state = this.states["pinch"] || new InputState();
|
759
|
+
const maxDistance = (pinchThreshold + pinchHysteresis) * 1.5;
|
760
|
+
if (this.isRight) console.log(distance);
|
761
|
+
state.value = 1 - ((distance - pinchThreshold) / maxDistance);
|
762
|
+
|
763
|
+
const isPressed = distance < (pinchThreshold - pinchHysteresis);
|
764
|
+
const isReleased = distance > (pinchThreshold + pinchHysteresis);
|
765
|
+
if (isPressed && !state.pressed) {
|
766
|
+
if (debugCustomGesture) console.log("pinch start", distance);
|
767
|
+
state.isDown = true;
|
768
|
+
state.isUp = false;
|
769
|
+
state.pressed = true;
|
770
|
+
}
|
771
|
+
else if (isReleased && state.pressed) {
|
772
|
+
state.isDown = false;
|
773
|
+
state.isUp = true;
|
774
|
+
state.pressed = false;
|
775
|
+
}
|
776
|
+
else {
|
777
|
+
state.isDown = false;
|
778
|
+
state.isUp = false;
|
779
|
+
}
|
780
|
+
this.states["pinch"] = state;
|
767
781
|
}
|
768
|
-
else if (isReleased && state.pressed) {
|
769
|
-
state.isDown = false;
|
770
|
-
state.isUp = true;
|
771
|
-
state.pressed = false;
|
772
|
-
}
|
773
|
-
else {
|
774
|
-
state.isDown = false;
|
775
|
-
state.isUp = false;
|
776
|
-
}
|
777
|
-
this.states["pinch"] = state;
|
778
782
|
|
779
783
|
/** Workaround for visionOS not emitting selectstart. See https://linear.app/needle/issue/NE-4212
|
780
784
|
* If a selectstart event was never received we do a manual check here if the user is pinching
|
@@ -1260,9 +1260,11 @@
|
|
1260
1260
|
this.onUpdateFade_PostRender();
|
1261
1261
|
|
1262
1262
|
// render spectator view if we're in VR using Link
|
1263
|
-
|
1263
|
+
// __rendered_once is for when we are on device, but opening the browser should not show a blank space
|
1264
|
+
if (isDesktop() || !this["_renderOnceOnDevice"]) {
|
1264
1265
|
const renderer = this.context.renderer;
|
1265
1266
|
if (renderer.xr.isPresenting && this.context.mainCamera) {
|
1267
|
+
this["_renderOnceOnDevice"] = true;
|
1266
1268
|
const wasXr = renderer.xr.enabled;
|
1267
1269
|
const previousRenderTarget = renderer.getRenderTarget();
|
1268
1270
|
const previousBackground = this.context.scene.background;
|
@@ -1,5 +1,6 @@
|
|
1
1
|
import { AnimationMixer, Object3D, Quaternion, Vector3 } from 'three';
|
2
2
|
|
3
|
+
import { isDevEnvironment } from '../../engine/debug/index.js';
|
3
4
|
import { FrameEvent } from '../../engine/engine_context.js';
|
4
5
|
import { isLocalNetwork } from '../../engine/engine_networking_utils.js';
|
5
6
|
import { serializable } from '../../engine/engine_serialization.js';
|
@@ -107,7 +108,17 @@
|
|
107
108
|
|
108
109
|
this.rebuildGraph();
|
109
110
|
|
110
|
-
if (!this.isValid()
|
111
|
+
if (!this.isValid() && (debug || isDevEnvironment())) {
|
112
|
+
if (debug) {
|
113
|
+
console.warn("PlayableDirector is not valid", "Asset?", this.playableAsset, "Tracks:", this.playableAsset?.tracks, "IsArray?", Array.isArray(this.playableAsset?.tracks), this);
|
114
|
+
}
|
115
|
+
else if (!this.playableAsset?.tracks?.length) {
|
116
|
+
console.warn("PlayableDirector has no tracks");
|
117
|
+
}
|
118
|
+
else {
|
119
|
+
console.warn("PlayableDirector is not valid");
|
120
|
+
}
|
121
|
+
}
|
111
122
|
}
|
112
123
|
|
113
124
|
/** @internal */
|
@@ -560,7 +571,7 @@
|
|
560
571
|
if (typeof clip === "string" || typeof clip === "number") {
|
561
572
|
clip = animationClips.find(c => c.name === targetObjectId);
|
562
573
|
}
|
563
|
-
if(debug) console.log(animModel, targetObjectId, "→", clip)
|
574
|
+
if (debug) console.log(animModel, targetObjectId, "→", clip)
|
564
575
|
if (!clip) {
|
565
576
|
console.warn("Could not find animationClip for model", clipModel, track.name, this.name, this.playableAsset?.name, animationClips, binding);
|
566
577
|
continue;
|
@@ -2,12 +2,12 @@
|
|
2
2
|
|
3
3
|
import { Addressables, AssetReference } from "../engine/engine_addressables.js";
|
4
4
|
import { registerObservableAttribute } from "../engine/engine_element_extras.js";
|
5
|
+
import { destroy } from "../engine/engine_gameobject.js";
|
5
6
|
import { InputEvents } from "../engine/engine_input.js";
|
6
7
|
import { isLocalNetwork } from "../engine/engine_networking_utils.js";
|
7
8
|
import { serializable } from "../engine/engine_serialization.js";
|
8
9
|
import { delay, getParam, setParamWithoutReload } from "../engine/engine_utils.js";
|
9
10
|
import { Behaviour, GameObject } from "./Component.js";
|
10
|
-
import { destroy } from "../engine/engine_gameobject.js";
|
11
11
|
|
12
12
|
const debug = getParam("debugsceneswitcher");
|
13
13
|
const experimental_clearSceneOnLoad = getParam("sceneswitcher:clearscene");
|
@@ -196,7 +196,14 @@
|
|
196
196
|
@serializable()
|
197
197
|
preloadConcurrent: number = 2;
|
198
198
|
|
199
|
+
/**
|
200
|
+
* When enabled will create a button for the Needle menu to switch to the next or previous scene
|
201
|
+
* @default false
|
202
|
+
*/
|
203
|
+
@serializable()
|
204
|
+
createMenuButtons: boolean = false;
|
199
205
|
|
206
|
+
|
200
207
|
/** The index of the currently loaded and active scene */
|
201
208
|
get currentIndex(): number { return this._currentIndex; }
|
202
209
|
|
@@ -220,6 +227,8 @@
|
|
220
227
|
|
221
228
|
private _preloadScheduler?: PreLoadScheduler;
|
222
229
|
|
230
|
+
private _menuButtons?: HTMLElement[];
|
231
|
+
|
223
232
|
/** @internal */
|
224
233
|
awake(): void {
|
225
234
|
if (this.scenes === undefined) this.scenes = [];
|
@@ -279,6 +288,23 @@
|
|
279
288
|
// this.lock = locked;
|
280
289
|
}
|
281
290
|
}
|
291
|
+
|
292
|
+
// create the menu buttons
|
293
|
+
if (this.createMenuButtons) {
|
294
|
+
this._menuButtons ??= [];
|
295
|
+
this._menuButtons.push(this.context.menu.appendChild({
|
296
|
+
label: "Previous",
|
297
|
+
icon: "arrow_back_ios",
|
298
|
+
onClick: () => this.selectPrev(),
|
299
|
+
priority: -1005,
|
300
|
+
}));
|
301
|
+
this._menuButtons.push(this.context.menu.appendChild({
|
302
|
+
label: "Next",
|
303
|
+
icon: "arrow_forward_ios",
|
304
|
+
onClick: () => this.selectNext(),
|
305
|
+
priority: -1000,
|
306
|
+
}));
|
307
|
+
}
|
282
308
|
}
|
283
309
|
|
284
310
|
/** @internal */
|
@@ -288,6 +314,14 @@
|
|
288
314
|
this.context.input.removeEventListener(InputEvents.PointerMove, this.onInputPointerMove);
|
289
315
|
this.context.input.removeEventListener(InputEvents.PointerUp, this.onInputPointerUp);
|
290
316
|
this._preloadScheduler?.stop();
|
317
|
+
|
318
|
+
// remove the menu buttons
|
319
|
+
if (this._menuButtons) {
|
320
|
+
for (const button of this._menuButtons) {
|
321
|
+
button.remove();
|
322
|
+
}
|
323
|
+
this._menuButtons = undefined;
|
324
|
+
}
|
291
325
|
}
|
292
326
|
|
293
327
|
private onPopState = async (_state: PopStateEvent) => {
|
@@ -187,6 +187,7 @@
|
|
187
187
|
NeedleXRSession.stop();
|
188
188
|
}
|
189
189
|
|
190
|
+
private _exitXRMenuButton?: HTMLElement;
|
190
191
|
private _previousXRState: number = 0;
|
191
192
|
|
192
193
|
private get isActiveWebXR() {
|
@@ -250,9 +251,16 @@
|
|
250
251
|
}
|
251
252
|
|
252
253
|
this.createLocalAvatar(args.xr);
|
254
|
+
|
255
|
+
this._exitXRMenuButton = this.context.menu.appendChild({
|
256
|
+
label: "Quit XR",
|
257
|
+
onClick: () => this.exitXR(),
|
258
|
+
});
|
253
259
|
}
|
254
260
|
|
255
261
|
onLeaveXR(_: NeedleXREventArgs): void {
|
262
|
+
this._exitXRMenuButton?.remove();
|
263
|
+
|
256
264
|
if (!this.isActiveWebXR) return;
|
257
265
|
|
258
266
|
// revert XR flags
|
@@ -12,9 +12,9 @@
|
|
12
12
|
import { getParam } from "../../../engine/engine_utils.js";
|
13
13
|
import { NeedleXRController, type NeedleXREventArgs, NeedleXRSession } from "../../../engine/engine_xr.js";
|
14
14
|
import { Behaviour, GameObject } from "../../Component.js"
|
15
|
+
import { hasPointerEventComponent } from "../../ui/PointerEvents.js";
|
15
16
|
import { TeleportTarget } from "../TeleportTarget.js";
|
16
17
|
import type { XRMovementBehaviour } from "../types.js";
|
17
|
-
import { hasPointerEventComponent } from "../../ui/PointerEvents.js";
|
18
18
|
|
19
19
|
const debug = getParam("debugwebxr");
|
20
20
|
|
@@ -41,6 +41,13 @@
|
|
41
41
|
@serializable()
|
42
42
|
useTeleport: boolean = true;
|
43
43
|
|
44
|
+
/**
|
45
|
+
* When enabled you can teleport by pinching the right XR controller's index finger tip in front of the hand
|
46
|
+
* @default true
|
47
|
+
*/
|
48
|
+
@serializable()
|
49
|
+
usePinchToTeleport: boolean = true;
|
50
|
+
|
44
51
|
/** Enable to only allow teleporting on objects with a teleport target component
|
45
52
|
* @default false
|
46
53
|
*/
|
@@ -142,19 +149,36 @@
|
|
142
149
|
}
|
143
150
|
|
144
151
|
protected onHandleTeleport(controller: NeedleXRController, rig: IGameObject) {
|
145
|
-
|
152
|
+
let teleportInput = 0;
|
153
|
+
|
154
|
+
if (controller.hand && this.usePinchToTeleport) {
|
155
|
+
const pinch = controller.getGesture("pinch");
|
156
|
+
if (pinch) {
|
157
|
+
teleportInput = pinch.value;
|
158
|
+
}
|
159
|
+
}
|
160
|
+
else {
|
161
|
+
teleportInput = controller.getStick("xr-standard-thumbstick")?.y;
|
162
|
+
}
|
163
|
+
|
146
164
|
if (this._didTeleport) {
|
147
|
-
if (teleportInput
|
165
|
+
if (teleportInput < .4) {
|
148
166
|
this._didTeleport = false;
|
149
167
|
}
|
150
168
|
}
|
151
|
-
else if (teleportInput
|
169
|
+
else if (teleportInput > .8) {
|
152
170
|
this._didTeleport = true;
|
153
171
|
const hit = this.context.physics.raycastFromRay(controller.ray)[0];
|
172
|
+
|
173
|
+
// Ignore hits on objects with interactive components if teleport target is disabled
|
174
|
+
if (hit && controller.hand && !this.useTeleportTarget) {
|
175
|
+
if (this.isObjectWithInteractiveComponent(hit.object)) return;
|
176
|
+
}
|
177
|
+
|
154
178
|
let point: Vector3 | null = hit?.point;
|
155
179
|
|
156
180
|
// If we didnt hit an object in the scene use the ground plane
|
157
|
-
if (!point) {
|
181
|
+
if (!point && !this.useTeleportTarget) {
|
158
182
|
if (!this._plane) {
|
159
183
|
this._plane = new Plane(new Vector3(0, 1, 0), 0);
|
160
184
|
}
|
@@ -189,9 +213,9 @@
|
|
189
213
|
|
190
214
|
private _plane: Plane | null = null;
|
191
215
|
|
192
|
-
private readonly _lines:
|
216
|
+
private readonly _lines: Line2[] = [];
|
193
217
|
private readonly _hitDiscs: Object3D[] = [];
|
194
|
-
private readonly _hitDistances: number
|
218
|
+
private readonly _hitDistances: Array<number | null> = [];
|
195
219
|
|
196
220
|
protected renderRays(session: NeedleXRSession) {
|
197
221
|
|
@@ -223,11 +247,21 @@
|
|
223
247
|
line.position.copy(pos);
|
224
248
|
line.quaternion.copy(rot);
|
225
249
|
const scale = session.rigScale;
|
226
|
-
const
|
250
|
+
const distance = this._hitDistances[i];
|
251
|
+
const hasHit = distance != null;
|
252
|
+
const dist = hasHit ? distance : scale;
|
227
253
|
line.scale.set(scale, scale, dist);
|
228
254
|
line.visible = true;
|
229
255
|
line.layers.disableAll();
|
230
256
|
line.layers.enable(2);
|
257
|
+
let targetOpacity = line.material.opacity;
|
258
|
+
if (ctrl.getButton("primary")?.pressed) {
|
259
|
+
targetOpacity = .5;
|
260
|
+
}
|
261
|
+
else {
|
262
|
+
targetOpacity = hasHit ? .2 : .1;
|
263
|
+
}
|
264
|
+
line.material.opacity = Mathf.lerp(line.material.opacity, targetOpacity, this.context.time.deltaTimeUnscaled / .1);
|
231
265
|
if (line.parent !== this.context.scene)
|
232
266
|
this.context.scene.add(line);
|
233
267
|
}
|
@@ -261,9 +295,8 @@
|
|
261
295
|
const hits = this.context.physics.raycastFromRay(ctrl.ray, { testObject: this.hitPointRaycastFilter, precise: false });
|
262
296
|
const hit = hits.find(h => {
|
263
297
|
// Only render hits on interactable objects
|
264
|
-
return
|
298
|
+
return this.isObjectWithInteractiveComponent(h.object);
|
265
299
|
});
|
266
|
-
this._hitDistances[i] = hit?.distance || 0;
|
267
300
|
|
268
301
|
let disc = this._hitDiscs[i];
|
269
302
|
if (disc) // save the hit object on the disc
|
@@ -271,8 +304,10 @@
|
|
271
304
|
disc["controller"] = ctrl;
|
272
305
|
disc["hit"] = hit;
|
273
306
|
}
|
307
|
+
this._hitDistances[i] = hit?.distance || null;
|
274
308
|
|
275
309
|
if (hit) {
|
310
|
+
|
276
311
|
const rigScale = (session.rigScale ?? 1);
|
277
312
|
if (debug) {
|
278
313
|
Gizmos.DrawWireSphere(hit.point, .025 * rigScale, 0xff0000);
|
@@ -310,6 +345,11 @@
|
|
310
345
|
}
|
311
346
|
}
|
312
347
|
}
|
348
|
+
|
349
|
+
private isObjectWithInteractiveComponent(object: Object3D) {
|
350
|
+
return hasPointerEventComponent(object) || (object["isUI"] === true || object.parent?.["isUI"] === true)
|
351
|
+
}
|
352
|
+
|
313
353
|
private hitPointerSetPosition(ctrl: NeedleXRController, disc: Object3D, distance: number) {
|
314
354
|
disc.position.copy(ctrl.rayWorldPosition)
|
315
355
|
disc.position.add(getTempVector(0, 0, distance - .01).applyQuaternion(ctrl.rayWorldQuaternion));
|
@@ -381,7 +421,7 @@
|
|
381
421
|
// TODO: this doesnt work with passthrough
|
382
422
|
blending: AdditiveBlending,
|
383
423
|
dashed: false,
|
384
|
-
alphaToCoverage: true,
|
424
|
+
// alphaToCoverage: true,
|
385
425
|
|
386
426
|
});
|
387
427
|
line.material = mat;
|