From 3616f38cdefad40f254eec7022e75ff2779f5829 Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Wed, 17 Dec 2025 16:04:52 +0100 Subject: [PATCH] Fix cursor component triggering clicks on wrong controller's hovered entity in VR --- docs/components/cursor.md | 1 + docs/components/laser-controls.md | 7 ++- src/components/cursor.js | 12 +++++ src/components/laser-controls.js | 3 +- tests/components/cursor.test.js | 85 +++++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 3 deletions(-) diff --git a/docs/components/cursor.md b/docs/components/cursor.md index 608a3acf864..1e03c6894f4 100644 --- a/docs/components/cursor.md +++ b/docs/components/cursor.md @@ -87,6 +87,7 @@ AFRAME.registerComponent('cursor-listener', { | downEvents | Array of additional events on the entity to *listen* to for triggering `mousedown` (e.g., `triggerdown` for vive-controls). | [] | | fuse | Whether cursor is fuse-based. | false on desktop, true on mobile | | fuseTimeout | How long to wait (in milliseconds) before triggering a fuse-based click event. | 1500 | +| hand | Filter WebXR `selectstart`/`selectend` events by controller handedness (`left` or `right`). When set, the cursor only responds to WebXR events from the matching controller. Used by laser-controls to prevent one controller's trigger from affecting another controller's hovered entity. | '' | | mouseCursorStylesEnabled | Whether to show pointer cursor in `rayOrigin: mouse` mode when hovering over entity. | true | | rayOrigin | Where the intersection ray is cast from (i.e. xrselect ,entity or mouse). `rayOrigin: mouse` is extremely useful for VR development on a mouse and keyboard. | entity | upEvents | Array of additional events on the entity to *listen* to for triggering `mouseup`. | [] | diff --git a/docs/components/laser-controls.md b/docs/components/laser-controls.md index 7987b8bde41..3e8ec977457 100644 --- a/docs/components/laser-controls.md +++ b/docs/components/laser-controls.md @@ -33,8 +33,11 @@ the hood, laser-controls sets all of the tracked controller components: These controller components get activated if its respective controller is connected and detected via the Gamepad API. Then the model of the actual controller is used. laser-controls then configures the [cursor -component][cursor] for listen to the appropriate events and configures the -[raycaster component][raycaster] to draw the laser. +component][cursor] to listen to the appropriate events and passes the `hand` +property to ensure WebXR events are filtered by controller handedness. This +prevents one controller's trigger press from affecting another controller's +hovered entity. It also configures the [raycaster component][raycaster] to draw +the laser. When the laser intersects with an entity, the length of the line gets truncated to the distance to the intersection point. diff --git a/src/components/cursor.js b/src/components/cursor.js index c679c5daf88..e38bfb2d1db 100644 --- a/src/components/cursor.js +++ b/src/components/cursor.js @@ -49,6 +49,7 @@ export var Component = registerComponent('cursor', { downEvents: {default: []}, fuse: {default: utils.device.isMobile()}, fuseTimeout: {default: 1500, min: 0}, + hand: {default: ''}, mouseCursorStylesEnabled: {default: true}, upEvents: {default: []}, rayOrigin: {default: 'entity', oneOf: ['mouse', 'entity', 'xrselect']} @@ -332,6 +333,12 @@ export var Component = registerComponent('cursor', { * Trigger mousedown and keep track of the mousedowned entity. */ onCursorDown: function (evt) { + // Filter WebXR events by handedness when hand is configured. + if (evt.type === 'selectstart' && this.data.hand && + evt.inputSource.handedness !== this.data.hand) { + return; + } + this.isCursorDown = true; // Raycast again for touch. if (this.data.rayOrigin === 'mouse' && evt.type === 'touchstart') { @@ -370,6 +377,11 @@ export var Component = registerComponent('cursor', { */ onCursorUp: function (evt) { if (!this.isCursorDown) { return; } + // Filter WebXR events by handedness when hand is configured. + if (evt && evt.type === 'selectend' && this.data.hand && + evt.inputSource.handedness !== this.data.hand) { + return; + } if (this.data.rayOrigin === 'xrselect' && this.activeXRInput !== evt.inputSource) { return; } this.isCursorDown = false; diff --git a/src/components/laser-controls.js b/src/components/laser-controls.js index ea0f404b58e..cc1c9e25ce6 100644 --- a/src/components/laser-controls.js +++ b/src/components/laser-controls.js @@ -63,7 +63,8 @@ registerComponent('laser-controls', { } el.setAttribute('cursor', utils.extend({ - fuse: false + fuse: false, + hand: data.hand }, controllerConfig.cursor)); } diff --git a/tests/components/cursor.test.js b/tests/components/cursor.test.js index 2aa69584b19..c6cc3a77d21 100644 --- a/tests/components/cursor.test.js +++ b/tests/components/cursor.test.js @@ -566,3 +566,88 @@ suite('cursor + raycaster', function () { parentEl.innerHTML = ''; }); }); + +suite('cursor WebXR handedness filtering', function () { + var component; + var el; + var intersectedEl; + + setup(function (done) { + var cameraEl = entityFactory(); + el = document.createElement('a-entity'); + intersectedEl = document.createElement('a-entity'); + cameraEl.setAttribute('camera', 'active: true'); + el.setAttribute('cursor', 'hand: right'); + el.addEventListener('componentinitialized', function (evt) { + if (evt.detail.name !== 'cursor') { return; } + component = el.components.cursor; + done(); + }); + cameraEl.appendChild(el); + }); + + suite('onCursorDown', function () { + test('ignores selectstart from non-matching hand', function () { + var twoWayEmitSpy = this.sinon.spy(component, 'twoWayEmit'); + component.onCursorDown({ + type: 'selectstart', + inputSource: {handedness: 'left'} + }); + assert.isFalse(twoWayEmitSpy.called); + assert.isFalse(component.isCursorDown); + }); + + test('processes selectstart from matching hand', function () { + var twoWayEmitSpy = this.sinon.spy(component, 'twoWayEmit'); + component.intersectedEl = intersectedEl; + component.onCursorDown({ + type: 'selectstart', + inputSource: {handedness: 'right'} + }); + assert.isTrue(twoWayEmitSpy.calledWith('mousedown')); + assert.isTrue(component.isCursorDown); + }); + + test('processes non-WebXR events regardless of hand setting', function () { + var twoWayEmitSpy = this.sinon.spy(component, 'twoWayEmit'); + component.intersectedEl = intersectedEl; + component.onCursorDown({type: 'mousedown'}); + assert.isTrue(twoWayEmitSpy.calledWith('mousedown')); + assert.isTrue(component.isCursorDown); + }); + }); + + suite('onCursorUp', function () { + test('ignores selectend from non-matching hand', function () { + var twoWayEmitSpy = this.sinon.spy(component, 'twoWayEmit'); + component.isCursorDown = true; + component.onCursorUp({ + type: 'selectend', + inputSource: {handedness: 'left'} + }); + assert.isFalse(twoWayEmitSpy.called); + assert.isTrue(component.isCursorDown); + }); + + test('processes selectend from matching hand', function () { + var twoWayEmitSpy = this.sinon.spy(component, 'twoWayEmit'); + component.isCursorDown = true; + component.intersectedEl = intersectedEl; + component.onCursorUp({ + type: 'selectend', + inputSource: {handedness: 'right'} + }); + assert.isTrue(twoWayEmitSpy.calledWith('mouseup')); + assert.isFalse(component.isCursorDown); + }); + + test('processes non-WebXR events regardless of hand setting', function () { + var twoWayEmitSpy = this.sinon.spy(component, 'twoWayEmit'); + component.isCursorDown = true; + component.intersectedEl = intersectedEl; + component.onCursorUp({type: 'mouseup'}); + assert.isTrue(twoWayEmitSpy.calledWith('mouseup')); + assert.isFalse(component.isCursorDown); + }); + }); +});