forked from EXT/VR180-Web-Player
removed hand specific tracking
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,4 +6,3 @@ dist/
|
||||
vr180player/*.css
|
||||
vr180player/*.js
|
||||
vr180player/**/*.js
|
||||
/media
|
||||
|
||||
@@ -96,7 +96,7 @@ When the page loads, the script binds every `[data-vr-web-launcher]` on the page
|
||||
- Video controls include a loop toggle for indefinite replay.
|
||||
- Static images show only applicable controls; playback, seek, and mute controls are video-only.
|
||||
- Image carousels replace skip buttons with previous/next image controls, so you can change stills without leaving the immersive session.
|
||||
- Controller pointers and lightweight hand/controller overlays appear after controller interaction, then fade away after a short idle period so they do not distract from viewing.
|
||||
- Controller pointers and lightweight controller overlays appear after controller interaction, then fade away after a short idle period so they do not distract from viewing.
|
||||
- Control icons are embedded from [Lucide](https://lucide.dev/) SVG definitions, so no PNG icon assets are required.
|
||||
|
||||
## Demo
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run build && vite --host 0.0.0.0",
|
||||
"build": "tsc && node scripts/copy-styles.mjs",
|
||||
"build": "node scripts/clean-build-output.mjs && tsc && node scripts/copy-styles.mjs",
|
||||
"build:test-app": "npm run build && node scripts/build-test-app.mjs",
|
||||
"check": "tsc --noEmit",
|
||||
"deploy:r2": "npm run build && npm run upload:r2",
|
||||
"test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs tests/hand-aim.test.mjs tests/input-mode.test.mjs tests/icons.test.mjs tests/control-panel-timing.test.mjs tests/launcher.test.mjs",
|
||||
"test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs tests/input-mode.test.mjs tests/icons.test.mjs tests/control-panel-timing.test.mjs tests/launcher.test.mjs",
|
||||
"preview": "npm run build && vite preview --host 127.0.0.1",
|
||||
"upload:r2": "node scripts/upload-r2.mjs",
|
||||
"upload:r2:dry-run": "node scripts/upload-r2.mjs --dry-run"
|
||||
|
||||
7
scripts/clean-build-output.mjs
Normal file
7
scripts/clean-build-output.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
await rm(join(rootDir, 'vr180player'), { force: true, recursive: true });
|
||||
@@ -563,8 +563,7 @@ export class PlayerSession {
|
||||
|
||||
try {
|
||||
const session = await navigator.xr.requestSession('immersive-vr', {
|
||||
requiredFeatures: ['local-floor'],
|
||||
optionalFeatures: ['hand-tracking']
|
||||
requiredFeatures: ['local-floor']
|
||||
});
|
||||
if (!session) { throw new Error('requestSession returned no session.'); }
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
import {
|
||||
computePalmAimRay,
|
||||
type PalmAimRay,
|
||||
type VectorLike
|
||||
} from './hand-aim.js';
|
||||
|
||||
export type AimRay = {
|
||||
direction: any;
|
||||
origin: any;
|
||||
};
|
||||
|
||||
export const DEFAULT_RAY_DIRECTION = new THREE.Vector3(0, 0, -1);
|
||||
|
||||
export function getHandAimRay(hand: any): AimRay | null {
|
||||
const joints = hand?.joints;
|
||||
if (!joints) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const palmAimRay = computePalmAimRay({
|
||||
handedness: getHandedness(hand),
|
||||
indexMetacarpal: getJointWorldPosition(joints['index-finger-metacarpal']),
|
||||
middleMetacarpal: getJointWorldPosition(joints['middle-finger-metacarpal']),
|
||||
pinkyMetacarpal: getJointWorldPosition(joints['pinky-finger-metacarpal']),
|
||||
ringMetacarpal: getJointWorldPosition(joints['ring-finger-metacarpal']),
|
||||
wrist: getJointWorldPosition(joints.wrist)
|
||||
});
|
||||
if (!palmAimRay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toAimRay(palmAimRay);
|
||||
}
|
||||
|
||||
export function toPalmAimRay(ray: AimRay): PalmAimRay {
|
||||
return {
|
||||
direction: fromThreeVector(ray.direction),
|
||||
origin: fromThreeVector(ray.origin)
|
||||
};
|
||||
}
|
||||
|
||||
export function toAimRay(ray: PalmAimRay): AimRay {
|
||||
return {
|
||||
direction: toThreeVector(ray.direction),
|
||||
origin: toThreeVector(ray.origin)
|
||||
};
|
||||
}
|
||||
|
||||
export function rememberHandedness(hand: any, event: any): void {
|
||||
const handedness = event?.data?.handedness ||
|
||||
event?.data?.inputSource?.handedness ||
|
||||
hand?.inputState?.handedness;
|
||||
|
||||
if (handedness !== 'left' && handedness !== 'right') {
|
||||
return;
|
||||
}
|
||||
|
||||
hand.userData = {
|
||||
...hand.userData,
|
||||
vrwpHandedness: handedness
|
||||
};
|
||||
}
|
||||
|
||||
export function getHandedness(hand: any): string | undefined {
|
||||
return hand?.userData?.vrwpHandedness ||
|
||||
hand?.inputState?.handedness ||
|
||||
hand?.userData?.inputSource?.handedness;
|
||||
}
|
||||
|
||||
export function getJointWorldPosition(joint: any): VectorLike | null {
|
||||
if (!joint?.getWorldPosition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
joint.updateMatrixWorld?.(true);
|
||||
return joint.getWorldPosition(new THREE.Vector3());
|
||||
}
|
||||
|
||||
export function toThreeVector(vector: VectorLike): any {
|
||||
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
||||
}
|
||||
|
||||
export function fromThreeVector(vector: any): VectorLike {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
export type VectorLike = {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
|
||||
export type PalmAimInput = {
|
||||
handedness?: string | null;
|
||||
indexMetacarpal?: VectorLike | null;
|
||||
middleMetacarpal?: VectorLike | null;
|
||||
pinkyMetacarpal?: VectorLike | null;
|
||||
ringMetacarpal?: VectorLike | null;
|
||||
wrist?: VectorLike | null;
|
||||
};
|
||||
|
||||
export type PalmAimRay = {
|
||||
direction: VectorLike;
|
||||
origin: VectorLike;
|
||||
};
|
||||
|
||||
export type TimedPalmAimRay = PalmAimRay & {
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type PalmAimLatch = {
|
||||
isSelecting: boolean;
|
||||
selectedRay: TimedPalmAimRay | null;
|
||||
stableRay: TimedPalmAimRay | null;
|
||||
};
|
||||
|
||||
const MIN_AXIS_LENGTH_SQ = 0.000001;
|
||||
const PALM_AIM_FORWARD_TILT_DEGREES = 40;
|
||||
const PALM_SURFACE_OFFSET_METERS = 0.035;
|
||||
export const DEFAULT_PALM_AIM_LATCH_MAX_AGE_MS = 300;
|
||||
|
||||
export function computePalmAimRay(input: PalmAimInput): PalmAimRay | null {
|
||||
const { indexMetacarpal, pinkyMetacarpal, wrist } = input;
|
||||
if (!indexMetacarpal || !pinkyMetacarpal || !wrist) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const knuckleCenter = averageVectors([
|
||||
indexMetacarpal,
|
||||
input.middleMetacarpal,
|
||||
input.ringMetacarpal,
|
||||
pinkyMetacarpal
|
||||
]);
|
||||
if (!knuckleCenter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fingerAxis = normalize(subtract(knuckleCenter, wrist));
|
||||
const acrossPalmAxis = normalize(subtract(pinkyMetacarpal, indexMetacarpal));
|
||||
if (!fingerAxis || !acrossPalmAxis) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const direction = normalize(getTiltedPalmDirection(
|
||||
getPalmNormal(fingerAxis, acrossPalmAxis, input.handedness),
|
||||
fingerAxis
|
||||
));
|
||||
if (!direction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const palmCenter = lerp(wrist, knuckleCenter, 0.62);
|
||||
const origin = add(palmCenter, scale(direction, PALM_SURFACE_OFFSET_METERS));
|
||||
return { direction, origin };
|
||||
}
|
||||
|
||||
export function createPalmAimLatch(): PalmAimLatch {
|
||||
return {
|
||||
isSelecting: false,
|
||||
selectedRay: null,
|
||||
stableRay: null
|
||||
};
|
||||
}
|
||||
|
||||
export function recordStablePalmAimRay(
|
||||
latch: PalmAimLatch | null | undefined,
|
||||
ray: PalmAimRay,
|
||||
timestamp: number
|
||||
): void {
|
||||
if (!latch) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (latch.isSelecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
latch.stableRay = withTimestamp(ray, timestamp);
|
||||
}
|
||||
|
||||
export function beginPalmAimSelection(
|
||||
latch: PalmAimLatch | null | undefined,
|
||||
timestamp: number,
|
||||
maxAgeMs = DEFAULT_PALM_AIM_LATCH_MAX_AGE_MS
|
||||
): PalmAimRay | null {
|
||||
if (!latch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
latch.isSelecting = true;
|
||||
const stableRay = getFreshTimedRay(latch.stableRay, timestamp, maxAgeMs);
|
||||
latch.selectedRay = stableRay ? cloneTimedRay(stableRay) : null;
|
||||
return latch.selectedRay ? clonePalmAimRay(latch.selectedRay) : null;
|
||||
}
|
||||
|
||||
export function endPalmAimSelection(latch: PalmAimLatch | null | undefined): void {
|
||||
if (!latch) {
|
||||
return;
|
||||
}
|
||||
|
||||
latch.isSelecting = false;
|
||||
latch.selectedRay = null;
|
||||
}
|
||||
|
||||
export function getPalmAimSelectionRay(
|
||||
latch: PalmAimLatch | null | undefined,
|
||||
timestamp: number,
|
||||
maxAgeMs = DEFAULT_PALM_AIM_LATCH_MAX_AGE_MS
|
||||
): PalmAimRay | null {
|
||||
if (!latch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (latch.isSelecting && latch.selectedRay) {
|
||||
return clonePalmAimRay(latch.selectedRay);
|
||||
}
|
||||
|
||||
const stableRay = getFreshTimedRay(latch.stableRay, timestamp, maxAgeMs);
|
||||
return stableRay ? clonePalmAimRay(stableRay) : null;
|
||||
}
|
||||
|
||||
function getPalmNormal(fingerAxis: VectorLike, acrossPalmAxis: VectorLike, handedness?: string | null): VectorLike {
|
||||
if (handedness === 'left') {
|
||||
return cross(acrossPalmAxis, fingerAxis);
|
||||
}
|
||||
|
||||
return cross(fingerAxis, acrossPalmAxis);
|
||||
}
|
||||
|
||||
function getTiltedPalmDirection(palmNormal: VectorLike, fingerAxis: VectorLike): VectorLike {
|
||||
const tiltRadians = (PALM_AIM_FORWARD_TILT_DEGREES * Math.PI) / 180;
|
||||
return add(
|
||||
scale(palmNormal, Math.cos(tiltRadians)),
|
||||
scale(fingerAxis, Math.sin(tiltRadians))
|
||||
);
|
||||
}
|
||||
|
||||
function withTimestamp(ray: PalmAimRay, timestamp: number): TimedPalmAimRay {
|
||||
return {
|
||||
...clonePalmAimRay(ray),
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
|
||||
function cloneTimedRay(ray: TimedPalmAimRay): TimedPalmAimRay {
|
||||
return {
|
||||
...clonePalmAimRay(ray),
|
||||
timestamp: ray.timestamp
|
||||
};
|
||||
}
|
||||
|
||||
function clonePalmAimRay(ray: PalmAimRay): PalmAimRay {
|
||||
return {
|
||||
direction: cloneVector(ray.direction),
|
||||
origin: cloneVector(ray.origin)
|
||||
};
|
||||
}
|
||||
|
||||
function cloneVector(vector: VectorLike): VectorLike {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z
|
||||
};
|
||||
}
|
||||
|
||||
function getFreshTimedRay(
|
||||
ray: TimedPalmAimRay | null,
|
||||
timestamp: number,
|
||||
maxAgeMs: number
|
||||
): TimedPalmAimRay | null {
|
||||
if (!ray) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ageMs = Math.max(0, timestamp - ray.timestamp);
|
||||
return ageMs <= maxAgeMs ? ray : null;
|
||||
}
|
||||
|
||||
function averageVectors(vectors: Array<VectorLike | null | undefined>): VectorLike | null {
|
||||
const usableVectors = vectors.filter(Boolean) as VectorLike[];
|
||||
if (usableVectors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = usableVectors.reduce(
|
||||
(sum, vector) => add(sum, vector),
|
||||
{ x: 0, y: 0, z: 0 }
|
||||
);
|
||||
return scale(total, 1 / usableVectors.length);
|
||||
}
|
||||
|
||||
function normalize(vector: VectorLike): VectorLike | null {
|
||||
const lengthSq = vector.x * vector.x + vector.y * vector.y + vector.z * vector.z;
|
||||
if (lengthSq < MIN_AXIS_LENGTH_SQ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const length = Math.sqrt(lengthSq);
|
||||
return scale(vector, 1 / length);
|
||||
}
|
||||
|
||||
function add(a: VectorLike, b: VectorLike): VectorLike {
|
||||
return {
|
||||
x: a.x + b.x,
|
||||
y: a.y + b.y,
|
||||
z: a.z + b.z
|
||||
};
|
||||
}
|
||||
|
||||
function subtract(a: VectorLike, b: VectorLike): VectorLike {
|
||||
return {
|
||||
x: a.x - b.x,
|
||||
y: a.y - b.y,
|
||||
z: a.z - b.z
|
||||
};
|
||||
}
|
||||
|
||||
function scale(vector: VectorLike, scalar: number): VectorLike {
|
||||
return {
|
||||
x: vector.x * scalar,
|
||||
y: vector.y * scalar,
|
||||
z: vector.z * scalar
|
||||
};
|
||||
}
|
||||
|
||||
function lerp(a: VectorLike, b: VectorLike, amount: number): VectorLike {
|
||||
return {
|
||||
x: a.x + (b.x - a.x) * amount,
|
||||
y: a.y + (b.y - a.y) * amount,
|
||||
z: a.z + (b.z - a.z) * amount
|
||||
};
|
||||
}
|
||||
|
||||
function cross(a: VectorLike, b: VectorLike): VectorLike {
|
||||
return {
|
||||
x: a.y * b.z - a.z * b.y,
|
||||
y: a.z * b.x - a.x * b.z,
|
||||
z: a.x * b.y - a.y * b.x
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type PointerInputMode = 'controller' | 'hand';
|
||||
export type PointerInputMode = 'controller';
|
||||
|
||||
export type PointerInputModeCarrier = {
|
||||
controller?: {
|
||||
@@ -31,22 +31,9 @@ export function getPointerInputMode(eventInputSource: any): PointerInputMode | n
|
||||
return null;
|
||||
}
|
||||
|
||||
if (eventInputSource.hand) {
|
||||
return 'hand';
|
||||
}
|
||||
|
||||
if (Array.isArray(eventInputSource.profiles) &&
|
||||
eventInputSource.profiles.some((profile: string) => profile.toLowerCase().includes('hand'))) {
|
||||
return 'hand';
|
||||
}
|
||||
|
||||
if (eventInputSource.gamepad || eventInputSource.targetRayMode === 'tracked-pointer') {
|
||||
return 'controller';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldUseHandPointer(inputSource: PointerInputModeCarrier | undefined): boolean {
|
||||
return inputSource?.pointerInputMode === 'hand';
|
||||
}
|
||||
|
||||
@@ -3,36 +3,14 @@ import {
|
||||
getSeekProgressFromIntersection,
|
||||
type VrControlPanel
|
||||
} from './vr-control-panel.js';
|
||||
import {
|
||||
beginPalmAimSelection,
|
||||
createPalmAimLatch,
|
||||
endPalmAimSelection,
|
||||
getPalmAimSelectionRay,
|
||||
recordStablePalmAimRay,
|
||||
type PalmAimLatch
|
||||
} from './hand-aim.js';
|
||||
import {
|
||||
DEFAULT_RAY_DIRECTION,
|
||||
getHandAimRay,
|
||||
rememberHandedness,
|
||||
toAimRay,
|
||||
toPalmAimRay,
|
||||
type AimRay
|
||||
} from './hand-aim-three.js';
|
||||
import {
|
||||
rememberPointerInputMode,
|
||||
shouldUseHandPointer,
|
||||
type PointerInputMode
|
||||
} from './input-mode.js';
|
||||
import { rememberPointerInputMode } from './input-mode.js';
|
||||
import {
|
||||
bindOverlayActivity,
|
||||
createControllerOverlay,
|
||||
createHandOverlay,
|
||||
createPointerOverlay,
|
||||
createWorldPointerOverlay,
|
||||
POINTER_HIT_SURFACE_OFFSET,
|
||||
getPointerIntersectionLength,
|
||||
POINTER_LENGTH,
|
||||
POINTER_MIN_LENGTH,
|
||||
resetInputPointerLengths,
|
||||
setPointerOverlayLength,
|
||||
VrOverlayVisibility
|
||||
} from './pointer-overlays.js';
|
||||
@@ -51,28 +29,21 @@ type ActiveSeekDrag = {
|
||||
panel: VrControlPanel;
|
||||
};
|
||||
|
||||
type HandPointerOverlay = {
|
||||
fallbackPointerOverlay: any;
|
||||
hand: any;
|
||||
handAimLatch: PalmAimLatch;
|
||||
inputSource: VrInputSource;
|
||||
pointerOverlay: any;
|
||||
type AimRay = {
|
||||
direction: any;
|
||||
origin: any;
|
||||
};
|
||||
|
||||
type VrInputSource = {
|
||||
controller: any;
|
||||
controllerPointerOverlay: any;
|
||||
hand?: any;
|
||||
handAimLatch?: PalmAimLatch;
|
||||
handPointerOverlay?: any;
|
||||
pointerInputMode: PointerInputMode;
|
||||
pointerInputMode: 'controller';
|
||||
};
|
||||
|
||||
const tempMatrix = new THREE.Matrix4();
|
||||
|
||||
export function createVrInputRig(scene: any, renderer: any, onSelectStart: (event: any) => void): VrInputRig {
|
||||
const overlayVisibility = new VrOverlayVisibility();
|
||||
const handPointerOverlays: HandPointerOverlay[] = [];
|
||||
const inputSources: VrInputSource[] = [];
|
||||
const raycaster = createPointerRaycaster();
|
||||
const hoverRaycaster = createPointerRaycaster();
|
||||
@@ -97,23 +68,15 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
||||
rememberPointerInputMode(inputSource, event, 'controller');
|
||||
});
|
||||
controller.addEventListener('selectstart', (event: any) => {
|
||||
const timestamp = getEventTimestamp(event);
|
||||
rememberPointerInputMode(inputSource, event, inputSource.pointerInputMode);
|
||||
if (shouldUseHandPointer(inputSource)) {
|
||||
beginPalmAimSelection(controller.userData?.vrwpHandAimLatch, timestamp);
|
||||
}
|
||||
overlayVisibility.show(timestamp);
|
||||
rememberPointerInputMode(inputSource, event, 'controller');
|
||||
overlayVisibility.show(getEventTimestamp(event));
|
||||
onSelectStart(event);
|
||||
});
|
||||
controller.addEventListener('selectend', () => {
|
||||
endPalmAimSelection(controller.userData?.vrwpHandAimLatch);
|
||||
if (activeSeekDrag?.inputSource.controller === controller) {
|
||||
activeSeekDrag = null;
|
||||
}
|
||||
});
|
||||
controller.addEventListener('select', () => {
|
||||
endPalmAimSelection(controller.userData?.vrwpHandAimLatch);
|
||||
});
|
||||
bindOverlayActivity(controller, overlayVisibility);
|
||||
controller.add(controllerPointerOverlay);
|
||||
scene.add(controller);
|
||||
@@ -127,43 +90,6 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
||||
grip.add(createControllerOverlay(index, overlayVisibility));
|
||||
scene.add(grip);
|
||||
}
|
||||
|
||||
const hand = renderer.xr.getHand?.(index);
|
||||
if (hand) {
|
||||
const handAimLatch = createPalmAimLatch();
|
||||
inputSource.hand = hand;
|
||||
inputSource.handAimLatch = handAimLatch;
|
||||
controller.userData = {
|
||||
...controller.userData,
|
||||
vrwpHand: hand,
|
||||
vrwpHandAimLatch: handAimLatch
|
||||
};
|
||||
hand.userData = {
|
||||
...hand.userData,
|
||||
vrwpAimLatch: handAimLatch
|
||||
};
|
||||
bindOverlayActivity(hand, overlayVisibility);
|
||||
rememberHandedness(hand, { data: hand.inputState });
|
||||
createHandOverlay(hand, index, overlayVisibility);
|
||||
hand.addEventListener?.('connected', (event: any) => {
|
||||
rememberPointerInputMode(inputSource, event, 'hand');
|
||||
rememberHandedness(hand, event);
|
||||
createHandOverlay(hand, index, overlayVisibility);
|
||||
overlayVisibility.show();
|
||||
});
|
||||
scene.add(hand);
|
||||
|
||||
const handPointerOverlay = createWorldPointerOverlay(index, overlayVisibility);
|
||||
inputSource.handPointerOverlay = handPointerOverlay;
|
||||
scene.add(handPointerOverlay);
|
||||
handPointerOverlays.push({
|
||||
fallbackPointerOverlay: controllerPointerOverlay,
|
||||
hand,
|
||||
handAimLatch,
|
||||
inputSource,
|
||||
pointerOverlay: handPointerOverlay
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
overlayVisibility.hideImmediately();
|
||||
@@ -182,9 +108,8 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
||||
raycaster,
|
||||
showOverlays: (timestamp?: number) => overlayVisibility.show(timestamp),
|
||||
update: (timestamp: number, hoverTargets: any[] = []) => {
|
||||
updateHandPointerOverlays(handPointerOverlays, timestamp);
|
||||
updateActiveSeekDrag(activeSeekDrag, dragRaycaster, timestamp);
|
||||
const isHovering = updateInputPointerIntersections(inputSources, hoverTargets, hoverRaycaster, timestamp);
|
||||
updateActiveSeekDrag(activeSeekDrag, dragRaycaster);
|
||||
const isHovering = updateInputPointerIntersections(inputSources, hoverTargets, hoverRaycaster);
|
||||
if (isHovering) {
|
||||
overlayVisibility.show(timestamp);
|
||||
}
|
||||
@@ -208,15 +133,14 @@ function getInputSourceByController(inputSources: VrInputSource[], controller: a
|
||||
function updateInputPointerIntersections(
|
||||
inputSources: VrInputSource[],
|
||||
hoverTargets: any[],
|
||||
hoverRaycaster: any,
|
||||
timestamp: number
|
||||
hoverRaycaster: any
|
||||
): boolean {
|
||||
let isHoveringAnyTarget = false;
|
||||
|
||||
inputSources.forEach((inputSource) => {
|
||||
resetInputPointerLengths(inputSource);
|
||||
const aimRay = getInputSourceAimRay(inputSource, timestamp);
|
||||
const pointerOverlay = getActivePointerOverlay(inputSource);
|
||||
const aimRay = getControllerAimRay(inputSource.controller);
|
||||
const pointerOverlay = inputSource.controllerPointerOverlay;
|
||||
if (!aimRay || !pointerOverlay || hoverTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -228,25 +152,26 @@ function updateInputPointerIntersections(
|
||||
return;
|
||||
}
|
||||
|
||||
isHoveringAnyTarget = true;
|
||||
setPointerOverlayLength(pointerOverlay, getPointerIntersectionLength(intersections[0].distance));
|
||||
isHoveringAnyTarget = true;
|
||||
});
|
||||
|
||||
return isHoveringAnyTarget;
|
||||
}
|
||||
|
||||
function updateActiveSeekDrag(activeSeekDrag: ActiveSeekDrag | null, dragRaycaster: any, timestamp: number): void {
|
||||
function updateActiveSeekDrag(activeSeekDrag: ActiveSeekDrag | null, dragRaycaster: any): void {
|
||||
if (!activeSeekDrag?.panel.seekBarHitAreaMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const aimRay = getInputSourceAimRay(activeSeekDrag.inputSource, timestamp, { preferLiveHandAim: true });
|
||||
const aimRay = getControllerAimRay(activeSeekDrag.inputSource.controller);
|
||||
if (!aimRay) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragRaycaster.ray.origin.copy(aimRay.origin);
|
||||
dragRaycaster.ray.direction.copy(aimRay.direction);
|
||||
|
||||
const intersections = dragRaycaster.intersectObject(activeSeekDrag.panel.seekBarHitAreaMesh, true);
|
||||
if (intersections.length === 0) {
|
||||
return;
|
||||
@@ -255,33 +180,6 @@ function updateActiveSeekDrag(activeSeekDrag: ActiveSeekDrag | null, dragRaycast
|
||||
activeSeekDrag.onSeek(getSeekProgressFromIntersection(activeSeekDrag.panel, intersections[0].point));
|
||||
}
|
||||
|
||||
function getInputSourceAimRay(
|
||||
inputSource: VrInputSource,
|
||||
timestamp: number,
|
||||
{ preferLiveHandAim = false }: { preferLiveHandAim?: boolean } = {}
|
||||
): AimRay | null {
|
||||
if (shouldUseHandPointer(inputSource) && inputSource.hand && inputSource.handAimLatch) {
|
||||
if (preferLiveHandAim) {
|
||||
const handRay = getHandAimRay(inputSource.hand);
|
||||
if (handRay) {
|
||||
return handRay;
|
||||
}
|
||||
}
|
||||
|
||||
const latchedRay = getPalmAimSelectionRay(inputSource.handAimLatch, timestamp);
|
||||
if (latchedRay) {
|
||||
return toAimRay(latchedRay);
|
||||
}
|
||||
|
||||
const handRay = getHandAimRay(inputSource.hand);
|
||||
if (handRay) {
|
||||
return handRay;
|
||||
}
|
||||
}
|
||||
|
||||
return getControllerAimRay(inputSource.controller);
|
||||
}
|
||||
|
||||
function getControllerAimRay(controller: any): AimRay | null {
|
||||
if (!controller) {
|
||||
return null;
|
||||
@@ -294,72 +192,6 @@ function getControllerAimRay(controller: any): AimRay | null {
|
||||
return { direction, origin };
|
||||
}
|
||||
|
||||
function resetInputPointerLengths(inputSource: VrInputSource): void {
|
||||
setPointerOverlayLength(inputSource.controllerPointerOverlay, POINTER_LENGTH);
|
||||
if (inputSource.handPointerOverlay) {
|
||||
setPointerOverlayLength(inputSource.handPointerOverlay, POINTER_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
function getActivePointerOverlay(inputSource: VrInputSource): any {
|
||||
if (inputSource.handPointerOverlay && inputSource.handPointerOverlay.userData?.vrwpOverlayAvailable !== false) {
|
||||
return inputSource.handPointerOverlay;
|
||||
}
|
||||
|
||||
if (inputSource.controllerPointerOverlay && inputSource.controllerPointerOverlay.userData?.vrwpOverlayAvailable !== false) {
|
||||
return inputSource.controllerPointerOverlay;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPointerIntersectionLength(distance: number): number {
|
||||
return Math.max(
|
||||
POINTER_MIN_LENGTH,
|
||||
Math.min(POINTER_LENGTH, distance - POINTER_HIT_SURFACE_OFFSET)
|
||||
);
|
||||
}
|
||||
|
||||
function updateHandPointerOverlays(handPointerOverlays: HandPointerOverlay[], timestamp: number): void {
|
||||
handPointerOverlays.forEach(({ fallbackPointerOverlay, hand, handAimLatch, inputSource, pointerOverlay }) => {
|
||||
if (!shouldUseHandPointer(inputSource)) {
|
||||
pointerOverlay.userData = {
|
||||
...pointerOverlay.userData,
|
||||
vrwpOverlayAvailable: false
|
||||
};
|
||||
fallbackPointerOverlay.userData = {
|
||||
...fallbackPointerOverlay.userData,
|
||||
vrwpOverlayAvailable: true
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const currentHandRay = getHandAimRay(hand);
|
||||
if (currentHandRay) {
|
||||
recordStablePalmAimRay(handAimLatch, toPalmAimRay(currentHandRay), timestamp);
|
||||
}
|
||||
|
||||
const selectionPalmRay = getPalmAimSelectionRay(handAimLatch, timestamp);
|
||||
const displayHandRay = selectionPalmRay ? toAimRay(selectionPalmRay) : currentHandRay;
|
||||
const hasHandRay = Boolean(displayHandRay);
|
||||
pointerOverlay.userData = {
|
||||
...pointerOverlay.userData,
|
||||
vrwpOverlayAvailable: hasHandRay
|
||||
};
|
||||
fallbackPointerOverlay.userData = {
|
||||
...fallbackPointerOverlay.userData,
|
||||
vrwpOverlayAvailable: !hasHandRay
|
||||
};
|
||||
|
||||
if (!displayHandRay) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerOverlay.position.copy(displayHandRay.origin);
|
||||
pointerOverlay.quaternion.setFromUnitVectors(DEFAULT_RAY_DIRECTION, displayHandRay.direction);
|
||||
});
|
||||
}
|
||||
|
||||
function getEventTimestamp(event: any): number {
|
||||
return Number.isFinite(event?.timeStamp) ? event.timeStamp : performance.now();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
|
||||
export type PointerOverlayInputSource = {
|
||||
controllerPointerOverlay: any;
|
||||
handPointerOverlay?: any;
|
||||
};
|
||||
|
||||
export type VrOverlayVisibilityOptions = {
|
||||
@@ -17,14 +16,6 @@ export const POINTER_LENGTH = 5;
|
||||
export const POINTER_MIN_LENGTH = 0.06;
|
||||
export const POINTER_HIT_SURFACE_OFFSET = 0.015;
|
||||
|
||||
const HAND_JOINT_NAMES = [
|
||||
'wrist',
|
||||
'thumb-tip',
|
||||
'index-finger-tip',
|
||||
'middle-finger-tip',
|
||||
'pinky-finger-tip'
|
||||
];
|
||||
|
||||
export class VrOverlayVisibility {
|
||||
private readonly fadeDurationMs: number;
|
||||
private readonly hideDelayMs: number;
|
||||
@@ -121,9 +112,7 @@ export function bindOverlayActivity(target: any, overlayVisibility: VrOverlayVis
|
||||
'selectend',
|
||||
'squeezestart',
|
||||
'squeeze',
|
||||
'squeezeend',
|
||||
'pinchstart',
|
||||
'pinchend'
|
||||
'squeezeend'
|
||||
].forEach((eventName) => {
|
||||
target.addEventListener?.(eventName, () => overlayVisibility.show());
|
||||
});
|
||||
@@ -161,16 +150,6 @@ export function createPointerOverlay(index: number, overlayVisibility: VrOverlay
|
||||
return group;
|
||||
}
|
||||
|
||||
export function createWorldPointerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
|
||||
const group = createPointerOverlay(index, overlayVisibility);
|
||||
group.name = `vrHandPointerOverlay${index}`;
|
||||
group.userData = {
|
||||
...group.userData,
|
||||
vrwpOverlayAvailable: false
|
||||
};
|
||||
return group;
|
||||
}
|
||||
|
||||
export function createControllerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
|
||||
const group = new THREE.Group();
|
||||
group.name = `vrControllerOverlay${index}`;
|
||||
@@ -200,39 +179,8 @@ export function createControllerOverlay(index: number, overlayVisibility: VrOver
|
||||
return group;
|
||||
}
|
||||
|
||||
export function createHandOverlay(hand: any, index: number, overlayVisibility: VrOverlayVisibility): void {
|
||||
const joints = getHandJoints(hand);
|
||||
if (joints.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const material = createOverlayMeshMaterial(index === 0 ? 0xb9e8ff : 0xffd99a, 0.26);
|
||||
joints.forEach(({ joint, name }) => {
|
||||
if (!joint || joint.userData?.vrwpHandOverlayMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTip = name.endsWith('tip');
|
||||
const isWrist = name === 'wrist';
|
||||
const radius = isWrist ? 0.008 : isTip ? 0.006 : 0.005;
|
||||
const marker = new THREE.Mesh(new THREE.SphereGeometry(radius, 8, 5), material);
|
||||
marker.name = `vrHandJointOverlay${index}-${name}`;
|
||||
marker.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 2;
|
||||
marker.frustumCulled = false;
|
||||
joint.add(marker);
|
||||
joint.userData = {
|
||||
...joint.userData,
|
||||
vrwpHandOverlayMarker: marker
|
||||
};
|
||||
overlayVisibility.register(marker);
|
||||
});
|
||||
}
|
||||
|
||||
export function resetInputPointerLengths(inputSource: PointerOverlayInputSource): void {
|
||||
setPointerOverlayLength(inputSource.controllerPointerOverlay, POINTER_LENGTH);
|
||||
if (inputSource.handPointerOverlay) {
|
||||
setPointerOverlayLength(inputSource.handPointerOverlay, POINTER_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
export function getPointerIntersectionLength(distance: number): number {
|
||||
@@ -302,22 +250,3 @@ function getOverlayMaterialMaxOpacity(material: any): number {
|
||||
const maxOpacity = material.userData?.vrwpOverlayMaxOpacity;
|
||||
return Number.isFinite(maxOpacity) ? maxOpacity : 1;
|
||||
}
|
||||
|
||||
function getHandJoints(hand: any): Array<{ joint: any; name: string }> {
|
||||
const joints = hand?.joints;
|
||||
if (!joints) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const namedJoints = HAND_JOINT_NAMES
|
||||
.map((name) => ({ joint: joints[name], name }))
|
||||
.filter(({ joint }) => Boolean(joint));
|
||||
|
||||
if (namedJoints.length > 0) {
|
||||
return namedJoints;
|
||||
}
|
||||
|
||||
return Object.entries(joints)
|
||||
.map(([name, joint]) => ({ joint, name }))
|
||||
.filter(({ joint }) => Boolean(joint));
|
||||
}
|
||||
|
||||
@@ -3,13 +3,6 @@ import {
|
||||
getSeekProgressFromIntersection,
|
||||
type VrControlPanel
|
||||
} from './vr-control-panel.js';
|
||||
import { getPalmAimSelectionRay } from './hand-aim.js';
|
||||
import {
|
||||
getHandAimRay,
|
||||
toAimRay,
|
||||
type AimRay
|
||||
} from './hand-aim-three.js';
|
||||
import { shouldUseHandPointer } from './input-mode.js';
|
||||
|
||||
type VrControllerSelectionOptions = {
|
||||
beginSeekDrag?: (controller: any) => void;
|
||||
@@ -100,28 +93,8 @@ function togglePanel(options: VrControllerSelectionOptions): void {
|
||||
}
|
||||
|
||||
function applySelectionRay(controller: any, raycaster: any): void {
|
||||
const handRay = shouldUseHandPointer(controller.userData?.vrwpInputSource)
|
||||
? getSelectionHandAimRay(controller) || getHandAimRay(controller.userData?.vrwpHand)
|
||||
: null;
|
||||
if (handRay) {
|
||||
raycaster.ray.origin.copy(handRay.origin);
|
||||
raycaster.ray.direction.copy(handRay.direction);
|
||||
return;
|
||||
}
|
||||
|
||||
controller.updateMatrixWorld();
|
||||
tempMatrix.identity().extractRotation(controller.matrixWorld);
|
||||
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
|
||||
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
|
||||
}
|
||||
|
||||
function getSelectionHandAimRay(controller: any): AimRay | null {
|
||||
const latch = controller.userData?.vrwpHandAimLatch ||
|
||||
controller.userData?.vrwpHand?.userData?.vrwpAimLatch;
|
||||
if (!latch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const palmAimRay = getPalmAimSelectionRay(latch, performance.now());
|
||||
return palmAimRay ? toAimRay(palmAimRay) : null;
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
beginPalmAimSelection,
|
||||
computePalmAimRay,
|
||||
createPalmAimLatch,
|
||||
endPalmAimSelection,
|
||||
getPalmAimSelectionRay,
|
||||
recordStablePalmAimRay
|
||||
} from '../vr180player/xr/hand-aim.js';
|
||||
|
||||
const wrist = { x: 0, y: 0, z: 0 };
|
||||
const middleMetacarpal = { x: 0, y: 0.1, z: 0 };
|
||||
const ringMetacarpal = { x: 0.025, y: 0.1, z: 0 };
|
||||
const straightAheadRay = {
|
||||
direction: { x: 0, y: 0, z: -1 },
|
||||
origin: { x: 0, y: 1.4, z: -0.04 }
|
||||
};
|
||||
const pinchedRay = {
|
||||
direction: { x: 0.4, y: -0.2, z: -0.8 },
|
||||
origin: { x: 0.04, y: 1.32, z: -0.03 }
|
||||
};
|
||||
|
||||
test('computePalmAimRay tilts a right palm ray toward the finger-forward axis', () => {
|
||||
const ray = computePalmAimRay({
|
||||
handedness: 'right',
|
||||
indexMetacarpal: { x: -0.045, y: 0.1, z: 0 },
|
||||
middleMetacarpal: { x: -0.015, y: 0.1, z: 0 },
|
||||
pinkyMetacarpal: { x: 0.045, y: 0.1, z: 0 },
|
||||
ringMetacarpal: { x: 0.015, y: 0.1, z: 0 },
|
||||
wrist
|
||||
});
|
||||
|
||||
assert.ok(ray);
|
||||
assert.equal(Math.round(ray.direction.x * 1000) / 1000, 0);
|
||||
assert.equal(Math.round(ray.direction.y * 1000) / 1000, 0.643);
|
||||
assert.equal(Math.round(ray.direction.z * 1000) / 1000, -0.766);
|
||||
assert.equal(Math.round(ray.origin.y * 1000) / 1000, 0.084);
|
||||
assert.equal(Math.round(ray.origin.z * 1000) / 1000, -0.027);
|
||||
});
|
||||
|
||||
test('computePalmAimRay flips the palm normal for a left hand while keeping forward tilt', () => {
|
||||
const ray = computePalmAimRay({
|
||||
handedness: 'left',
|
||||
indexMetacarpal: { x: 0.045, y: 0.1, z: 0 },
|
||||
middleMetacarpal: { x: 0.015, y: 0.1, z: 0 },
|
||||
pinkyMetacarpal: { x: -0.045, y: 0.1, z: 0 },
|
||||
ringMetacarpal: { x: -0.015, y: 0.1, z: 0 },
|
||||
wrist
|
||||
});
|
||||
|
||||
assert.ok(ray);
|
||||
assert.equal(Math.round(ray.direction.y * 1000) / 1000, 0.643);
|
||||
assert.equal(Math.round(ray.direction.z * 1000) / 1000, -0.766);
|
||||
});
|
||||
|
||||
test('computePalmAimRay returns null when palm joints cannot define a stable ray', () => {
|
||||
assert.equal(computePalmAimRay({ handedness: 'right', wrist }), null);
|
||||
assert.equal(computePalmAimRay({
|
||||
handedness: 'right',
|
||||
indexMetacarpal: { x: 0, y: 0.1, z: 0 },
|
||||
pinkyMetacarpal: { x: 0, y: 0.1, z: 0 },
|
||||
wrist
|
||||
}), null);
|
||||
});
|
||||
|
||||
test('palm aim latch returns the latest fresh stable ray', () => {
|
||||
const latch = createPalmAimLatch();
|
||||
|
||||
recordStablePalmAimRay(latch, straightAheadRay, 100);
|
||||
|
||||
assert.deepEqual(getPalmAimSelectionRay(latch, 120), straightAheadRay);
|
||||
assert.equal(getPalmAimSelectionRay(latch, 500), null);
|
||||
});
|
||||
|
||||
test('palm aim latch freezes the pre-selection ray while selecting', () => {
|
||||
const latch = createPalmAimLatch();
|
||||
|
||||
recordStablePalmAimRay(latch, straightAheadRay, 100);
|
||||
assert.deepEqual(beginPalmAimSelection(latch, 120), straightAheadRay);
|
||||
|
||||
recordStablePalmAimRay(latch, pinchedRay, 130);
|
||||
|
||||
assert.deepEqual(getPalmAimSelectionRay(latch, 140), straightAheadRay);
|
||||
assert.deepEqual(getPalmAimSelectionRay(latch, 1000), straightAheadRay);
|
||||
|
||||
endPalmAimSelection(latch);
|
||||
recordStablePalmAimRay(latch, pinchedRay, 150);
|
||||
|
||||
assert.deepEqual(getPalmAimSelectionRay(latch, 160), pinchedRay);
|
||||
});
|
||||
|
||||
test('palm aim latch ignores stale rays when selection starts too late', () => {
|
||||
const latch = createPalmAimLatch();
|
||||
|
||||
recordStablePalmAimRay(latch, straightAheadRay, 100);
|
||||
|
||||
assert.equal(beginPalmAimSelection(latch, 450), null);
|
||||
assert.equal(getPalmAimSelectionRay(latch, 450), null);
|
||||
});
|
||||
@@ -3,14 +3,13 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getPointerInputMode,
|
||||
rememberPointerInputMode,
|
||||
shouldUseHandPointer
|
||||
rememberPointerInputMode
|
||||
} from '../vr180player/xr/input-mode.js';
|
||||
|
||||
test('getPointerInputMode detects WebXR hand sources', () => {
|
||||
assert.equal(getPointerInputMode({ hand: {} }), 'hand');
|
||||
assert.equal(getPointerInputMode({ profiles: ['generic-hand-tracking'] }), 'hand');
|
||||
assert.equal(getPointerInputMode({ profiles: ['Oculus-Hand'] }), 'hand');
|
||||
test('getPointerInputMode ignores WebXR hand sources', () => {
|
||||
assert.equal(getPointerInputMode({ hand: {} }), null);
|
||||
assert.equal(getPointerInputMode({ profiles: ['generic-hand-tracking'] }), null);
|
||||
assert.equal(getPointerInputMode({ profiles: ['Oculus-Hand'] }), null);
|
||||
});
|
||||
|
||||
test('getPointerInputMode detects controller sources', () => {
|
||||
@@ -27,20 +26,20 @@ test('getPointerInputMode returns null for unknown or gaze-like sources', () =>
|
||||
|
||||
test('rememberPointerInputMode reads input sources from supported event shapes', () => {
|
||||
const fromNestedInputSource = {};
|
||||
rememberPointerInputMode(fromNestedInputSource, { data: { inputSource: { hand: {} } } }, 'controller');
|
||||
assert.equal(fromNestedInputSource.pointerInputMode, 'hand');
|
||||
rememberPointerInputMode(fromNestedInputSource, { data: { inputSource: { gamepad: {} } } }, 'controller');
|
||||
assert.equal(fromNestedInputSource.pointerInputMode, 'controller');
|
||||
|
||||
const fromDirectInputSource = {};
|
||||
rememberPointerInputMode(fromDirectInputSource, { inputSource: { gamepad: {} } }, 'hand');
|
||||
rememberPointerInputMode(fromDirectInputSource, { inputSource: { gamepad: {} } }, 'controller');
|
||||
assert.equal(fromDirectInputSource.pointerInputMode, 'controller');
|
||||
|
||||
const fromDataSource = {};
|
||||
rememberPointerInputMode(fromDataSource, { data: { targetRayMode: 'tracked-pointer' } }, 'hand');
|
||||
rememberPointerInputMode(fromDataSource, { data: { targetRayMode: 'tracked-pointer' } }, 'controller');
|
||||
assert.equal(fromDataSource.pointerInputMode, 'controller');
|
||||
});
|
||||
|
||||
test('rememberPointerInputMode keeps fallback mode when the event is ambiguous', () => {
|
||||
const inputSource = { pointerInputMode: 'hand' };
|
||||
const inputSource = { pointerInputMode: 'controller' };
|
||||
|
||||
rememberPointerInputMode(inputSource, { data: { targetRayMode: 'gaze' } }, 'controller');
|
||||
|
||||
@@ -56,16 +55,9 @@ test('rememberPointerInputMode stores the input source on controller userData',
|
||||
}
|
||||
};
|
||||
|
||||
rememberPointerInputMode(inputSource, { data: { inputSource: { hand: {} } } }, 'controller');
|
||||
rememberPointerInputMode(inputSource, { data: { inputSource: { gamepad: {} } } }, 'controller');
|
||||
|
||||
assert.equal(inputSource.pointerInputMode, 'hand');
|
||||
assert.equal(inputSource.pointerInputMode, 'controller');
|
||||
assert.equal(inputSource.controller.userData.existing, true);
|
||||
assert.equal(inputSource.controller.userData.vrwpInputSource, inputSource);
|
||||
});
|
||||
|
||||
test('shouldUseHandPointer only enables the hand ray for remembered hand mode', () => {
|
||||
assert.equal(shouldUseHandPointer({ pointerInputMode: 'hand' }), true);
|
||||
assert.equal(shouldUseHandPointer({ pointerInputMode: 'controller' }), false);
|
||||
assert.equal(shouldUseHandPointer({}), false);
|
||||
assert.equal(shouldUseHandPointer(undefined), false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user