@threlte/xr
Version:
Tools to more easily create VR and AR experiences with Threlte
205 lines (204 loc) • 9.13 kB
JavaScript
import { Vector3 } from 'three';
import { watch } from '@threlte/core';
import { getInternalContext } from './context';
import { useController } from '../../hooks/useController';
import { useHand } from '../../hooks/useHand';
import { useXR } from '../../hooks/useXR';
import { useFixed } from '../../internal/useFixed';
import { pointerIntersection } from '../../internal/stores';
const getIntersectionId = (intersection) => {
return `${(intersection.eventObject || intersection.object).uuid}/${intersection.index}${intersection.instanceId ?? ''}`;
};
const EPSILON = 0.0001;
export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) => {
const handedness = handContext.hand;
const controller = useController(handedness);
const hand = useHand(handedness);
const { dispatchers } = getInternalContext();
let hits = [];
let lastPosition = new Vector3();
const handlePointerDown = (event) => {
// Save initial coordinates on pointer-down
const [hit] = hits;
if (!hit)
return;
handContext.initialClick = [hit.point.x, hit.point.y, hit.point.z];
handContext.initialHits = hits.map((hit) => hit.eventObject);
handleEvent('onpointerdown', event);
};
const handlePointerUp = (event) => {
handleEvent('onpointerup', event);
};
const handleClick = (event) => {
// If a click yields no results, pass it back to the user as a miss
// Missed events have to come first in order to establish user-land side-effect clean up
if (hits.length === 0) {
pointerMissed(context.interactiveObjects, event);
}
handleEvent('onclick', event);
};
function cancelPointer(intersections) {
for (const [, hoveredObj] of handContext.hovered) {
// When no objects were hit or the the hovered object wasn't found underneath the cursor
// we call pointerout and delete the object from the hovered elements map
if (intersections.length === 0 ||
!intersections.some((hit) => hit.object === hoveredObj.object &&
hit.index === hoveredObj.index &&
hit.instanceId === hoveredObj.instanceId)) {
const { eventObject } = hoveredObj;
handContext.hovered.delete(getIntersectionId(hoveredObj));
const events = dispatchers.get(eventObject);
if (events !== undefined) {
// Clear out intersects, they are outdated by now
const data = { ...hoveredObj, intersections };
events.onpointerout?.(data);
events.onpointerleave?.(data);
// Deal with cancelation
handContext.pointerOverTarget.set(false);
cancelPointer([]);
}
}
}
}
const getHits = () => {
const intersections = [];
const hits = context.raycaster.intersectObjects(context.interactiveObjects, true);
const filtered = context.filter === undefined ? hits : context.filter(hits, context, handContext);
pointerIntersection[handedness].set(filtered[0]);
// Bubble up the events, find the event source (eventObject)
for (const hit of filtered) {
let eventObject = hit.object;
// Bubble event up
while (eventObject) {
if (dispatchers.has(eventObject)) {
intersections.push({ ...hit, eventObject });
}
eventObject = eventObject.parent;
}
}
return intersections;
};
function pointerMissed(objects, event) {
for (const object of objects) {
dispatchers.get(object)?.pointermissed?.(event);
}
}
function processHits() {
context.compute(context, handContext);
return getHits();
}
const handleEvent = (name, event) => {
const isPointerMove = name === 'onpointermove';
const isClickEvent = name === 'onclick' || name === 'oncontextmenu';
// Take care of unhover
if (isPointerMove)
cancelPointer(hits);
let stopped = false;
// loop through all hits and dispatch events
dispatchEvents: for (const hit of hits) {
const intersectionEvent = {
stopped,
...hit,
intersections: hits,
stopPropagation() {
stopped = true;
intersectionEvent.stopped = true;
if (handContext.hovered.size > 0 &&
Array.from(handContext.hovered.values()).some((i) => i.eventObject === hit.eventObject)) {
// Objects cannot flush out higher up objects that have already caught the event
const higher = hits.slice(0, hits.indexOf(hit));
cancelPointer([...higher, hit]);
}
},
delta: 0,
nativeEvent: event,
pointer: handContext.pointer.current,
ray: context.raycaster.ray
};
const events = dispatchers.get(hit.eventObject);
if (events === undefined)
return;
if (isPointerMove) {
// Move event ...
handContext.pointer.update((value) => value.copy(intersectionEvent.point));
if (events.onpointerover ||
events.onpointerenter ||
events.onpointerout ||
events.onpointerleave) {
const id = getIntersectionId(intersectionEvent);
const hoveredItem = handContext.hovered.get(id);
if (hoveredItem === undefined) {
// If the object wasn't previously hovered, book it and call its handler
handContext.hovered.set(id, intersectionEvent);
events.onpointerover?.(intersectionEvent);
events.onpointerenter?.(intersectionEvent);
handContext.pointerOverTarget.set(true);
}
else if (hoveredItem.stopped) {
// If the object was previously hovered and stopped, we shouldn't allow other items to proceed
intersectionEvent.stopPropagation();
}
}
// Call pointer move
events.onpointermove?.(intersectionEvent);
}
else if ((!isClickEvent || handContext.initialHits.includes(hit.eventObject)) &&
events[name] !== undefined) {
// Missed events have to come first
pointerMissed(context.interactiveObjects.filter((object) => !handContext.initialHits.includes(object)), event);
// Call the event
events[name]?.(intersectionEvent);
}
else if (isClickEvent && handContext.initialHits.includes(hit.eventObject)) {
pointerMissed(context.interactiveObjects.filter((object) => !handContext.initialHits.includes(object)), event);
}
if (stopped)
break dispatchEvents;
}
};
const { start, stop } = useFixed(() => {
hits = processHits();
const targetRay = controller.current?.targetRay;
if (targetRay === undefined)
return;
if (targetRay.position.distanceTo(lastPosition) > EPSILON) {
handleEvent('onpointermove');
}
lastPosition.copy(targetRay.position);
}, {
fixedStep,
autoStart: false
});
watch(controller, (input) => {
if (input === undefined)
return;
input.targetRay.addEventListener('selectstart', handlePointerDown);
input.targetRay.addEventListener('selectend', handlePointerUp);
input.targetRay.addEventListener('select', handleClick);
return () => {
input.targetRay.removeEventListener('selectstart', handlePointerDown);
input.targetRay.removeEventListener('selectend', handlePointerUp);
input.targetRay.removeEventListener('select', handleClick);
};
});
watch(hand, (input) => {
if (input === undefined)
return;
input.hand.addEventListener('pinchstart', handlePointerDown);
input.hand.addEventListener('pinchend', handlePointerUp);
input.hand.addEventListener('pinchend', handleClick);
return () => {
input.hand.removeEventListener('pinchstart', handlePointerDown);
input.hand.removeEventListener('pinchend', handlePointerUp);
input.hand.removeEventListener('pinchend', handleClick);
};
});
watch([useXR().isPresenting, handContext.enabled], ([isPresenting, enabled]) => {
if (isPresenting && enabled) {
start();
}
else {
stop();
}
});
};