@threlte/xr
Version:
Tools to more easily create VR and AR experiences with Threlte
248 lines (247 loc) • 10.2 kB
JavaScript
import { Matrix4, Mesh, Ray, Sphere, Vector3 } from 'three';
import { observe } from '@threlte/core';
import { fromStore } from 'svelte/store';
import { getInternalContext } from './context.js';
import { useFixed } from '../../internal/useFixed.js';
import { isPresenting } from '../../internal/state.svelte.js';
const getIntersectionId = (intersection) => {
const target = intersection.eventObject ?? intersection.object;
if (intersection.instanceId !== undefined) {
return `${target.uuid}|${intersection.instanceId}`;
}
if (intersection.object.isPoints) {
return `${target.uuid}|${intersection.index}`;
}
return target.uuid;
};
let nextPointerId = 2001;
const worldSphere = new Sphere();
const invMatrix = new Matrix4();
const localOrigin = new Vector3();
const localClamped = new Vector3();
const surfacePoint = new Vector3();
// The IntersectionEvent shape still carries `ray`, which isn't meaningful for
// touch. A fixed dummy ray keeps the type happy and matches the shape that the
// ray-based plugins provide.
const dummyRay = new Ray();
export const setupTouchControls = (context, handContext, fixedStep = 1 / 40) => {
const handedness = handContext.hand;
const pointerId = nextPointerId++;
const enabled = fromStore(handContext.enabled);
const { dispatchers } = getInternalContext();
let hits = [];
const pushHit = (raw, origin, reachSquared, object) => {
const mesh = object;
const geometry = mesh.geometry;
if (geometry === undefined)
return;
if (geometry.boundingSphere === null)
geometry.computeBoundingSphere();
if (geometry.boundingBox === null)
geometry.computeBoundingBox();
if (geometry.boundingSphere === null || geometry.boundingBox === null)
return;
mesh.updateWorldMatrix(true, false);
// Broad-phase: world-space bounding sphere reject.
worldSphere.copy(geometry.boundingSphere).applyMatrix4(mesh.matrixWorld);
const broad = handContext.hoverRadius + worldSphere.radius;
if (origin.distanceToSquared(worldSphere.center) > broad * broad)
return;
// Narrow-phase: closest point on the local-space AABB (so rotation /
// scale are handled exactly), projected back to world.
invMatrix.copy(mesh.matrixWorld).invert();
localOrigin.copy(origin).applyMatrix4(invMatrix);
geometry.boundingBox.clampPoint(localOrigin, localClamped);
surfacePoint.copy(localClamped).applyMatrix4(mesh.matrixWorld);
const distSq = origin.distanceToSquared(surfacePoint);
if (distSq > reachSquared)
return;
raw.push({
distance: Math.sqrt(distSq),
point: surfacePoint.clone(),
object: mesh,
eventObject: mesh,
face: null
});
};
const collectHits = (raw, origin, reachSquared, object, seen) => {
if (seen.has(object.uuid))
return;
seen.add(object.uuid);
pushHit(raw, origin, reachSquared, object);
for (const child of object.children) {
collectHits(raw, origin, reachSquared, child, seen);
}
};
function cancelPointer(intersections) {
if (handContext.hovered.size === 0)
return;
const currentIds = new Set();
for (const hit of intersections) {
currentIds.add(getIntersectionId(hit));
}
const toRemove = [];
for (const [id, hoveredObj] of handContext.hovered) {
if (!currentIds.has(id)) {
toRemove.push([id, hoveredObj]);
}
}
for (const [id, hoveredObj] of toRemove) {
const { eventObject } = hoveredObj;
handContext.hovered.delete(id);
const events = dispatchers.get(eventObject);
if (events !== undefined) {
const data = { ...hoveredObj, intersections };
events.onpointerout?.(data);
events.onpointerleave?.(data);
}
}
if (handContext.hovered.size === 0) {
handContext.pointerOverTarget.set(false);
}
}
// Unlike `pointerControls`, this plugin doesn't publish a per-hand
// `pointerIntersection` global. There's no on-screen cursor for touch — the
// tracked joint is the cursor — so nothing internal needs to subscribe to
// the closest hit. Consumers that want hover state from outside event
// handlers can read the returned `hovered` Map.
const getHits = () => {
if (!handContext.originValid)
return [];
const origin = handContext.origin;
const reach = handContext.hoverRadius;
const reachSquared = reach * reach;
const raw = [];
const seen = new Set();
for (const obj of context.interactiveObjects) {
collectHits(raw, origin, reachSquared, obj, seen);
}
raw.sort((a, b) => a.distance - b.distance);
const filtered = handContext.filter === undefined ? raw : handContext.filter(raw, context, handContext);
const intersections = [];
for (const hit of filtered) {
let eventObject = hit.object;
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)?.onpointermissed?.(event);
}
}
const handleEvent = (name, event) => {
const isPointerMove = name === 'onpointermove';
const isClickEvent = name === 'onclick';
if (isClickEvent) {
pointerMissed(context.interactiveObjects.filter((object) => !handContext.initialHits.includes(object)), event);
}
if (isPointerMove)
cancelPointer(hits);
let stopped = false;
dispatchEvents: for (const hit of hits) {
const events = dispatchers.get(hit.eventObject);
if (events === undefined)
continue;
const intersectionEvent = {
stopped,
...hit,
intersections: hits,
handedness,
pointerId,
stopPropagation() {
stopped = true;
intersectionEvent.stopped = true;
if (handContext.hovered.size > 0 &&
Array.from(handContext.hovered.values()).some((i) => i.eventObject === hit.eventObject)) {
const higher = hits.slice(0, hits.indexOf(hit));
cancelPointer([...higher, hit]);
}
},
delta: 0,
nativeEvent: event,
pointer: handContext.pointer.current,
ray: dummyRay
};
if (isPointerMove) {
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) {
handContext.hovered.set(id, intersectionEvent);
events.onpointerover?.(intersectionEvent);
events.onpointerenter?.(intersectionEvent);
handContext.pointerOverTarget.set(true);
}
else if (hoveredItem.stopped) {
intersectionEvent.stopPropagation();
}
}
events.onpointermove?.(intersectionEvent);
}
else if (events[name] !== undefined) {
if (!isClickEvent || handContext.initialHits.includes(hit.eventObject)) {
events[name]?.(intersectionEvent);
}
}
if (stopped)
break dispatchEvents;
}
};
// Release-phase dispatch uses hits synthesized from `initialHits` (the
// objects that received pointerdown), so pointerup/click fire even if the
// finger has moved past the object — mirrors DOM pointer capture.
const buildCapturedHits = () => {
const [x, y, z] = handContext.initialClick;
return handContext.initialHits.map((object) => ({
distance: 0,
point: new Vector3(x, y, z),
object,
eventObject: object,
face: null
}));
};
const { start, stop } = useFixed(() => {
handContext.compute(context, handContext);
hits = getHits();
// Hover / move every tick — the joint moves continuously, so there is
// no "still pointer" optimization to make here.
handleEvent('onpointermove');
const closest = hits[0];
const shouldBeDown = closest !== undefined && closest.distance < handContext.downRadius;
if (shouldBeDown && !handContext.down) {
handContext.down = true;
handContext.initialClick = [closest.point.x, closest.point.y, closest.point.z];
handContext.initialHits = hits.map((h) => h.eventObject);
handleEvent('onpointerdown');
}
else if (!shouldBeDown && handContext.down) {
handContext.down = false;
const liveHits = hits;
hits = buildCapturedHits();
handleEvent('onpointerup');
handleEvent('onclick');
hits = liveHits;
}
}, {
fixedStep,
autoStart: false
});
observe.pre(() => [isPresenting.current, enabled.current], ([presenting, active]) => {
if (presenting && active) {
start();
}
else {
stop();
}
});
};