UNPKG

@threlte/xr

Version:

Tools to more easily create VR and AR experiences with Threlte

248 lines (247 loc) 10.2 kB
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(); } }); };