Needle Engine

Changes between version 3.32.14-alpha and 3.32.15-alpha
Files changed (11) hide show
  1. src/engine-components/ui/Button.ts +7 -5
  2. src/engine-components/ui/Canvas.ts +9 -2
  3. src/engine-components/DragControls.ts +10 -0
  4. src/engine/engine_context.ts +1 -1
  5. src/engine/engine_gizmos.ts +9 -8
  6. src/engine/engine_types.ts +3 -1
  7. src/engine-components/ui/EventSystem.ts +57 -20
  8. src/engine/xr/NeedleXRController.ts +106 -6
  9. src/engine-components/export/usdz/ThreeUSDZExporter.ts +3 -1
  10. src/engine-components/webxr/controllers/XRControllerModel.ts +16 -4
  11. src/engine-components/webxr/controllers/XRControllerMovement.ts +8 -13
src/engine-components/ui/Button.ts CHANGED
@@ -65,12 +65,12 @@
65
65
  @serializable(EventList)
66
66
  onClick?: EventList;
67
67
 
68
- private _isHovered: boolean = false;
68
+ private _isHovered: number = 0;
69
69
 
70
70
  onPointerEnter(_) {
71
+ this._isHovered += 1;
71
72
  if (debug)
72
- console.log("Button Enter", this.animationTriggers?.highlightedTrigger, this.animator);
73
- this._isHovered = true;
73
+ console.warn("Button Enter", this._isHovered, this.animationTriggers?.highlightedTrigger, this.animator);
74
74
  if (!this.interactable) return;
75
75
  if (this.transition == Transition.Animation && this.animationTriggers && this.animator) {
76
76
  this.animator.setTrigger(this.animationTriggers.highlightedTrigger);
@@ -82,10 +82,12 @@
82
82
  }
83
83
 
84
84
  onPointerExit() {
85
+ this._isHovered -= 1;
85
86
  if (debug)
86
- console.log("Button Exit", this.animationTriggers?.highlightedTrigger, this.animator);
87
- this._isHovered = false;
87
+ console.log("Button Exit", this._isHovered, this.animationTriggers?.highlightedTrigger, this.animator);
88
88
  if (!this.interactable) return;
89
+ if (this._isHovered > 0) return;
90
+ this._isHovered = 0;
89
91
  if (this.transition == Transition.Animation && this.animationTriggers && this.animator) {
90
92
  this.animator.setTrigger(this.animationTriggers.normalTrigger);
91
93
  }
src/engine-components/ui/Canvas.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  import { Mathf } from "../../engine/engine_math.js";
5
5
  import { serializable } from "../../engine/engine_serialization_decorator.js";
6
6
  import { FrameEvent } from "../../engine/engine_setup.js";
7
- import { getParam } from "../../engine/engine_utils.js";
7
+ import { delayForFrames, getParam } from "../../engine/engine_utils.js";
8
8
  import { NeedleXREventArgs } from "../../engine/xr/index.js";
9
9
  import { Camera } from "../Camera.js";
10
10
  import { GameObject } from "../Component.js";
@@ -202,12 +202,19 @@
202
202
  }
203
203
  }
204
204
 
205
- onEnterXR(args: NeedleXREventArgs): void {
205
+ async onEnterXR(args: NeedleXREventArgs) {
206
+ // workaround for https://linear.app/needle/issue/NE-4114
206
207
  if (this.screenspace) {
207
208
  if (args.xr.isVR || args.xr.isPassThrough) {
208
209
  this.gameObject.visible = false;
209
210
  }
210
211
  }
212
+ else {
213
+ this.gameObject.visible = false;
214
+ await delayForFrames(1).then(()=>{
215
+ this.gameObject.visible = true;
216
+ });
217
+ }
211
218
  }
212
219
  onLeaveXR(args: NeedleXREventArgs): void {
213
220
  if (this.screenspace) {
src/engine-components/DragControls.ts CHANGED
@@ -451,6 +451,16 @@
451
451
  private _initialDistance: number = 0;
452
452
 
453
453
  private alignManipulator() {
454
+ if (!this.handlerA || !this.handlerB) {
455
+ console.error("alignManipulator called on MultiTouchDragHandler without valid handlers. This is likely a bug.", this);
456
+ return;
457
+ }
458
+
459
+ if (!this.handlerA.followObject || !this.handlerB.followObject) {
460
+ console.error("alignManipulator called on MultiTouchDragHandler without valid follow objects. This is likely a bug.", this.handlerA, this.handlerB);
461
+ return;
462
+ }
463
+
454
464
  this._tempVec1.copy(this._handlerAAttachmentPoint);
455
465
  this._tempVec2.copy(this._handlerBAttachmentPoint);
456
466
  this.handlerA.followObject.localToWorld(this._tempVec1);
src/engine/engine_context.ts CHANGED
@@ -1005,7 +1005,7 @@
1005
1005
  if ((isDevEnvironment() || debug) && (err instanceof Error || err instanceof TypeError))
1006
1006
  showBalloonMessage(`Caught unhandled exception during render-loop - see console for details.`, LogType.Error);
1007
1007
  console.error("Frame #" + this.time.frame + "\n", err);
1008
- if (this._renderlooperrors > 10) {
1008
+ if (this._renderlooperrors >= 3) {
1009
1009
  console.warn("Stopping render loop due to error")
1010
1010
  this.renderer.setAnimationLoop(null);
1011
1011
  }
src/engine/engine_gizmos.ts CHANGED
@@ -208,12 +208,12 @@
208
208
  width: "auto",
209
209
  fontSize: size,
210
210
  color: color,
211
- lineHeight: .75,
211
+ lineHeight: 1,
212
212
  backgroundColor: backgroundColor ?? undefined,
213
213
  backgroundOpacity: opacity,
214
214
  textContent: text,
215
- borderRadius: 1 * size,
216
- padding: 1 * size,
215
+ borderRadius: .5 * size,
216
+ padding: .8 * size,
217
217
  whiteSpace: 'pre',
218
218
  };
219
219
 
@@ -232,7 +232,6 @@
232
232
  // handle.setText(text);
233
233
  }
234
234
  this.tmuiNeedsUpdate = true;
235
- element.layers.enableAll();
236
235
  this.registerTimedObject(Context.Current, element as any, duration, this.textLabelCache as any);
237
236
  return element as Text & LabelHandle;
238
237
  }
@@ -320,8 +319,10 @@
320
319
  context.post_render_callbacks.push(postRender);
321
320
  }
322
321
 
323
- object.layers.disableAll();
324
- object.layers.enable(2);
322
+ object.traverse(obj => {
323
+ obj.layers.disableAll();
324
+ obj.layers.enable(2);
325
+ });
325
326
 
326
327
  object.renderOrder = 999999;
327
328
  object[$cacheSymbol] = cache;
@@ -349,7 +350,7 @@
349
350
  continue;
350
351
  }
351
352
  const isInXR = ctx.isInVR;
352
- const keepUp = isInXR;
353
+ const keepUp = false;
353
354
  const copyRotation = !isInXR;
354
355
  lookAtObject(obj as any, ctx.mainCamera, keepUp, copyRotation);
355
356
  }
@@ -364,7 +365,7 @@
364
365
  objects.splice(i, 1);
365
366
  times.splice(i, 1);
366
367
  obj.removeFromParent();
367
- if (isDestroyed(obj) == false) {
368
+ if (isDestroyed(obj) != true) {
368
369
  const cache = obj[$cacheSymbol];
369
370
  cache.push(obj);
370
371
  }
src/engine/engine_types.ts CHANGED
@@ -526,5 +526,7 @@
526
526
 
527
527
  export type XRControllerButtonName = "thumbrest" | "xr-standard-trigger" | "xr-standard-squeeze" | "xr-standard-thumbstick" | "xr-standard-touchpad" | "menu" | GamepadButtonName;
528
528
 
529
+ export type XRGestureName = "pinch";
530
+
529
531
  /** All known (types) button names for various devices and cases combined. You should use the device specific names if you e.g. know you only deal with a mouse use MouseButtonName */
530
- export type ButtonName = "unknown" | MouseButtonName | GamepadButtonName | XRControllerButtonName;
532
+ export type ButtonName = "unknown" | MouseButtonName | GamepadButtonName | XRControllerButtonName | XRGestureName;
src/engine-components/ui/EventSystem.ts CHANGED
@@ -28,6 +28,8 @@
28
28
  hasActiveUI: boolean
29
29
  }
30
30
 
31
+ declare type IComponentCanMaybeReceiveEvents = IPointerEventHandler & IComponent & { interactable?: boolean };
32
+
31
33
  export class EventSystem extends Behaviour {
32
34
  private static _eventSystemMap = new Map<Context, EventSystem[]>();
33
35
 
@@ -340,7 +342,7 @@
340
342
  // thus is not hovering over anything
341
343
  const hoveredData = this.hoveredByID.get(args.pointerId);
342
344
  if (hoveredData) {
343
- this.triggerOnExit(hoveredData.obj, hoveredData.data, null);
345
+ this.propagatePointerExit(hoveredData.obj, hoveredData.data, null);
344
346
  }
345
347
  this.hoveredByID.delete(args.pointerId);
346
348
 
@@ -444,7 +446,7 @@
444
446
 
445
447
  // trigger onPointerExit
446
448
  if (isNewlyHovering && prevHovering) {
447
- this.triggerOnExit(prevHovering, hovering.data, object);
449
+ this.propagatePointerExit(prevHovering, hovering.data, object);
448
450
  }
449
451
 
450
452
  // save hovered object
@@ -513,35 +515,29 @@
513
515
  case "hand":
514
516
  // for hands and controller we assume they are never totally still (except for simulated environments)
515
517
  // we might want to add a threshold here (e.g. if a user holds their hand very still or controller)
516
- // so maybe check the angle every frame?
518
+ // so maybe check the angle everxy frame?
517
519
  break;
518
520
  }
519
521
 
520
522
  this.propagate(object, (behaviour) => {
521
- const comp = behaviour as any;
523
+ const comp = behaviour as IComponentCanMaybeReceiveEvents;
522
524
 
523
525
  if (comp.interactable === false) return;
524
526
  if (!comp.activeAndEnabled || !comp.enabled) return;
525
527
 
526
528
  if (comp.onPointerEnter) {
527
529
  if (hoveredObjectChanged) {
528
- if (!comp[this.pointerEnterSymbol]) {
529
- comp[this.pointerEnterSymbol] = true;
530
- delete comp[this.pointerExitSymbol];
531
- comp.onPointerEnter(args);
532
- }
530
+ this.handlePointerEnter(comp, args);
533
531
  }
534
532
  }
535
533
 
536
534
  if (args.isDown) {
537
535
  if (comp.onPointerDown) {
538
536
  comp.onPointerDown(args);
539
-
540
537
  // Set the handler that we called the down event on
541
538
  // So we can call the up event on the same handler
542
539
  // In a scenario where we Down on one object and Up on another
543
540
  pressedEvent?.handlers.add(comp);
544
-
545
541
  this.handlePointerCapture(args, comp);
546
542
  }
547
543
  }
@@ -582,11 +578,8 @@
582
578
  }
583
579
  }
584
580
 
585
- /**
586
- * Propagate up in hiearchy and call OnExit regardless of the pointer event data
587
- */
588
- private triggerOnExit(object: Object3D, args: PointerEventData, newObject: Object3D | null) {
589
-
581
+ /** Propagate up in hierarchy and call onPointerExit */
582
+ private propagatePointerExit(object: Object3D, args: PointerEventData, newObject: Object3D | null) {
590
583
  this.propagate(object, (behaviour) => {
591
584
  if (!behaviour.gameObject || behaviour.destroyed) return;
592
585
 
@@ -596,10 +589,7 @@
596
589
  if (newObject && this.isChild(newObject, behaviour.gameObject)) {
597
590
  return;
598
591
  }
599
- if (inst[this.pointerExitSymbol]) return;
600
- inst[this.pointerExitSymbol] = true;
601
- delete inst[this.pointerEnterSymbol];
602
- inst.onPointerExit(args);
592
+ this.handlePointerExit(inst, args);
603
593
  }
604
594
  });
605
595
  }
@@ -610,6 +600,53 @@
610
600
  this.releasePointerCapture(evt, handler);
611
601
  }
612
602
 
603
+ /** Responsible for invoking onPointerEnter (and updating onPointerExit). We invoke onPointerEnter once per active pointerId */
604
+ private handlePointerEnter(comp: IComponentCanMaybeReceiveEvents, args: PointerEventData) {
605
+ if (comp.onPointerEnter) {
606
+ if (this.updatePointerState(comp, args.pointerId, this.pointerEnterSymbol, true)) {
607
+ comp.onPointerEnter(args);
608
+ }
609
+ }
610
+ this.updatePointerState(comp, args.pointerId, this.pointerExitSymbol, false);
611
+ }
612
+
613
+ /** Responsible for invoking onPointerExit (and updating onPointerEnter). We invoke onPointerExit once per active pointerId */
614
+ private handlePointerExit(comp: IComponentCanMaybeReceiveEvents, evt: PointerEventData) {
615
+ if (comp.onPointerExit) {
616
+ if (this.updatePointerState(comp, evt.pointerId, this.pointerExitSymbol, true)) {
617
+ comp.onPointerExit(evt);
618
+ }
619
+ }
620
+ this.updatePointerState(comp, evt.pointerId, this.pointerEnterSymbol, false);
621
+ }
622
+
623
+ /** updates the pointer state list for a component
624
+ * @param comp the component to update
625
+ * @param pointerId the pointerId to update
626
+ * @param symbol the symbol to use for the state
627
+ * @param add if true, the pointerId is added to the state list, if false the pointerId will be removed
628
+ */
629
+ private updatePointerState(comp: IComponentCanMaybeReceiveEvents, pointerId: number, symbol: symbol, add: boolean) {
630
+ let state = comp[symbol];
631
+
632
+ if (add) {
633
+ // the pointer is already in the state list
634
+ if (state && state.includes(pointerId)) return false;
635
+ state = state || [];
636
+ state.push(pointerId);
637
+ comp[symbol] = state;
638
+ return true;
639
+ }
640
+ else {
641
+ if (!state || !state.includes(pointerId)) return false;
642
+ const i = state.indexOf(pointerId);
643
+ if (i !== -1) {
644
+ state.splice(i, 1);
645
+ }
646
+ return true;
647
+ }
648
+ }
649
+
613
650
  /** the list of component handlers that requested pointerCapture for a specific pointerId */
614
651
  private readonly _capturedPointer: { [id: number]: IPointerEventHandler[] } = {};
615
652
 
src/engine/xr/NeedleXRController.ts CHANGED
@@ -1,17 +1,26 @@
1
1
  import { fetchProfile, MotionController } from "@webxr-input-profiles/motion-controllers";
2
2
  import { AxesHelper, Int8BufferAttribute, Object3D, Quaternion, Ray, Vector3 } from "three";
3
3
 
4
+ import { RGBAColor } from "../../engine-components/js-extensions/RGBAColor.js";
4
5
  import { Context } from "../engine_context.js";
5
6
  import { Gizmos } from "../engine_gizmos.js";
6
7
  import { InputEventNames, InputEvents, NEPointerEvent, NEPointerEventInit, PointerType } from "../engine_input.js";
7
8
  import { getTempQuaternion, getTempVector, getWorldQuaternion } from "../engine_three_utils.js";
8
- import type { ButtonName, IGameObject, Vec3, XRControllerButtonName } from "../engine_types.js";
9
+ import type { ButtonName, IGameObject, Vec3, XRControllerButtonName, XRGestureName } from "../engine_types.js";
9
10
  import { getParam } from "../engine_utils.js";
10
11
  import { flipForwardMatrix, flipForwardQuaternion } from "./internal.js";
11
12
  import type { NeedleXRHitTestResult, NeedleXRSession } from "./NeedleXRSession.js";
12
13
 
13
14
  const debug = getParam("debugwebxr");
15
+ /** when enabled we will not use the browser select event but instead
16
+ * we will emit the input event based on our own pinch detection
17
+ * this is a workaround for visionOS not emitting the select events, see https://linear.app/needle/issue/NE-4212
18
+ */
19
+ const debugCustomGesture = getParam("debugcustomgesture");
14
20
 
21
+ /** true when selectstart was ever received */
22
+ let _didReceiveSelectStartEvent = false;
23
+
15
24
  // https://github.com/immersive-web/webxr-input-profiles/blob/4484a05e30bcd43fe86bb4e06b7a707861a26796/packages/registry/profiles/meta/meta-quest-touch-plus.json
16
25
  declare type ControllerAxes = "xr-standard-thumbstick";
17
26
  declare type StickName = "xr-standard-thumbstick";
@@ -62,6 +71,7 @@
62
71
  export class NeedleXRController {
63
72
  /** the Needle XR Session */
64
73
  readonly xr: NeedleXRSession;
74
+ get context() { return this.xr.context; }
65
75
  /**
66
76
  * https://developer.mozilla.org/en-US/docs/Web/API/XRInputSource
67
77
  */
@@ -90,6 +100,10 @@
90
100
  * @link https://developer.mozilla.org/en-US/docs/Web/API/XRHand
91
101
  */
92
102
  get hand() { return this.inputSource.hand; }
103
+ /** threejs XRHandSpace, shorthand for `context.renderer.xr.getHand(controllerIndex)`
104
+ * @link https://threejs.org/docs/#api/en/renderers/webxr/WebXRManager.getHand
105
+ */
106
+ get handObject() { return this.context.renderer.xr.getHand(this.index); }
93
107
  /** The input source profiles */
94
108
  get profiles() { return this.inputSource.profiles; }
95
109
  /** The device input layout */
@@ -200,7 +214,7 @@
200
214
  get object() { return this._object; }
201
215
  private readonly _object: IGameObject;
202
216
 
203
- private readonly _debugAxesHelper = new AxesHelper(.03);
217
+ private readonly _debugAxesHelper = new AxesHelper(.2);
204
218
 
205
219
  /** returns the URL of the default controller model */
206
220
  async getModelUrl(): Promise<string | null> {
@@ -250,6 +264,13 @@
250
264
  onRenderDebug() {
251
265
  Gizmos.DrawWireSphere(this.rayWorldPosition, .02);
252
266
  Gizmos.DrawDirection(this.rayWorldPosition, getTempVector(0, 0, 10).applyQuaternion(this.rayWorldQuaternion));
267
+ const debugLabelPosition = this.gripWorldPosition.sub(this.object.worldForward.multiplyScalar(.1));
268
+ const profileStr = this.inputSource.profiles.join("\n");
269
+ let debugStr = `Controller[${this.index}] ${this.side}
270
+ C:${this.connected ? "yes" : "no"} T:${this.isTracking ? "yes" : "no"} Hand:${this.inputSource.hand ? "yes" : "no"}`;
271
+ if (this.inputSource.hand) debugStr += `\nPinch: ${this.getGesture("pinch")?.value.toFixed(3)}`;
272
+ debugStr += "\n" + profileStr;
273
+ Gizmos.DrawLabel(debugLabelPosition, debugStr, .01);
253
274
  }
254
275
 
255
276
  private onUpdateFrame(frame: XRFrame) {
@@ -345,7 +366,7 @@
345
366
 
346
367
  /** Called when the input source disconnects */
347
368
  onDisconnected() {
348
- if (this.connected) return;
369
+ if (debug) console.warn("Controller disconnected", this.index);
349
370
  // move all attached objects into the scene
350
371
  for (const child of this._object.children) {
351
372
  this.xr.context.scene.attach(child);
@@ -398,7 +419,21 @@
398
419
  return undefined;
399
420
  }
400
421
 
401
- private readonly _needleGamepadButtons = new Array<NeedleGamepadButton>();
422
+ /** Get a gesture state */
423
+ getGesture(key: XRGestureName): NeedleGamepadButton | null {
424
+ const state = this.states[key];
425
+ if (!state) return null;
426
+ this.states[key] = state;
427
+ const needleButton = this._needleGamepadButtons[key] || new NeedleGamepadButton();
428
+ needleButton.pressed = state.pressed;
429
+ needleButton.value = state.value;
430
+ needleButton.isDown = state.isDown;
431
+ needleButton.isUp = state.isUp;
432
+ this._needleGamepadButtons[key] = needleButton;
433
+ return needleButton;
434
+ }
435
+
436
+ private readonly _needleGamepadButtons: { [key: number | string]: NeedleGamepadButton } = {};
402
437
  /** combine the InputState information + the GamepadButton information (since GamepadButtons can not be extended) */
403
438
  private toNeedleGamepadButton(index: number): NeedleGamepadButton {
404
439
  const button = this.inputSource.gamepad?.buttons[index];
@@ -453,7 +488,6 @@
453
488
  return { x: 0, y: 0, z: 0 }
454
489
  }
455
490
 
456
-
457
491
  private readonly _buttonMap = new Map<ButtonName, number>();
458
492
 
459
493
  // the motion controller contains the controller scheme, we use this to simplify button access
@@ -520,10 +554,21 @@
520
554
  const selectComponentId = this._layout?.selectComponentId;
521
555
  const i = this._layout?.components[selectComponentId!]?.gamepadIndices?.button;
522
556
  if (i !== undefined) this._selectButtonIndex = i;
557
+ if (debugCustomGesture) return;
558
+ if (!_didReceiveSelectStartEvent) {
559
+ _didReceiveSelectStartEvent = true;
560
+ // safeguard first pinch event - check if the pinch gesture is already down
561
+ const pinch = this.getGesture("pinch");
562
+ if (pinch?.pressed) {
563
+ console.warn("Select start event was received but the pinch gesture is already down. This might happen the first time you start pinching", this.index, this.side);
564
+ return;
565
+ }
566
+ }
523
567
  if (debug) Gizmos.DrawDirection(this.rayWorldPosition, getTempVector(0, .01, 1).applyQuaternion(this.rayWorldQuaternion), 0xff0000, 10);
524
568
  this.emitPointerEvent(InputEvents.PointerDown, this._selectButtonIndex || 0, "xr-standard-trigger", true, evt);
525
569
  }
526
570
  private onSelectEnd = (evt: XRInputSourceEvent) => {
571
+ if (debugCustomGesture) return;
527
572
  if (this.inputSource !== evt.inputSource) return;
528
573
  this.emitPointerEvent(InputEvents.PointerUp, this._selectButtonIndex || 0, "xr-standard-trigger", true, evt);
529
574
  }
@@ -542,7 +587,7 @@
542
587
  };
543
588
 
544
589
  /** Index = button index */
545
- private readonly states = new Array<InputState>();
590
+ private readonly states: { [key: number | string]: InputState } = {};
546
591
  // If we want to invoke button events for ALL buttons we need to keep track of the previous state
547
592
  // instead of using XR input select start events which is only raised for the primary button
548
593
  // we should probably do both but then we need to ignore the primary index in the following function (to not raise an event for the same button twice)
@@ -583,11 +628,65 @@
583
628
 
584
629
  if (eventName != null && emitEvent) {
585
630
  const name = this._layout?.gamepad[k];
631
+ if (debug || debugCustomGesture) console.log("Emitting pointer event", eventName, k, name, button.value, this.gamepad, this._layout);
586
632
  this.emitPointerEvent(eventName, k, name ?? "none", false, null, button.value);
587
633
  }
588
634
  }
589
635
  }
636
+
637
+ // update hand gesture states
638
+ if (this.hand) {
639
+ const handObject = this.handObject;
640
+ if (handObject) {
641
+ // update pinch state
642
+ const indexTip = handObject.joints["index-finger-tip"];
643
+ const thumbTip = handObject.joints["thumb-tip"];
644
+ if (indexTip && thumbTip) {
645
+ const pinchThreshold = .02;
646
+ const pinchHysteresis = .01;
647
+ const distance = indexTip.position.distanceTo(thumbTip.position);
648
+ const state = this.states["pinch"] || new InputState();
649
+ state.value = distance;
650
+
651
+ const isPressed = distance < (pinchThreshold - pinchHysteresis);
652
+ const isReleased = distance > (pinchThreshold + pinchHysteresis);
653
+ if (isPressed && !state.pressed) {
654
+ if (debugCustomGesture) console.log("pinch start", distance);
655
+ state.isDown = true;
656
+ state.isUp = false;
657
+ state.pressed = true;
658
+ }
659
+ else if (isReleased && state.pressed) {
660
+ state.isDown = false;
661
+ state.isUp = true;
662
+ state.pressed = false;
663
+ }
664
+ else {
665
+ state.isDown = false;
666
+ state.isUp = false;
667
+ }
668
+ this.states["pinch"] = state;
669
+
670
+ /** workaround for visionOS not emitting selectstart. See https://linear.app/needle/issue/NE-4212
671
+ * If a select start event was never received we do a manual check here if the user is pinching
672
+ */
673
+ if (!_didReceiveSelectStartEvent && (state.isDown || state.isUp)) {
674
+ const eventName = isPressed ? "pointerdown" : "pointerup";
675
+ const pressure = distance / pinchThreshold;
676
+ if (debugCustomGesture) {
677
+ const p = this.rayWorldPosition.add(this.object.worldForward.multiplyScalar(.2));
678
+ p.y += .05;
679
+ p.y += Math.random() * .02;
680
+ Gizmos.DrawLabel(p, "pinch:" + eventName + ", " + this.index + ", " + this.side + "\n" + handObject.uuid, 0.01, 5, 0x000000, new RGBAColor(1, 1, 1, .1));
681
+ }
682
+ this.emitPointerEvent(eventName, 0, "pinch", false, null, pressure);
683
+ }
684
+ }
685
+ }
686
+ }
590
687
  }
688
+
689
+
591
690
  private onUpdateMove() {
592
691
  let button = this.xr.context.input.getFirstPressedButtonForPointer(this.index);
593
692
  if (button === undefined) button = 0;
@@ -622,6 +721,7 @@
622
721
 
623
722
  const prevContext = Context.Current;
624
723
  Context.Current = this.xr.context;
724
+ if (debug && type !== "pointermove") console.warn("Pointer event", type, button, buttonName, { ...this.pointerInit });
625
725
  this.xr.context.input.createInputEvent(new NEPointerEvent(type, source, this.pointerInit));
626
726
  Context.Current = prevContext;
627
727
  }
src/engine-components/export/usdz/ThreeUSDZExporter.ts CHANGED
@@ -1081,7 +1081,9 @@
1081
1081
 
1082
1082
  if ( geometry ) {
1083
1083
  writer.beginBlock( `def ${objType} "${name}"`, "(", false );
1084
- if (context.quickLookCompatible && material && material.side === DoubleSide)
1084
+ // NE-4084: To use the doubleSided workaround with skeletal meshes we'd have to
1085
+ // also emit extra data for jointIndices etc., so we're skipping skinned meshes here.
1086
+ if (context.quickLookCompatible && material && material.side === DoubleSide && !isSkinnedMesh)
1085
1087
  writer.appendLine(`prepend references = @./geometries/Geometry_${geometry.id}.usd@</Geometry_doubleSided>`);
1086
1088
  else
1087
1089
  writer.appendLine(`prepend references = @./geometries/Geometry_${geometry.id}.usd@</Geometry>`);
src/engine-components/webxr/controllers/XRControllerModel.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import { showBalloonWarning } from "../../../engine/debug/index.js";
7
7
  import { AssetReference } from "../../../engine/engine_addressables.js";
8
8
  import { setDontDestroy } from "../../../engine/engine_gameobject.js";
9
+ import { Gizmos } from "../../../engine/engine_gizmos.js";
9
10
  import { addDracoAndKTX2Loaders } from "../../../engine/engine_loaders.js";
10
11
  import { serializable } from "../../../engine/engine_serialization_decorator.js";
11
12
  import { IGameObject } from "../../../engine/engine_types.js";
@@ -58,7 +59,8 @@
58
59
  res?.handmesh?.controller?.removeFromParent();
59
60
  return;
60
61
  }
61
- this._models[controller.index] = { controller: controller, model: res.handObject, handmesh: res.handmesh };
62
+ this._models.push({ controller: controller, model: res.handObject, handmesh: res.handmesh });
63
+ this._models.sort((a, b) => a.controller.index - b.controller.index);
62
64
  this.scene.add(res.handObject);
63
65
  }
64
66
  }
@@ -68,7 +70,8 @@
68
70
  if (assetUrl) {
69
71
  const model = await this.loadModel(controller, assetUrl);
70
72
  if (!model || !controller.connected) return;
71
- this._models[controller.index] = { controller: controller, model };
73
+ this._models.push({ controller: controller, model });
74
+ this._models.sort((a, b) => a.controller.index - b.controller.index);
72
75
  this.scene.add(model);
73
76
  // The controller mesh should by default inherit layers.
74
77
  model.traverse(child => {
@@ -187,6 +190,10 @@
187
190
 
188
191
  const context = this.context;
189
192
  const hand = context.renderer.xr.getHand(controller.index);
193
+ if (!hand) {
194
+ if (debug) Gizmos.DrawLabel(controller.rayWorldPosition, "No hand found for index " + controller.index, .05, 5);
195
+ else console.warn("No hand found for index " + controller.index);
196
+ }
190
197
 
191
198
  const loader = new GLTFLoader();
192
199
  addDracoAndKTX2Loaders(loader, context);
@@ -220,6 +227,7 @@
220
227
  this.makeOccluder(child);
221
228
  });
222
229
  if (!controller.connected) {
230
+ if(debug) Gizmos.DrawLabel(controller.rayWorldPosition, "Hand is loaded but not connected anymore", .05, 5);
223
231
  object.removeFromParent();
224
232
  }
225
233
  });
@@ -229,9 +237,7 @@
229
237
  if (controller.inputSource.hand) {
230
238
  if (debug) console.log(controller.inputSource.hand);
231
239
  for (const inputjoint of controller.inputSource.hand.values()) {
232
-
233
240
  if (hand.joints[inputjoint.jointName] === undefined) {
234
-
235
241
  const joint = new Group();
236
242
  joint.matrixAutoUpdate = false;
237
243
  joint.visible = true;
@@ -243,6 +249,12 @@
243
249
  }
244
250
  }
245
251
  }
252
+ else {
253
+ if(debug) {
254
+ Gizmos.DrawLabel(controller.rayWorldPosition, "No inputSource.hand found for index " + controller.index, .05, 5);
255
+ }
256
+ }
257
+
246
258
  return { handObject: handObject as IGameObject, handmesh: handmesh };
247
259
  }
248
260
 
src/engine-components/webxr/controllers/XRControllerMovement.ts CHANGED
@@ -122,10 +122,6 @@
122
122
  const dir = rotationInput > 0 ? 1 : -1;
123
123
  rig.rotateY(dir * Mathf.toRadians(this.rotationStep));
124
124
  }
125
-
126
- const pos = controller.rayWorldPosition;
127
- pos.y += .1
128
- if (debug) Gizmos.DrawLabel(pos, stick.x.toFixed(2) + ", " + stick.y.toFixed(2), .02, 0)
129
125
  }
130
126
 
131
127
  protected onHandleTeleport(controller: NeedleXRController, rig: IGameObject) {
@@ -166,20 +162,18 @@
166
162
 
167
163
  protected renderRays(session: NeedleXRSession) {
168
164
 
169
- if (session.controllers.length < this._lines.length) {
170
- for (let i = session.controllers.length; i < this._lines.length; i++) {
171
- const line = this._lines[i];
172
- line.visible = false;
173
- }
165
+ for (let i = 0; i < this._lines.length; i++) {
166
+ const line = this._lines[i];
167
+ line.visible = false;
174
168
  }
175
169
 
176
- for (const disc of this._hitDiscs) {
177
- if (disc) disc.visible = false;
178
- }
179
-
180
170
  for (let i = 0; i < session.controllers.length; i++) {
181
171
  const ctrl = session.controllers[i];
182
172
  let line = this._lines[i];
173
+ if (!ctrl.connected || !ctrl.isTracking) {
174
+ if (line) line.visible = false;
175
+ continue;
176
+ }
183
177
  if (!line) {
184
178
  line = this.createRayLineObject();
185
179
  line.scale.z = .5;
@@ -209,6 +203,7 @@
209
203
  }
210
204
  for (let i = 0; i < session.controllers.length; i++) {
211
205
  const ctrl = session.controllers[i];
206
+ if (!ctrl.connected || !ctrl.isTracking) continue;
212
207
  const hit = this.context.physics.raycastFromRay(ctrl.ray, { testObject: this.hitPointRaycastFilter })[0];
213
208
  this._hitDistances[i] = hit?.distance;
214
209
  if (hit) {