forked from EXT/VR180-Web-Player
@@ -3,6 +3,7 @@ export type LucideIconName =
|
|||||||
| 'play'
|
| 'play'
|
||||||
| 'pause'
|
| 'pause'
|
||||||
| 'maximize'
|
| 'maximize'
|
||||||
|
| 'arrow-left'
|
||||||
| 'rotate-ccw'
|
| 'rotate-ccw'
|
||||||
| 'rotate-cw'
|
| 'rotate-cw'
|
||||||
| 'volume-2'
|
| 'volume-2'
|
||||||
@@ -32,6 +33,10 @@ const ICONS: Record<LucideIconName, readonly IconNode[]> = {
|
|||||||
['path', { d: 'M3 16v3a2 2 0 0 0 2 2h3' }],
|
['path', { d: 'M3 16v3a2 2 0 0 0 2 2h3' }],
|
||||||
['path', { d: 'M16 21h3a2 2 0 0 0 2-2v-3' }]
|
['path', { d: 'M16 21h3a2 2 0 0 0 2-2v-3' }]
|
||||||
],
|
],
|
||||||
|
'arrow-left': [
|
||||||
|
['path', { d: 'm12 19-7-7 7-7' }],
|
||||||
|
['path', { d: 'M19 12H5' }]
|
||||||
|
],
|
||||||
'rotate-ccw': [
|
'rotate-ccw': [
|
||||||
['path', { d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' }],
|
['path', { d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' }],
|
||||||
['path', { d: 'M3 3v5h5' }]
|
['path', { d: 'M3 3v5h5' }]
|
||||||
|
|||||||
@@ -273,6 +273,10 @@ function hidePanel() {
|
|||||||
vrPanelVisibility.hide();
|
vrPanelVisibility.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVisibleVrPanelInteractables() {
|
||||||
|
return vrPanelVisibility.isVisible ? (vrPanel?.interactables ?? []) : [];
|
||||||
|
}
|
||||||
|
|
||||||
function onWindowResize() {
|
function onWindowResize() {
|
||||||
if (!renderer) return;
|
if (!renderer) return;
|
||||||
|
|
||||||
@@ -563,7 +567,10 @@ function renderXR(timestamp, frame) {
|
|||||||
if (vrPanelVisibility.isFading) {
|
if (vrPanelVisibility.isFading) {
|
||||||
animatePanelFade(timestamp);
|
animatePanelFade(timestamp);
|
||||||
}
|
}
|
||||||
xrInputRig?.update(timestamp);
|
const isInputHoveringVrPanel = xrInputRig?.update(timestamp, getVisibleVrPanelInteractables()) ?? false;
|
||||||
|
if (isInputHoveringVrPanel) {
|
||||||
|
vrPanelVisibility.show();
|
||||||
|
}
|
||||||
|
|
||||||
if (!frame) {
|
if (!frame) {
|
||||||
console.warn("renderXR called without an XRFrame. Skipping render.");
|
console.warn("renderXR called without an XRFrame. Skipping render.");
|
||||||
|
|||||||
@@ -18,8 +18,20 @@ export type PalmAimRay = {
|
|||||||
origin: 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 MIN_AXIS_LENGTH_SQ = 0.000001;
|
||||||
|
const PALM_AIM_FORWARD_TILT_DEGREES = 40;
|
||||||
const PALM_SURFACE_OFFSET_METERS = 0.035;
|
const PALM_SURFACE_OFFSET_METERS = 0.035;
|
||||||
|
export const DEFAULT_PALM_AIM_LATCH_MAX_AGE_MS = 300;
|
||||||
|
|
||||||
export function computePalmAimRay(input: PalmAimInput): PalmAimRay | null {
|
export function computePalmAimRay(input: PalmAimInput): PalmAimRay | null {
|
||||||
const { indexMetacarpal, pinkyMetacarpal, wrist } = input;
|
const { indexMetacarpal, pinkyMetacarpal, wrist } = input;
|
||||||
@@ -43,7 +55,10 @@ export function computePalmAimRay(input: PalmAimInput): PalmAimRay | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const direction = normalize(getPalmNormal(fingerAxis, acrossPalmAxis, input.handedness));
|
const direction = normalize(getTiltedPalmDirection(
|
||||||
|
getPalmNormal(fingerAxis, acrossPalmAxis, input.handedness),
|
||||||
|
fingerAxis
|
||||||
|
));
|
||||||
if (!direction) {
|
if (!direction) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -53,6 +68,71 @@ export function computePalmAimRay(input: PalmAimInput): PalmAimRay | null {
|
|||||||
return { direction, origin };
|
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 {
|
function getPalmNormal(fingerAxis: VectorLike, acrossPalmAxis: VectorLike, handedness?: string | null): VectorLike {
|
||||||
if (handedness === 'left') {
|
if (handedness === 'left') {
|
||||||
return cross(acrossPalmAxis, fingerAxis);
|
return cross(acrossPalmAxis, fingerAxis);
|
||||||
@@ -61,6 +141,56 @@ function getPalmNormal(fingerAxis: VectorLike, acrossPalmAxis: VectorLike, hande
|
|||||||
return cross(fingerAxis, acrossPalmAxis);
|
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 {
|
function averageVectors(vectors: Array<VectorLike | null | undefined>): VectorLike | null {
|
||||||
const usableVectors = vectors.filter(Boolean) as VectorLike[];
|
const usableVectors = vectors.filter(Boolean) as VectorLike[];
|
||||||
if (usableVectors.length === 0) {
|
if (usableVectors.length === 0) {
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export function createVrControlPanel(
|
|||||||
centerY: FIGMA_EXIT_BUTTON_Y_PX,
|
centerY: FIGMA_EXIT_BUTTON_Y_PX,
|
||||||
name: 'vrExitButton',
|
name: 'vrExitButton',
|
||||||
size: FIGMA_EXIT_BUTTON_SIZE_PX,
|
size: FIGMA_EXIT_BUTTON_SIZE_PX,
|
||||||
texture: createLucideButtonTexture('log-out', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
texture: createLucideButtonTexture('arrow-left', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
||||||
});
|
});
|
||||||
group.add(exitButtonMesh);
|
group.add(exitButtonMesh);
|
||||||
interactables.push(exitButtonMesh);
|
interactables.push(exitButtonMesh);
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import {
|
|||||||
type VrControlPanel
|
type VrControlPanel
|
||||||
} from './vr-control-panel.js';
|
} from './vr-control-panel.js';
|
||||||
import {
|
import {
|
||||||
|
beginPalmAimSelection,
|
||||||
computePalmAimRay,
|
computePalmAimRay,
|
||||||
|
createPalmAimLatch,
|
||||||
|
endPalmAimSelection,
|
||||||
|
getPalmAimSelectionRay,
|
||||||
|
recordStablePalmAimRay,
|
||||||
|
type PalmAimLatch,
|
||||||
|
type PalmAimRay,
|
||||||
type VectorLike
|
type VectorLike
|
||||||
} from './hand-aim.js';
|
} from './hand-aim.js';
|
||||||
|
|
||||||
@@ -27,7 +34,7 @@ type VrInputRig = {
|
|||||||
hideOverlays: () => void;
|
hideOverlays: () => void;
|
||||||
raycaster: any;
|
raycaster: any;
|
||||||
showOverlays: (timestamp?: number) => void;
|
showOverlays: (timestamp?: number) => void;
|
||||||
update: (timestamp: number) => void;
|
update: (timestamp: number, hoverTargets?: any[]) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AimRay = {
|
type AimRay = {
|
||||||
@@ -38,9 +45,18 @@ type AimRay = {
|
|||||||
type HandPointerOverlay = {
|
type HandPointerOverlay = {
|
||||||
fallbackPointerOverlay: any;
|
fallbackPointerOverlay: any;
|
||||||
hand: any;
|
hand: any;
|
||||||
|
handAimLatch: PalmAimLatch;
|
||||||
pointerOverlay: any;
|
pointerOverlay: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type VrInputSource = {
|
||||||
|
controller: any;
|
||||||
|
controllerPointerOverlay: any;
|
||||||
|
hand?: any;
|
||||||
|
handAimLatch?: PalmAimLatch;
|
||||||
|
handPointerOverlay?: any;
|
||||||
|
};
|
||||||
|
|
||||||
type VrOverlayVisibilityOptions = {
|
type VrOverlayVisibilityOptions = {
|
||||||
fadeDurationMs?: number;
|
fadeDurationMs?: number;
|
||||||
hideDelayMs?: number;
|
hideDelayMs?: number;
|
||||||
@@ -50,6 +66,8 @@ const INPUT_OVERLAY_HIDE_DELAY_MS = 2500;
|
|||||||
const INPUT_OVERLAY_FADE_DURATION_MS = 200;
|
const INPUT_OVERLAY_FADE_DURATION_MS = 200;
|
||||||
const INPUT_OVERLAY_RENDER_ORDER = 10000;
|
const INPUT_OVERLAY_RENDER_ORDER = 10000;
|
||||||
const POINTER_LENGTH = 5;
|
const POINTER_LENGTH = 5;
|
||||||
|
const POINTER_MIN_LENGTH = 0.06;
|
||||||
|
const POINTER_HIT_SURFACE_OFFSET = 0.015;
|
||||||
const HAND_JOINT_NAMES = [
|
const HAND_JOINT_NAMES = [
|
||||||
'wrist',
|
'wrist',
|
||||||
'thumb-metacarpal',
|
'thumb-metacarpal',
|
||||||
@@ -83,18 +101,35 @@ const tempMatrix = new THREE.Matrix4();
|
|||||||
export function createVrInputRig(scene: any, renderer: any, onSelectStart: (event: any) => void): VrInputRig {
|
export function createVrInputRig(scene: any, renderer: any, onSelectStart: (event: any) => void): VrInputRig {
|
||||||
const overlayVisibility = new VrOverlayVisibility();
|
const overlayVisibility = new VrOverlayVisibility();
|
||||||
const handPointerOverlays: HandPointerOverlay[] = [];
|
const handPointerOverlays: HandPointerOverlay[] = [];
|
||||||
|
const inputSources: VrInputSource[] = [];
|
||||||
const raycaster = new THREE.Raycaster();
|
const raycaster = new THREE.Raycaster();
|
||||||
raycaster.near = 0.1;
|
raycaster.near = 0.1;
|
||||||
raycaster.far = POINTER_LENGTH;
|
raycaster.far = POINTER_LENGTH;
|
||||||
|
const hoverRaycaster = new THREE.Raycaster();
|
||||||
|
hoverRaycaster.near = 0.1;
|
||||||
|
hoverRaycaster.far = POINTER_LENGTH;
|
||||||
|
|
||||||
for (let index = 0; index < 2; index += 1) {
|
for (let index = 0; index < 2; index += 1) {
|
||||||
const controller = renderer.xr.getController(index);
|
const controller = renderer.xr.getController(index);
|
||||||
|
const controllerPointerOverlay = createPointerOverlay(index, overlayVisibility);
|
||||||
|
const inputSource: VrInputSource = {
|
||||||
|
controller,
|
||||||
|
controllerPointerOverlay
|
||||||
|
};
|
||||||
|
inputSources.push(inputSource);
|
||||||
controller.addEventListener('selectstart', (event: any) => {
|
controller.addEventListener('selectstart', (event: any) => {
|
||||||
overlayVisibility.show();
|
const timestamp = getEventTimestamp(event);
|
||||||
|
beginPalmAimSelection(controller.userData?.vrwpHandAimLatch, timestamp);
|
||||||
|
overlayVisibility.show(timestamp);
|
||||||
onSelectStart(event);
|
onSelectStart(event);
|
||||||
});
|
});
|
||||||
|
controller.addEventListener('selectend', () => {
|
||||||
|
endPalmAimSelection(controller.userData?.vrwpHandAimLatch);
|
||||||
|
});
|
||||||
|
controller.addEventListener('select', () => {
|
||||||
|
endPalmAimSelection(controller.userData?.vrwpHandAimLatch);
|
||||||
|
});
|
||||||
bindOverlayActivity(controller, overlayVisibility);
|
bindOverlayActivity(controller, overlayVisibility);
|
||||||
const controllerPointerOverlay = createPointerOverlay(index, overlayVisibility);
|
|
||||||
controller.add(controllerPointerOverlay);
|
controller.add(controllerPointerOverlay);
|
||||||
scene.add(controller);
|
scene.add(controller);
|
||||||
|
|
||||||
@@ -107,9 +142,17 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
|||||||
|
|
||||||
const hand = renderer.xr.getHand?.(index);
|
const hand = renderer.xr.getHand?.(index);
|
||||||
if (hand) {
|
if (hand) {
|
||||||
|
const handAimLatch = createPalmAimLatch();
|
||||||
|
inputSource.hand = hand;
|
||||||
|
inputSource.handAimLatch = handAimLatch;
|
||||||
controller.userData = {
|
controller.userData = {
|
||||||
...controller.userData,
|
...controller.userData,
|
||||||
vrwpHand: hand
|
vrwpHand: hand,
|
||||||
|
vrwpHandAimLatch: handAimLatch
|
||||||
|
};
|
||||||
|
hand.userData = {
|
||||||
|
...hand.userData,
|
||||||
|
vrwpAimLatch: handAimLatch
|
||||||
};
|
};
|
||||||
bindOverlayActivity(hand, overlayVisibility);
|
bindOverlayActivity(hand, overlayVisibility);
|
||||||
rememberHandedness(hand, { data: hand.inputState });
|
rememberHandedness(hand, { data: hand.inputState });
|
||||||
@@ -122,10 +165,12 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
|||||||
scene.add(hand);
|
scene.add(hand);
|
||||||
|
|
||||||
const handPointerOverlay = createWorldPointerOverlay(index, overlayVisibility);
|
const handPointerOverlay = createWorldPointerOverlay(index, overlayVisibility);
|
||||||
|
inputSource.handPointerOverlay = handPointerOverlay;
|
||||||
scene.add(handPointerOverlay);
|
scene.add(handPointerOverlay);
|
||||||
handPointerOverlays.push({
|
handPointerOverlays.push({
|
||||||
fallbackPointerOverlay: controllerPointerOverlay,
|
fallbackPointerOverlay: controllerPointerOverlay,
|
||||||
hand,
|
hand,
|
||||||
|
handAimLatch,
|
||||||
pointerOverlay: handPointerOverlay
|
pointerOverlay: handPointerOverlay
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -137,9 +182,14 @@ export function createVrInputRig(scene: any, renderer: any, onSelectStart: (even
|
|||||||
hideOverlays: () => overlayVisibility.hideImmediately(),
|
hideOverlays: () => overlayVisibility.hideImmediately(),
|
||||||
raycaster,
|
raycaster,
|
||||||
showOverlays: (timestamp?: number) => overlayVisibility.show(timestamp),
|
showOverlays: (timestamp?: number) => overlayVisibility.show(timestamp),
|
||||||
update: (timestamp: number) => {
|
update: (timestamp: number, hoverTargets: any[] = []) => {
|
||||||
updateHandPointerOverlays(handPointerOverlays);
|
updateHandPointerOverlays(handPointerOverlays, timestamp);
|
||||||
|
const isHovering = updateInputPointerIntersections(inputSources, hoverTargets, hoverRaycaster, timestamp);
|
||||||
|
if (isHovering) {
|
||||||
|
overlayVisibility.show(timestamp);
|
||||||
|
}
|
||||||
overlayVisibility.update(timestamp);
|
overlayVisibility.update(timestamp);
|
||||||
|
return isHovering;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -207,7 +257,7 @@ function togglePanel(options: VrControllerSelectionOptions): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applySelectionRay(controller: any, raycaster: any): void {
|
function applySelectionRay(controller: any, raycaster: any): void {
|
||||||
const handRay = getHandAimRay(controller.userData?.vrwpHand);
|
const handRay = getSelectionHandAimRay(controller) || getHandAimRay(controller.userData?.vrwpHand);
|
||||||
if (handRay) {
|
if (handRay) {
|
||||||
raycaster.ray.origin.copy(handRay.origin);
|
raycaster.ray.origin.copy(handRay.origin);
|
||||||
raycaster.ray.direction.copy(handRay.direction);
|
raycaster.ray.direction.copy(handRay.direction);
|
||||||
@@ -220,10 +270,126 @@ function applySelectionRay(controller: any, raycaster: any): void {
|
|||||||
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
|
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHandPointerOverlays(handPointerOverlays: HandPointerOverlay[]): void {
|
function updateInputPointerIntersections(
|
||||||
handPointerOverlays.forEach(({ fallbackPointerOverlay, hand, pointerOverlay }) => {
|
inputSources: VrInputSource[],
|
||||||
const handRay = getHandAimRay(hand);
|
hoverTargets: any[],
|
||||||
const hasHandRay = Boolean(handRay);
|
hoverRaycaster: any,
|
||||||
|
timestamp: number
|
||||||
|
): boolean {
|
||||||
|
let isHoveringAnyTarget = false;
|
||||||
|
|
||||||
|
inputSources.forEach((inputSource) => {
|
||||||
|
resetInputPointerLengths(inputSource);
|
||||||
|
const aimRay = getInputSourceAimRay(inputSource, timestamp);
|
||||||
|
const pointerOverlay = getActivePointerOverlay(inputSource);
|
||||||
|
if (!aimRay || !pointerOverlay || hoverTargets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverRaycaster.ray.origin.copy(aimRay.origin);
|
||||||
|
hoverRaycaster.ray.direction.copy(aimRay.direction);
|
||||||
|
const intersections = hoverRaycaster.intersectObjects(hoverTargets, true);
|
||||||
|
if (intersections.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isHoveringAnyTarget = true;
|
||||||
|
setPointerOverlayLength(pointerOverlay, getPointerIntersectionLength(intersections[0].distance));
|
||||||
|
});
|
||||||
|
|
||||||
|
return isHoveringAnyTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputSourceAimRay(inputSource: VrInputSource, timestamp: number): AimRay | null {
|
||||||
|
if (inputSource.hand && inputSource.handAimLatch) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.updateMatrixWorld();
|
||||||
|
tempMatrix.identity().extractRotation(controller.matrixWorld);
|
||||||
|
const origin = new THREE.Vector3().setFromMatrixPosition(controller.matrixWorld);
|
||||||
|
const direction = new THREE.Vector3(0, 0, -1).applyMatrix4(tempMatrix);
|
||||||
|
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 setPointerOverlayLength(pointerOverlay: any, length: number): void {
|
||||||
|
if (!pointerOverlay || pointerOverlay.userData?.vrwpPointerLength === length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointerLine = pointerOverlay.userData?.vrwpPointerLine;
|
||||||
|
const pointerTip = pointerOverlay.userData?.vrwpPointerTip;
|
||||||
|
const positionAttribute = pointerLine?.geometry?.getAttribute?.('position') ||
|
||||||
|
pointerLine?.geometry?.attributes?.position;
|
||||||
|
|
||||||
|
if (positionAttribute?.setXYZ) {
|
||||||
|
positionAttribute.setXYZ(1, 0, 0, -length);
|
||||||
|
positionAttribute.needsUpdate = true;
|
||||||
|
pointerLine.geometry.computeBoundingSphere?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointerTip) {
|
||||||
|
pointerTip.position.z = -length;
|
||||||
|
}
|
||||||
|
|
||||||
|
pointerOverlay.userData = {
|
||||||
|
...pointerOverlay.userData,
|
||||||
|
vrwpPointerLength: length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHandPointerOverlays(handPointerOverlays: HandPointerOverlay[], timestamp: number): void {
|
||||||
|
handPointerOverlays.forEach(({ fallbackPointerOverlay, hand, handAimLatch, pointerOverlay }) => {
|
||||||
|
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 = {
|
||||||
...pointerOverlay.userData,
|
...pointerOverlay.userData,
|
||||||
vrwpOverlayAvailable: hasHandRay
|
vrwpOverlayAvailable: hasHandRay
|
||||||
@@ -233,15 +399,26 @@ function updateHandPointerOverlays(handPointerOverlays: HandPointerOverlay[]): v
|
|||||||
vrwpOverlayAvailable: !hasHandRay
|
vrwpOverlayAvailable: !hasHandRay
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!handRay) {
|
if (!displayHandRay) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pointerOverlay.position.copy(handRay.origin);
|
pointerOverlay.position.copy(displayHandRay.origin);
|
||||||
pointerOverlay.quaternion.setFromUnitVectors(DEFAULT_RAY_DIRECTION, handRay.direction);
|
pointerOverlay.quaternion.setFromUnitVectors(DEFAULT_RAY_DIRECTION, displayHandRay.direction);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function getHandAimRay(hand: any): AimRay | null {
|
function getHandAimRay(hand: any): AimRay | null {
|
||||||
const joints = hand?.joints;
|
const joints = hand?.joints;
|
||||||
if (!joints) {
|
if (!joints) {
|
||||||
@@ -265,6 +442,20 @@ function getHandAimRay(hand: any): AimRay | null {
|
|||||||
return { direction, origin };
|
return { direction, origin };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPalmAimRay(ray: AimRay): PalmAimRay {
|
||||||
|
return {
|
||||||
|
direction: fromThreeVector(ray.direction),
|
||||||
|
origin: fromThreeVector(ray.origin)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAimRay(ray: PalmAimRay): AimRay {
|
||||||
|
return {
|
||||||
|
direction: toThreeVector(ray.direction),
|
||||||
|
origin: toThreeVector(ray.origin)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function rememberHandedness(hand: any, event: any): void {
|
function rememberHandedness(hand: any, event: any): void {
|
||||||
const handedness = event?.data?.handedness ||
|
const handedness = event?.data?.handedness ||
|
||||||
event?.data?.inputSource?.handedness ||
|
event?.data?.inputSource?.handedness ||
|
||||||
@@ -299,6 +490,18 @@ function toThreeVector(vector: VectorLike): any {
|
|||||||
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fromThreeVector(vector: any): VectorLike {
|
||||||
|
return {
|
||||||
|
x: vector.x,
|
||||||
|
y: vector.y,
|
||||||
|
z: vector.z
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventTimestamp(event: any): number {
|
||||||
|
return Number.isFinite(event?.timeStamp) ? event.timeStamp : performance.now();
|
||||||
|
}
|
||||||
|
|
||||||
class VrOverlayVisibility {
|
class VrOverlayVisibility {
|
||||||
private readonly fadeDurationMs: number;
|
private readonly fadeDurationMs: number;
|
||||||
private readonly hideDelayMs: number;
|
private readonly hideDelayMs: number;
|
||||||
@@ -421,6 +624,13 @@ function createPointerOverlay(index: number, overlayVisibility: VrOverlayVisibil
|
|||||||
tipMesh.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
|
tipMesh.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
|
||||||
group.add(tipMesh);
|
group.add(tipMesh);
|
||||||
|
|
||||||
|
group.userData = {
|
||||||
|
...group.userData,
|
||||||
|
vrwpPointerLength: POINTER_LENGTH,
|
||||||
|
vrwpPointerLine: pointerLine,
|
||||||
|
vrwpPointerTip: tipMesh
|
||||||
|
};
|
||||||
|
|
||||||
overlayVisibility.register(group);
|
overlayVisibility.register(group);
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,58 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { computePalmAimRay } from '../vr180player/xr/hand-aim.js';
|
import {
|
||||||
|
beginPalmAimSelection,
|
||||||
|
computePalmAimRay,
|
||||||
|
createPalmAimLatch,
|
||||||
|
endPalmAimSelection,
|
||||||
|
getPalmAimSelectionRay,
|
||||||
|
recordStablePalmAimRay
|
||||||
|
} from '../vr180player/xr/hand-aim.js';
|
||||||
|
|
||||||
const wrist = { x: 0, y: 0, z: 0 };
|
const wrist = { x: 0, y: 0, z: 0 };
|
||||||
const middleMetacarpal = { x: 0, y: 0.1, z: 0 };
|
const middleMetacarpal = { x: 0, y: 0.1, z: 0 };
|
||||||
const ringMetacarpal = { x: 0.025, 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 points out from a right palm instead of following the index finger', () => {
|
test('computePalmAimRay tilts a right palm ray toward the finger-forward axis', () => {
|
||||||
const ray = computePalmAimRay({
|
const ray = computePalmAimRay({
|
||||||
handedness: 'right',
|
handedness: 'right',
|
||||||
indexMetacarpal: { x: -0.035, y: 0.1, z: 0 },
|
indexMetacarpal: { x: -0.045, y: 0.1, z: 0 },
|
||||||
middleMetacarpal,
|
middleMetacarpal: { x: -0.015, y: 0.1, z: 0 },
|
||||||
pinkyMetacarpal: { x: 0.055, y: 0.1, z: 0 },
|
pinkyMetacarpal: { x: 0.045, y: 0.1, z: 0 },
|
||||||
ringMetacarpal,
|
ringMetacarpal: { x: 0.015, y: 0.1, z: 0 },
|
||||||
wrist
|
wrist
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.ok(ray);
|
assert.ok(ray);
|
||||||
assert.equal(Math.round(ray.direction.x * 1000) / 1000, 0);
|
assert.equal(Math.round(ray.direction.x * 1000) / 1000, 0);
|
||||||
assert.equal(Math.round(ray.direction.y * 1000) / 1000, 0);
|
assert.equal(Math.round(ray.direction.y * 1000) / 1000, 0.643);
|
||||||
assert.equal(Math.round(ray.direction.z * 1000) / 1000, -1);
|
assert.equal(Math.round(ray.direction.z * 1000) / 1000, -0.766);
|
||||||
assert.equal(Math.round(ray.origin.z * 1000) / 1000, -0.035);
|
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', () => {
|
test('computePalmAimRay flips the palm normal for a left hand while keeping forward tilt', () => {
|
||||||
const ray = computePalmAimRay({
|
const ray = computePalmAimRay({
|
||||||
handedness: 'left',
|
handedness: 'left',
|
||||||
indexMetacarpal: { x: 0.035, y: 0.1, z: 0 },
|
indexMetacarpal: { x: 0.045, y: 0.1, z: 0 },
|
||||||
middleMetacarpal,
|
middleMetacarpal: { x: 0.015, y: 0.1, z: 0 },
|
||||||
pinkyMetacarpal: { x: -0.055, y: 0.1, z: 0 },
|
pinkyMetacarpal: { x: -0.045, y: 0.1, z: 0 },
|
||||||
ringMetacarpal: { x: -0.025, y: 0.1, z: 0 },
|
ringMetacarpal: { x: -0.015, y: 0.1, z: 0 },
|
||||||
wrist
|
wrist
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.ok(ray);
|
assert.ok(ray);
|
||||||
assert.equal(Math.round(ray.direction.z * 1000) / 1000, -1);
|
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', () => {
|
test('computePalmAimRay returns null when palm joints cannot define a stable ray', () => {
|
||||||
@@ -47,3 +64,38 @@ test('computePalmAimRay returns null when palm joints cannot define a stable ray
|
|||||||
wrist
|
wrist
|
||||||
}), null);
|
}), 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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user