From 9da630215a05f8d81ac6404138804d2b648213bf Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sat, 18 Apr 2026 14:20:48 +0200 Subject: [PATCH 1/2] Support per-instance cursor events on BatchedMesh / InstancedMesh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple a-entities sharing a single BatchedMesh (draw-call reduction) or InstancedMesh (instancing) all map to the same host entity, so the raycaster and cursor collapse hover / click tracking across every logical instance: ray moves between two instances of the same shared mesh, nothing fires. raycaster: also fire raycaster-closest-entity-changed when the closest hit's (entity, batchId, instanceId) tuple changes, even if the entity is unchanged. No per-instance mapping in core — the raycaster's role stops at surfacing batchId / instanceId on intersection details. cursor: track (intersectedEl, intersection.batchId, intersection.instanceId) as the hover identity. Transitions within a shared mesh now fire mouseleave with the OLD tuple and mouseenter with the NEW tuple. The stored intersection is used for event payloads so leave events carry the outgoing instance's details, not the incoming one. App-level mapping: the new examples/test/raycaster-batched-mesh example shows a `batch-proxy` component on each host entity that holds its own map from id to member entity and forwards mouseenter / mouseleave / click to the matching per-instance entity. Keeping the map in userspace lets apps encode whatever identity makes sense (batchId, instanceId, custom composite keys) without expanding the core component API. Tests cover intersection details carrying batchId / instanceId, raycaster-closest-entity-changed firing on tuple-only change, and the cursor's tuple-aware mouseleave/mouseenter sequencing with the outgoing tuple in detail.intersection. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/components/raycaster.md | 21 +++ examples/index.html | 1 + .../raycaster-batched-mesh/build-batches.js | 133 ++++++++++++++++++ .../test/raycaster-batched-mesh/index.html | 50 +++++++ src/components/cursor.js | 39 +++-- src/components/raycaster.js | 16 ++- tests/components/cursor.test.js | 78 ++++++++++ tests/components/raycaster.test.js | 115 +++++++++++++++ 8 files changed, 443 insertions(+), 10 deletions(-) create mode 100644 examples/test/raycaster-batched-mesh/build-batches.js create mode 100644 examples/test/raycaster-batched-mesh/index.html diff --git a/docs/components/raycaster.md b/docs/components/raycaster.md index 4a23200ca60..d964123bbb0 100644 --- a/docs/components/raycaster.md +++ b/docs/components/raycaster.md @@ -204,6 +204,27 @@ A-Frame will call `.refreshObjects()` automatically when an entity is appended or detached from the scene, but it will not get called during normal DOM mutations (e.g., some entity changes its `class`). +## Per-instance Intersections on BatchedMesh / InstancedMesh + +When multiple a-entities share a single `THREE.BatchedMesh` or +`THREE.InstancedMesh` (for draw-call reduction), the raycaster keeps +resolving every intersection to the single host entity that owns the +shared mesh. To distinguish instances, check +`intersection.batchId` / `intersection.instanceId` on the event detail. + +`raycaster-closest-entity-changed` fires whenever the closest +`(entity, batchId, instanceId)` tuple changes, including transitions +between two instances of the same shared mesh. The cursor component +treats the tuple as its hover identity, so `mouseenter` and +`mouseleave` fire on those transitions too — the event's +`intersection` detail carries the per-instance ids. + +Apps keep their own map from `batchId` / `instanceId` to logical +entities and can proxy the host's cursor events to the matching +per-instance entity. See the +[raycaster-batched-mesh example](https://github.com/aframevr/aframe/tree/master/examples/test/raycaster-batched-mesh) +for a working setup. + ## Customizing the Line If `showLine` is set to `true`, the raycaster will configure the line given the diff --git a/examples/index.html b/examples/index.html index 11b0dec522f..eea7179017d 100644 --- a/examples/index.html +++ b/examples/index.html @@ -208,6 +208,7 @@

Tests

  • Raycaster
  • Raycaster (Simple)
  • Raycaster (Sprite)
  • +
  • Raycaster (BatchedMesh / InstancedMesh)
  • Shaders
  • Shadows
  • Text
  • diff --git a/examples/test/raycaster-batched-mesh/build-batches.js b/examples/test/raycaster-batched-mesh/build-batches.js new file mode 100644 index 00000000000..26a4b0142a5 --- /dev/null +++ b/examples/test/raycaster-batched-mesh/build-batches.js @@ -0,0 +1,133 @@ +/* global AFRAME, THREE */ +// Demonstrates per-instance hover / click on THREE.BatchedMesh and THREE.InstancedMesh. +// +// A-Frame's raycaster and cursor keep treating the host a-entity as the hover +// target, but they fire `mouseenter` / `mouseleave` / `click` whenever the +// (entity, batchId, instanceId) tuple changes. The event detail includes the +// per-instance ids on `detail.intersection`. Apps hold their own map from +// ids to logical entities and proxy events to them — the mapping stays in +// app code, not in A-Frame core. + +var BOX_COLOR = new THREE.Color('#4cc3d9'); +var CONE_COLOR = new THREE.Color('#f1ea65'); +var HOVER_COLOR = new THREE.Color('#ff6b9d'); + +AFRAME.registerComponent('log-events', { + events: { + mouseenter: function () { console.log('[mouseenter]', this.el.id); }, + mouseleave: function () { console.log('[mouseleave]', this.el.id); }, + click: function () { console.log('[click]', this.el.id); } + } +}); + +AFRAME.registerComponent('hover-tint', { + events: { + mouseenter: function () { this.setColor(HOVER_COLOR); }, + mouseleave: function () { this.setColor(this.baseColor); } + }, + setColor: function (color) { + var slot = this.el.object3D.userData.batchSlot; + if (!slot || !color) { return; } + slot.mesh.setColorAt(slot.id, color); + if (slot.mesh.instanceColor) { slot.mesh.instanceColor.needsUpdate = true; } + } +}); + +// Host-level proxy: reads detail.intersection.batchId / instanceId, looks up the +// logical per-instance entity in the host's local map, and re-dispatches the +// cursor events to that entity so components like hover-tint / log-events can +// react on the per-instance side. +AFRAME.registerComponent('batch-proxy', { + schema: { key: { default: 'batchId' } }, // 'batchId' or 'instanceId' + init: function () { + this.map = []; + }, + register: function (id, memberEl) { + this.map[id] = memberEl; + }, + events: { + mouseenter: function (evt) { this.forward(evt, 'mouseenter'); }, + mouseleave: function (evt) { this.forward(evt, 'mouseleave'); }, + click: function (evt) { this.forward(evt, 'click'); } + }, + forward: function (evt, name) { + var id = evt.detail.intersection && evt.detail.intersection[this.data.key]; + var target = id !== undefined ? this.map[id] : null; + if (!target) { return; } + target.emit(name, { cursorEl: evt.detail.cursorEl, intersection: evt.detail.intersection }); + } +}); + +function stashSlot (el, mesh, id, baseColor) { + el.object3D.userData.batchSlot = { mesh: mesh, id: id }; + var tint = el.components['hover-tint']; + if (tint) { tint.baseColor = baseColor; } +} + +function buildBatchedMesh (hostEl, memberEls, baseColor) { + var geometry = new THREE.BoxGeometry(1, 1, 1); + var material = new THREE.MeshStandardMaterial(); + var batched = new THREE.BatchedMesh( + memberEls.length, + geometry.attributes.position.count, + geometry.index.count, + material + ); + var geomId = batched.addGeometry(geometry); + var proxy = hostEl.components['batch-proxy']; + + memberEls.forEach(function (el) { + var instanceId = batched.addInstance(geomId); + el.object3D.updateMatrix(); + batched.setMatrixAt(instanceId, el.object3D.matrix); + batched.setColorAt(instanceId, baseColor); + proxy.register(instanceId, el); + stashSlot(el, batched, instanceId, baseColor); + }); + + hostEl.setObject3D('mesh', batched); +} + +function buildInstancedMesh (hostEl, memberEls, baseColor) { + var geometry = new THREE.ConeGeometry(0.5, 1, 16); + var material = new THREE.MeshStandardMaterial(); + var mesh = new THREE.InstancedMesh(geometry, material, memberEls.length); + var proxy = hostEl.components['batch-proxy']; + + memberEls.forEach(function (el, i) { + el.object3D.updateMatrix(); + mesh.setMatrixAt(i, el.object3D.matrix); + mesh.setColorAt(i, baseColor); + proxy.register(i, el); + stashSlot(el, mesh, i, baseColor); + }); + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) { mesh.instanceColor.needsUpdate = true; } + + hostEl.setObject3D('mesh', mesh); +} + +document.addEventListener('DOMContentLoaded', function () { + var sceneEl = document.querySelector('a-scene'); + sceneEl.addEventListener('loaded', function () { + buildBatchedMesh( + document.getElementById('batchHost'), + [ + document.getElementById('boxA'), + document.getElementById('boxB'), + document.getElementById('boxC') + ], + BOX_COLOR + ); + + buildInstancedMesh( + document.getElementById('instanceHost'), + [ + document.getElementById('coneA'), + document.getElementById('coneB'), + document.getElementById('coneC') + ], + CONE_COLOR + ); + }); +}); diff --git a/examples/test/raycaster-batched-mesh/index.html b/examples/test/raycaster-batched-mesh/index.html new file mode 100644 index 00000000000..f1bfcfb9438 --- /dev/null +++ b/examples/test/raycaster-batched-mesh/index.html @@ -0,0 +1,50 @@ + + + + + Raycaster — BatchedMesh / InstancedMesh per-instance intersections + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/cursor.js b/src/components/cursor.js index e38bfb2d1db..3078f260fc6 100644 --- a/src/components/cursor.js +++ b/src/components/cursor.js @@ -65,6 +65,10 @@ export var Component = registerComponent('cursor', { this.fuseTimeout = undefined; this.cursorDownEl = null; this.intersectedEl = null; + // Track the current intersection (not just the entity) so that transitions between + // instances of a shared BatchedMesh / InstancedMesh — where intersectedEl stays + // the host but intersection.batchId / instanceId changes — fire mouseleave/enter. + this.intersection = null; this.canvasBounds = document.body.getBoundingClientRect(); this.isCursorDown = false; this.activeXRInput = null; @@ -436,11 +440,19 @@ export var Component = registerComponent('cursor', { // If cursor is the only intersected object, ignore the event. if (!intersectedEl) { return; } - // Already intersecting this entity. - if (this.intersectedEl === intersectedEl) { return; } + // Already intersecting — compare (entity, batchId, instanceId) so ray transitions + // within a shared BatchedMesh / InstancedMesh (same entity, different instance) + // still trigger mouseleave / mouseenter. + if (this.intersectedEl === intersectedEl && + this.intersection && + this.intersection.batchId === intersection.batchId && + this.intersection.instanceId === intersection.instanceId) { + return; + } - // Ignore events further away than active intersection. - if (this.intersectedEl) { + // Ignore events further away than active intersection (unless it's the same entity — + // a closer instance on the same host should take over). + if (this.intersectedEl && this.intersectedEl !== intersectedEl) { currentIntersection = this.el.components.raycaster.getIntersection(this.intersectedEl); if (currentIntersection && currentIntersection.distance <= intersection.distance) { return; } } @@ -474,11 +486,17 @@ export var Component = registerComponent('cursor', { var data = this.data; var self = this; - // Already intersecting. - if (this.intersectedEl === intersectedEl) { return; } + // Already intersecting this exact (entity, batchId, instanceId) tuple. + if (this.intersectedEl === intersectedEl && + this.intersection && + this.intersection.batchId === intersection.batchId && + this.intersection.instanceId === intersection.instanceId) { + return; + } // Set new intersection. this.intersectedEl = intersectedEl; + this.intersection = intersection; // Hovering. cursorEl.addState(STATES.HOVERING); @@ -520,6 +538,7 @@ export var Component = registerComponent('cursor', { // Unset intersected entity (after emitting the event). this.intersectedEl = null; + this.intersection = null; // Clear fuseTimeout. clearTimeout(this.fuseTimeout); @@ -541,7 +560,12 @@ export var Component = registerComponent('cursor', { twoWayEmit: function (evtName, originalEvent) { var el = this.el; var intersectedEl = this.intersectedEl; - var intersection; + // Use the stashed intersection so mouseleave carries the OLD tuple — important for + // BatchedMesh / InstancedMesh transitions, where a fresh getIntersection(el) would + // return the incoming instance rather than the one we're leaving. + var intersection = evtName === EVENTS.MOUSELEAVE + ? this.intersection + : el.components.raycaster.getIntersection(intersectedEl); function addOriginalEvent (detail, evt) { if (originalEvent instanceof MouseEvent) { @@ -552,7 +576,6 @@ export var Component = registerComponent('cursor', { } } - intersection = this.el.components.raycaster.getIntersection(intersectedEl); this.eventDetail.intersectedEl = intersectedEl; this.eventDetail.intersection = intersection; addOriginalEvent(this.eventDetail, originalEvent); diff --git a/src/components/raycaster.js b/src/components/raycaster.js index c936d60e754..a853d13ba0d 100644 --- a/src/components/raycaster.js +++ b/src/components/raycaster.js @@ -68,6 +68,8 @@ export var Component = registerComponent('raycaster', { this.objects = []; this.prevCheckTime = undefined; this.prevIntersectedEls = []; + this.prevClosestBatchId = undefined; + this.prevClosestInstanceId = undefined; this.rawIntersections = []; this.raycaster = new THREE.Raycaster(); this.updateOriginDirection(); @@ -281,15 +283,25 @@ export var Component = registerComponent('raycaster', { el.emit(EVENTS.INTERSECTION, this.intersectionDetail); } - // Emit event when the closest intersected entity has changed. + // Emit event when the closest intersected entity has changed. Also fires when the + // closest entity is unchanged but `intersection.batchId` / `intersection.instanceId` + // changed (ray moved between instances of a shared BatchedMesh / InstancedMesh). + var prevBatchId = this.prevClosestBatchId; + var prevInstanceId = this.prevClosestInstanceId; + var closestBatchId = intersections.length ? intersections[0].batchId : undefined; + var closestInstanceId = intersections.length ? intersections[0].instanceId : undefined; if (prevIntersectedEls.length === 0 && intersections.length > 0 || prevIntersectedEls.length > 0 && intersections.length === 0 || (prevIntersectedEls.length && intersections.length && - prevIntersectedEls[0] !== intersections[0].object.el)) { + (prevIntersectedEls[0] !== intersections[0].object.el || + prevBatchId !== closestBatchId || + prevInstanceId !== closestInstanceId))) { this.intersectionDetail.els = this.intersectedEls; this.intersectionDetail.intersections = intersections; el.emit(EVENTS.INTERSECTION_CLOSEST_ENTITY_CHANGED, this.intersectionDetail); } + this.prevClosestBatchId = closestBatchId; + this.prevClosestInstanceId = closestInstanceId; // Update line length. if (data.showLine) { setTimeout(this.updateLine); } diff --git a/tests/components/cursor.test.js b/tests/components/cursor.test.js index c6cc3a77d21..ea14332dab7 100644 --- a/tests/components/cursor.test.js +++ b/tests/components/cursor.test.js @@ -331,6 +331,84 @@ suite('cursor', function () { done(); }); }); + + // When multiple a-entities share a BatchedMesh / InstancedMesh, the ray can move + // between instances without changing the resolved entity. Cursor should treat + // (entity, batchId, instanceId) as the hover identity so events fire on the + // transition and carry the current per-instance intersection detail. + test('re-emits mouseleave/mouseenter when batchId changes on same entity', function (done) { + var first = {distance: 5, batchId: 0}; + var second = {distance: 6, batchId: 1}; + var current = first; + var events = []; + // mouseenter pulls a fresh intersection from the raycaster, so reflect the + // currently-hovered instance here. + this.sinon.replace(el.components.raycaster, 'getIntersection', function (queryEl) { + return queryEl === intersectedEl ? current : null; + }); + intersectedEl.addEventListener('mouseenter', function (evt) { + events.push({type: 'enter', batchId: evt.detail.intersection.batchId}); + if (events.length === 3) { + assert.deepEqual(events, [ + {type: 'enter', batchId: 0}, + {type: 'leave', batchId: 0}, + {type: 'enter', batchId: 1} + ]); + done(); + } + }); + intersectedEl.addEventListener('mouseleave', function (evt) { + events.push({type: 'leave', batchId: evt.detail.intersection.batchId}); + }); + + el.emit('raycaster-intersection', { + intersections: [first], + els: [intersectedEl] + }); + current = second; + el.emit('raycaster-closest-entity-changed', { + intersections: [second], + els: [intersectedEl] + }); + }); + + test('does not re-emit when (entity, batchId, instanceId) is unchanged', function (done) { + var intersectionA = {distance: 5, batchId: 0}; + var enterCount = 0; + intersectedEl.addEventListener('mouseenter', function () { enterCount++; }); + + el.emit('raycaster-intersection', { + intersections: [intersectionA], + els: [intersectedEl] + }); + // Same tuple -> no new mouseenter. + el.emit('raycaster-closest-entity-changed', { + intersections: [intersectionA], + els: [intersectedEl] + }); + + setTimeout(function () { + assert.equal(enterCount, 1); + done(); + }); + }); + + test('mouseleave intersection carries the OLD tuple when batchId changes', function (done) { + var first = {distance: 5, batchId: 0}; + var second = {distance: 6, batchId: 1}; + intersectedEl.addEventListener('mouseleave', function (evt) { + assert.equal(evt.detail.intersection.batchId, 0); + done(); + }); + el.emit('raycaster-intersection', { + intersections: [first], + els: [intersectedEl] + }); + el.emit('raycaster-closest-entity-changed', { + intersections: [second], + els: [intersectedEl] + }); + }); }); suite('onIntersectionCleared', function () { diff --git a/tests/components/raycaster.test.js b/tests/components/raycaster.test.js index 36d2f2532a1..6dcd7be9763 100644 --- a/tests/components/raycaster.test.js +++ b/tests/components/raycaster.test.js @@ -435,6 +435,121 @@ suite('raycaster', function () { }); }); + suite('BatchedMesh per-instance tracking', function () { + var hostEl; + var batchedMesh; + + setup(function (done) { + el.setAttribute('position', '0 0 1'); + el.setAttribute('raycaster', {near: 0.1, far: 10}); + + hostEl = document.createElement('a-entity'); + hostEl.setAttribute('id', 'host'); + + // Two unit boxes batched into one mesh, one in front of the raycaster (-1 Z) + // and one to the side (-3 X). Default ray hits the first; after a 70deg yaw + // it hits the second — a transition between instances on the SAME host entity. + var geomA = new THREE.BoxGeometry(1, 1, 1); + var geomB = new THREE.BoxGeometry(1, 1, 1); + batchedMesh = new THREE.BatchedMesh(2, 1024, 2048, new THREE.MeshBasicMaterial()); + var idA = batchedMesh.addGeometry(geomA); + var idB = batchedMesh.addGeometry(geomB); + var inA = batchedMesh.addInstance(idA); + var inB = batchedMesh.addInstance(idB); + batchedMesh.setMatrixAt(inA, new THREE.Matrix4().makeTranslation(0, 0, -1)); + batchedMesh.setMatrixAt(inB, new THREE.Matrix4().makeTranslation(-3, 0, 0)); + + hostEl.addEventListener('loaded', function () { + hostEl.setObject3D('mesh', batchedMesh); + setTimeout(() => { done(); }); + }); + sceneEl.appendChild(hostEl); + }); + + test('intersection carries batchId for per-instance hits', function (done) { + el.addEventListener('raycaster-intersection', function (evt) { + assert.equal(component.intersectedEls[0], hostEl); + assert.equal(evt.detail.intersections[0].batchId, 0); + done(); + }); + sceneEl.object3D.updateMatrixWorld(); + component.refreshObjects(); + component.tock(); + }); + + test('emits closest-entity-changed when ray moves to a different instance on same host', function (done) { + el.addEventListener('raycaster-intersection', function onFirst () { + el.removeEventListener('raycaster-intersection', onFirst); + el.addEventListener('raycaster-closest-entity-changed', function (evt) { + // Same host entity, but the closest intersection has switched instance. + assert.equal(evt.detail.els[0], hostEl); + assert.equal(evt.detail.intersections[0].batchId, 1); + done(); + }); + el.setAttribute('rotation', '0 70 0'); + sceneEl.object3D.updateMatrixWorld(); + component.tock(); + }); + sceneEl.object3D.updateMatrixWorld(); + component.refreshObjects(); + component.tock(); + }); + }); + + suite('InstancedMesh per-instance tracking', function () { + var hostEl; + var instancedMesh; + + setup(function (done) { + el.setAttribute('position', '0 0 1'); + el.setAttribute('raycaster', {near: 0.1, far: 10}); + + hostEl = document.createElement('a-entity'); + hostEl.setAttribute('id', 'instHost'); + + var geom = new THREE.BoxGeometry(1, 1, 1); + var mat = new THREE.MeshBasicMaterial(); + instancedMesh = new THREE.InstancedMesh(geom, mat, 2); + instancedMesh.setMatrixAt(0, new THREE.Matrix4().makeTranslation(0, 0, -1)); + instancedMesh.setMatrixAt(1, new THREE.Matrix4().makeTranslation(-3, 0, 0)); + instancedMesh.instanceMatrix.needsUpdate = true; + + hostEl.addEventListener('loaded', function () { + hostEl.setObject3D('mesh', instancedMesh); + setTimeout(() => { done(); }); + }); + sceneEl.appendChild(hostEl); + }); + + test('intersection carries instanceId for per-instance hits', function (done) { + el.addEventListener('raycaster-intersection', function (evt) { + assert.equal(component.intersectedEls[0], hostEl); + assert.equal(evt.detail.intersections[0].instanceId, 0); + done(); + }); + sceneEl.object3D.updateMatrixWorld(); + component.refreshObjects(); + component.tock(); + }); + + test('emits closest-entity-changed when ray moves to a different instance on same host', function (done) { + el.addEventListener('raycaster-intersection', function onFirst () { + el.removeEventListener('raycaster-intersection', onFirst); + el.addEventListener('raycaster-closest-entity-changed', function (evt) { + assert.equal(evt.detail.els[0], hostEl); + assert.equal(evt.detail.intersections[0].instanceId, 1); + done(); + }); + el.setAttribute('rotation', '0 70 0'); + sceneEl.object3D.updateMatrixWorld(); + component.tock(); + }); + sceneEl.object3D.updateMatrixWorld(); + component.refreshObjects(); + component.tock(); + }); + }); + suite('updateOriginDirection', function () { test('updates ray origin if position changes', function () { var origin; From 1c38b5491cf4c483208c5b3ac51af7e67ade4559 Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Wed, 10 Jun 2026 09:59:50 +0200 Subject: [PATCH 2/2] test(cursor): assert click carries up-to-date uv, not stale hover intersection Guards the regression where the cursor emitted click with the intersection stashed at hover start instead of a fresh getIntersection: moving the ray across the same entity between mousedown and click now verifies the event detail carries the current uv. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/components/cursor.test.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/components/cursor.test.js b/tests/components/cursor.test.js index ea14332dab7..8115696f45f 100644 --- a/tests/components/cursor.test.js +++ b/tests/components/cursor.test.js @@ -160,6 +160,29 @@ suite('cursor', function () { component.onCursorUp(); }); + // The click must carry the uv where the ray currently points, not the + // uv stashed when hover began. The cursor pulls a fresh intersection from the + // raycaster for non-mouseleave events, so moving across the same entity between + // mousedown and click yields up-to-date uv in the event detail. + test('click carries the up-to-date uv, not the stale hover intersection', function (done) { + var staleIntersection = {distance: 10.5, uv: {x: 0.1, y: 0.1}}; + var freshIntersection = {distance: 10.5, uv: {x: 0.9, y: 0.9}}; + // Stored when hover began. + component.intersection = staleIntersection; + component.intersectedEl = intersectedEl; + component.cursorDownEl = intersectedEl; + // The ray has since moved; the raycaster reports the current intersection. + this.sinon.replace(el.components.raycaster, 'getIntersection', function (queryEl) { + return queryEl === intersectedEl ? freshIntersection : null; + }); + once(intersectedEl, 'click', function (evt) { + assert.shallowDeepEqual(evt.detail.intersection.uv, freshIntersection.uv); + done(); + }); + component.isCursorDown = true; + component.onCursorUp(); + }); + test('emits click event on intersectedEl when fuse and mouse cursor enabled', function (done) { el.setAttribute('cursor', 'fuse', true); el.setAttribute('cursor', 'rayOrigin', 'mouse');