forked from EXT/VR180-Web-Player
Compare commits
2 Commits
707cad3719
...
a470d4bdc7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a470d4bdc7 | ||
|
|
ea184ba448 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
|
||||
# Generated by `npm run build`.
|
||||
vr180player/*.css
|
||||
vr180player/*.js
|
||||
vr180player/**/*.js
|
||||
/media
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run build && vite --host 0.0.0.0",
|
||||
"build": "tsc",
|
||||
"build": "tsc && node scripts/copy-styles.mjs",
|
||||
"check": "tsc --noEmit",
|
||||
"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",
|
||||
"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",
|
||||
"preview": "npm run build && vite preview --host 127.0.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
16
scripts/copy-styles.mjs
Normal file
16
scripts/copy-styles.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import { copyFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const styleCopies = [
|
||||
{
|
||||
from: join(rootDir, 'src', 'vr180player', 'styles', 'vr180-player.css'),
|
||||
to: join(rootDir, 'vr180player', 'vr180-player.css')
|
||||
}
|
||||
];
|
||||
|
||||
await Promise.all(styleCopies.map(async ({ from, to }) => {
|
||||
await mkdir(dirname(to), { recursive: true });
|
||||
await copyFile(from, to);
|
||||
}));
|
||||
113
src/vr180player/media/image-carousel-media-adapter.ts
Normal file
113
src/vr180player/media/image-carousel-media-adapter.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type {
|
||||
MediaAdapter,
|
||||
MediaCapabilities,
|
||||
MediaLoadCallbacks
|
||||
} from './media-adapter.js';
|
||||
import { getFilenameTitle } from './media-title.js';
|
||||
|
||||
const IMAGE_CAROUSEL_CAPABILITIES: MediaCapabilities = {
|
||||
audio: false,
|
||||
carousel: true,
|
||||
dynamicTexture: false,
|
||||
navigation: true,
|
||||
playback: false,
|
||||
timeline: false
|
||||
};
|
||||
|
||||
export class ImageCarouselMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLImageElement> {
|
||||
readonly capabilities = IMAGE_CAROUSEL_CAPABILITIES;
|
||||
readonly kind = 'image' as const;
|
||||
private currentIndex = 0;
|
||||
private isHidden = false;
|
||||
|
||||
constructor(private readonly images: HTMLImageElement[]) {
|
||||
this.images.forEach((image) => {
|
||||
image.classList.add('vrwp-media', 'vrwp-image', 'vrwp-carousel-image');
|
||||
});
|
||||
this.applyVisibility();
|
||||
}
|
||||
|
||||
get element(): HTMLImageElement {
|
||||
return this.images[this.currentIndex];
|
||||
}
|
||||
|
||||
get textureSource(): HTMLImageElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return this.element.getAttribute('title') ||
|
||||
this.element.getAttribute('alt') ||
|
||||
getFilenameTitle(this.element.currentSrc || this.element.src) ||
|
||||
`Image ${this.currentIndex + 1}`;
|
||||
}
|
||||
|
||||
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
|
||||
let hasReportedReady = false;
|
||||
const reportReadyIfAllLoaded = () => {
|
||||
if (hasReportedReady || !this.areAllImagesReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasReportedReady = true;
|
||||
onReady();
|
||||
};
|
||||
|
||||
this.images.forEach((image) => {
|
||||
image.addEventListener('load', reportReadyIfAllLoaded);
|
||||
image.addEventListener('error', onError);
|
||||
});
|
||||
|
||||
if (this.areAllImagesReady()) {
|
||||
queueMicrotask(reportReadyIfAllLoaded);
|
||||
}
|
||||
}
|
||||
|
||||
hideElement(): void {
|
||||
this.isHidden = true;
|
||||
this.applyVisibility();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.images.forEach((image) => {
|
||||
image.loading = 'eager';
|
||||
});
|
||||
}
|
||||
|
||||
next(): boolean {
|
||||
return this.selectRelative(1);
|
||||
}
|
||||
|
||||
previous(): boolean {
|
||||
return this.selectRelative(-1);
|
||||
}
|
||||
|
||||
shouldUpdateTexture(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
showElement(): void {
|
||||
this.isHidden = false;
|
||||
this.applyVisibility();
|
||||
}
|
||||
|
||||
private selectRelative(offset: number): boolean {
|
||||
if (this.images.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.currentIndex = (this.currentIndex + offset + this.images.length) % this.images.length;
|
||||
this.applyVisibility();
|
||||
return true;
|
||||
}
|
||||
|
||||
private applyVisibility(): void {
|
||||
this.images.forEach((image, index) => {
|
||||
image.style.display = this.isHidden || index !== this.currentIndex ? 'none' : '';
|
||||
});
|
||||
}
|
||||
|
||||
private areAllImagesReady(): boolean {
|
||||
return this.images.every((image) => image.complete && image.naturalWidth > 0);
|
||||
}
|
||||
}
|
||||
66
src/vr180player/media/image-media-adapter.ts
Normal file
66
src/vr180player/media/image-media-adapter.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
MediaAdapter,
|
||||
MediaCapabilities,
|
||||
MediaLoadCallbacks
|
||||
} from './media-adapter.js';
|
||||
import { getFilenameTitle } from './media-title.js';
|
||||
|
||||
const IMAGE_CAPABILITIES: MediaCapabilities = {
|
||||
audio: false,
|
||||
carousel: false,
|
||||
dynamicTexture: false,
|
||||
navigation: false,
|
||||
playback: false,
|
||||
timeline: false
|
||||
};
|
||||
|
||||
export class ImageMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLImageElement> {
|
||||
readonly capabilities = IMAGE_CAPABILITIES;
|
||||
readonly kind = 'image' as const;
|
||||
|
||||
constructor(readonly element: HTMLImageElement) {}
|
||||
|
||||
get textureSource(): HTMLImageElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return this.element.getAttribute('title') ||
|
||||
this.element.getAttribute('alt') ||
|
||||
getFilenameTitle(this.element.currentSrc || this.element.src) ||
|
||||
'Image Title';
|
||||
}
|
||||
|
||||
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
|
||||
if (this.element.complete && this.element.naturalWidth > 0) {
|
||||
queueMicrotask(onReady);
|
||||
}
|
||||
|
||||
this.element.addEventListener('load', onReady);
|
||||
this.element.addEventListener('error', onError);
|
||||
}
|
||||
|
||||
hideElement(): void {
|
||||
this.element.style.display = 'none';
|
||||
}
|
||||
|
||||
load(): void {
|
||||
// Images begin loading from markup. Kept for parity with video media.
|
||||
}
|
||||
|
||||
next(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
previous(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
shouldUpdateTexture(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
showElement(): void {
|
||||
this.element.style.display = '';
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { ImageCarouselMediaAdapter } from './image-carousel-media-adapter.js';
|
||||
import { ImageMediaAdapter } from './image-media-adapter.js';
|
||||
import { VideoMediaAdapter } from './video-media-adapter.js';
|
||||
|
||||
export type MediaCapabilities = {
|
||||
audio: boolean;
|
||||
carousel: boolean;
|
||||
@@ -7,7 +11,7 @@ export type MediaCapabilities = {
|
||||
timeline: boolean;
|
||||
};
|
||||
|
||||
type MediaLoadCallbacks = {
|
||||
export type MediaLoadCallbacks = {
|
||||
onError: (event: Event) => void;
|
||||
onReady: () => void;
|
||||
};
|
||||
@@ -31,233 +35,12 @@ export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextu
|
||||
|
||||
export type SupportedMediaAdapter = ImageCarouselMediaAdapter | ImageMediaAdapter | VideoMediaAdapter;
|
||||
|
||||
const VIDEO_CAPABILITIES: MediaCapabilities = {
|
||||
audio: true,
|
||||
carousel: false,
|
||||
dynamicTexture: true,
|
||||
navigation: true,
|
||||
playback: true,
|
||||
timeline: true
|
||||
export {
|
||||
ImageCarouselMediaAdapter,
|
||||
ImageMediaAdapter,
|
||||
VideoMediaAdapter
|
||||
};
|
||||
|
||||
const IMAGE_CAPABILITIES: MediaCapabilities = {
|
||||
audio: false,
|
||||
carousel: false,
|
||||
dynamicTexture: false,
|
||||
navigation: false,
|
||||
playback: false,
|
||||
timeline: false
|
||||
};
|
||||
|
||||
const IMAGE_CAROUSEL_CAPABILITIES: MediaCapabilities = {
|
||||
audio: false,
|
||||
carousel: true,
|
||||
dynamicTexture: false,
|
||||
navigation: true,
|
||||
playback: false,
|
||||
timeline: false
|
||||
};
|
||||
|
||||
export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVideoElement> {
|
||||
readonly capabilities = VIDEO_CAPABILITIES;
|
||||
readonly kind = 'video' as const;
|
||||
|
||||
constructor(readonly element: HTMLVideoElement) {}
|
||||
|
||||
get textureSource(): HTMLVideoElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return this.element.getAttribute('title') ||
|
||||
this.element.querySelector('source')?.src.split('/').pop()?.split('.')[0].replace(/-/g, ' ') ||
|
||||
'Video Title';
|
||||
}
|
||||
|
||||
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
|
||||
if (this.element.readyState >= this.element.HAVE_METADATA) {
|
||||
queueMicrotask(onReady);
|
||||
}
|
||||
|
||||
this.element.addEventListener('loadedmetadata', onReady);
|
||||
this.element.addEventListener('canplaythrough', onReady);
|
||||
this.element.addEventListener('error', onError);
|
||||
}
|
||||
|
||||
hideElement(): void {
|
||||
this.element.style.display = 'none';
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.element.load();
|
||||
}
|
||||
|
||||
next(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
previous(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
shouldUpdateTexture(): boolean {
|
||||
return !this.element.paused && !this.element.ended;
|
||||
}
|
||||
|
||||
showElement(): void {
|
||||
this.element.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLImageElement> {
|
||||
readonly capabilities = IMAGE_CAPABILITIES;
|
||||
readonly kind = 'image' as const;
|
||||
|
||||
constructor(readonly element: HTMLImageElement) {}
|
||||
|
||||
get textureSource(): HTMLImageElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return this.element.getAttribute('title') ||
|
||||
this.element.getAttribute('alt') ||
|
||||
getFilenameTitle(this.element.currentSrc || this.element.src) ||
|
||||
'Image Title';
|
||||
}
|
||||
|
||||
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
|
||||
if (this.element.complete && this.element.naturalWidth > 0) {
|
||||
queueMicrotask(onReady);
|
||||
}
|
||||
|
||||
this.element.addEventListener('load', onReady);
|
||||
this.element.addEventListener('error', onError);
|
||||
}
|
||||
|
||||
hideElement(): void {
|
||||
this.element.style.display = 'none';
|
||||
}
|
||||
|
||||
load(): void {
|
||||
// Images begin loading from markup. Kept for parity with video media.
|
||||
}
|
||||
|
||||
next(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
previous(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
shouldUpdateTexture(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
showElement(): void {
|
||||
this.element.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageCarouselMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLImageElement> {
|
||||
readonly capabilities = IMAGE_CAROUSEL_CAPABILITIES;
|
||||
readonly kind = 'image' as const;
|
||||
private currentIndex = 0;
|
||||
private isHidden = false;
|
||||
|
||||
constructor(private readonly images: HTMLImageElement[]) {
|
||||
this.images.forEach((image) => {
|
||||
image.classList.add('vrwp-media', 'vrwp-image', 'vrwp-carousel-image');
|
||||
});
|
||||
this.applyVisibility();
|
||||
}
|
||||
|
||||
get element(): HTMLImageElement {
|
||||
return this.images[this.currentIndex];
|
||||
}
|
||||
|
||||
get textureSource(): HTMLImageElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return this.element.getAttribute('title') ||
|
||||
this.element.getAttribute('alt') ||
|
||||
getFilenameTitle(this.element.currentSrc || this.element.src) ||
|
||||
`Image ${this.currentIndex + 1}`;
|
||||
}
|
||||
|
||||
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
|
||||
let hasReportedReady = false;
|
||||
const reportReadyIfAllLoaded = () => {
|
||||
if (hasReportedReady || !this.areAllImagesReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasReportedReady = true;
|
||||
onReady();
|
||||
};
|
||||
|
||||
this.images.forEach((image) => {
|
||||
image.addEventListener('load', reportReadyIfAllLoaded);
|
||||
image.addEventListener('error', onError);
|
||||
});
|
||||
|
||||
if (this.areAllImagesReady()) {
|
||||
queueMicrotask(reportReadyIfAllLoaded);
|
||||
}
|
||||
}
|
||||
|
||||
hideElement(): void {
|
||||
this.isHidden = true;
|
||||
this.applyVisibility();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.images.forEach((image) => {
|
||||
image.loading = 'eager';
|
||||
});
|
||||
}
|
||||
|
||||
next(): boolean {
|
||||
return this.selectRelative(1);
|
||||
}
|
||||
|
||||
previous(): boolean {
|
||||
return this.selectRelative(-1);
|
||||
}
|
||||
|
||||
shouldUpdateTexture(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
showElement(): void {
|
||||
this.isHidden = false;
|
||||
this.applyVisibility();
|
||||
}
|
||||
|
||||
private selectRelative(offset: number): boolean {
|
||||
if (this.images.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.currentIndex = (this.currentIndex + offset + this.images.length) % this.images.length;
|
||||
this.applyVisibility();
|
||||
return true;
|
||||
}
|
||||
|
||||
private applyVisibility(): void {
|
||||
this.images.forEach((image, index) => {
|
||||
image.style.display = this.isHidden || index !== this.currentIndex ? 'none' : '';
|
||||
});
|
||||
}
|
||||
|
||||
private areAllImagesReady(): boolean {
|
||||
return this.images.every((image) => image.complete && image.naturalWidth > 0);
|
||||
}
|
||||
}
|
||||
|
||||
export function createMediaAdapter(playerContainer: HTMLElement): SupportedMediaAdapter | null {
|
||||
const mediaElements = Array.from(
|
||||
playerContainer.querySelectorAll<HTMLVideoElement | HTMLImageElement>('video,img')
|
||||
@@ -299,7 +82,3 @@ function isCarouselEnabled(playerContainer: HTMLElement): boolean {
|
||||
const carouselValue = playerContainer.dataset?.carousel;
|
||||
return carouselValue !== undefined && carouselValue.toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
function getFilenameTitle(source: string): string {
|
||||
return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || '';
|
||||
}
|
||||
|
||||
3
src/vr180player/media/media-title.ts
Normal file
3
src/vr180player/media/media-title.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function getFilenameTitle(source: string): string {
|
||||
return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || '';
|
||||
}
|
||||
65
src/vr180player/media/video-media-adapter.ts
Normal file
65
src/vr180player/media/video-media-adapter.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
MediaAdapter,
|
||||
MediaCapabilities,
|
||||
MediaLoadCallbacks
|
||||
} from './media-adapter.js';
|
||||
|
||||
const VIDEO_CAPABILITIES: MediaCapabilities = {
|
||||
audio: true,
|
||||
carousel: false,
|
||||
dynamicTexture: true,
|
||||
navigation: true,
|
||||
playback: true,
|
||||
timeline: true
|
||||
};
|
||||
|
||||
export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVideoElement> {
|
||||
readonly capabilities = VIDEO_CAPABILITIES;
|
||||
readonly kind = 'video' as const;
|
||||
|
||||
constructor(readonly element: HTMLVideoElement) {}
|
||||
|
||||
get textureSource(): HTMLVideoElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return this.element.getAttribute('title') ||
|
||||
this.element.querySelector('source')?.src.split('/').pop()?.split('.')[0].replace(/-/g, ' ') ||
|
||||
'Video Title';
|
||||
}
|
||||
|
||||
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
|
||||
if (this.element.readyState >= this.element.HAVE_METADATA) {
|
||||
queueMicrotask(onReady);
|
||||
}
|
||||
|
||||
this.element.addEventListener('loadedmetadata', onReady);
|
||||
this.element.addEventListener('canplaythrough', onReady);
|
||||
this.element.addEventListener('error', onError);
|
||||
}
|
||||
|
||||
hideElement(): void {
|
||||
this.element.style.display = 'none';
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.element.load();
|
||||
}
|
||||
|
||||
next(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
previous(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
shouldUpdateTexture(): boolean {
|
||||
return !this.element.paused && !this.element.ended;
|
||||
}
|
||||
|
||||
showElement(): void {
|
||||
this.element.style.display = '';
|
||||
}
|
||||
}
|
||||
207
src/vr180player/styles/vr180-player.css
Normal file
207
src/vr180player/styles/vr180-player.css
Normal file
@@ -0,0 +1,207 @@
|
||||
.vrwp {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vrwp [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vrwp-media,
|
||||
.vrwp canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.vrwp-play-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
transition: opacity 0.3s ease, transform 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.vrwp-play-button:hover {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
|
||||
.vrwp-play-button:active {
|
||||
transform: translate(-50%, -50%) scale(0.95);
|
||||
}
|
||||
|
||||
.vrwp-play-button.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vrwp-play-button .vrwp-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.vrwp-play-button {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.vrwp-play-button {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.vrwp-panel {
|
||||
position: absolute;
|
||||
bottom: 10%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80%;
|
||||
padding: 20px;
|
||||
border-radius: 30px;
|
||||
background: rgba(0, 0, 0, 0.70);
|
||||
color: #fff;
|
||||
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vrwp-panel.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vrwp-status {
|
||||
margin: 0 12px 12px 12px;
|
||||
}
|
||||
|
||||
.vrwp-video-title {
|
||||
text-align: center;
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vrwp-current-time,
|
||||
.vrwp-total-time {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.vrwp-progress {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr min-content;
|
||||
grid-gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vrwp-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #666;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vrwp-played {
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
height: 4px;
|
||||
width: 0%;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
.vrwp-controls {
|
||||
display: grid;
|
||||
grid-template-areas: "full lflex nav rflex loop mute";
|
||||
grid-template-columns: 44px 1fr 156px 1fr 44px 44px;
|
||||
column-gap: 8px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.vrwp-panel button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
transition: color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.vrwp-panel button:hover {
|
||||
color: #d8d8d8;
|
||||
}
|
||||
|
||||
.vrwp-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.vrwp-fullscreen,
|
||||
.vrwp-loop,
|
||||
.vrwp-mute,
|
||||
.vrwp-back,
|
||||
.vrwp-play-toggle,
|
||||
.vrwp-forward {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.vrwp-fullscreen {
|
||||
grid-area: full;
|
||||
}
|
||||
|
||||
.vrwp-mute {
|
||||
grid-area: mute;
|
||||
}
|
||||
|
||||
.vrwp-loop {
|
||||
grid-area: loop;
|
||||
}
|
||||
|
||||
.vrwp-loop.active {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.vrwp-nav {
|
||||
grid-area: nav;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 44px 44px;
|
||||
grid-gap: 12px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.vrwp-skip-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -48%);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
90
src/vr180player/xr/hand-aim-three.ts
Normal file
90
src/vr180player/xr/hand-aim-three.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
52
src/vr180player/xr/input-mode.ts
Normal file
52
src/vr180player/xr/input-mode.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type PointerInputMode = 'controller' | 'hand';
|
||||
|
||||
export type PointerInputModeCarrier = {
|
||||
controller?: {
|
||||
userData?: any;
|
||||
};
|
||||
pointerInputMode?: PointerInputMode;
|
||||
};
|
||||
|
||||
export function rememberPointerInputMode(
|
||||
inputSource: PointerInputModeCarrier,
|
||||
event: any,
|
||||
fallbackMode: PointerInputMode
|
||||
): void {
|
||||
const eventInputSource = event?.data?.inputSource || event?.inputSource || event?.data;
|
||||
const nextMode = getPointerInputMode(eventInputSource) || fallbackMode;
|
||||
inputSource.pointerInputMode = nextMode;
|
||||
|
||||
if (!inputSource.controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
inputSource.controller.userData = {
|
||||
...inputSource.controller.userData,
|
||||
vrwpInputSource: inputSource
|
||||
};
|
||||
}
|
||||
|
||||
export function getPointerInputMode(eventInputSource: any): PointerInputMode | null {
|
||||
if (!eventInputSource) {
|
||||
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';
|
||||
}
|
||||
365
src/vr180player/xr/input-rig.ts
Normal file
365
src/vr180player/xr/input-rig.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
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 {
|
||||
bindOverlayActivity,
|
||||
createControllerOverlay,
|
||||
createHandOverlay,
|
||||
createPointerOverlay,
|
||||
createWorldPointerOverlay,
|
||||
POINTER_HIT_SURFACE_OFFSET,
|
||||
POINTER_LENGTH,
|
||||
POINTER_MIN_LENGTH,
|
||||
setPointerOverlayLength,
|
||||
VrOverlayVisibility
|
||||
} from './pointer-overlays.js';
|
||||
|
||||
export type VrInputRig = {
|
||||
beginSeekDrag: (controller: any, panel: VrControlPanel | undefined, onSeek: (progress: number) => void) => void;
|
||||
hideOverlays: () => void;
|
||||
raycaster: any;
|
||||
showOverlays: (timestamp?: number) => void;
|
||||
update: (timestamp: number, hoverTargets?: any[]) => boolean;
|
||||
};
|
||||
|
||||
type ActiveSeekDrag = {
|
||||
inputSource: VrInputSource;
|
||||
onSeek: (progress: number) => void;
|
||||
panel: VrControlPanel;
|
||||
};
|
||||
|
||||
type HandPointerOverlay = {
|
||||
fallbackPointerOverlay: any;
|
||||
hand: any;
|
||||
handAimLatch: PalmAimLatch;
|
||||
inputSource: VrInputSource;
|
||||
pointerOverlay: any;
|
||||
};
|
||||
|
||||
type VrInputSource = {
|
||||
controller: any;
|
||||
controllerPointerOverlay: any;
|
||||
hand?: any;
|
||||
handAimLatch?: PalmAimLatch;
|
||||
handPointerOverlay?: any;
|
||||
pointerInputMode: PointerInputMode;
|
||||
};
|
||||
|
||||
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();
|
||||
const dragRaycaster = createPointerRaycaster();
|
||||
let activeSeekDrag: ActiveSeekDrag | null = null;
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const controller = renderer.xr.getController(index);
|
||||
const controllerPointerOverlay = createPointerOverlay(index, overlayVisibility);
|
||||
const inputSource: VrInputSource = {
|
||||
controller,
|
||||
controllerPointerOverlay,
|
||||
pointerInputMode: 'controller'
|
||||
};
|
||||
controller.userData = {
|
||||
...controller.userData,
|
||||
vrwpInputSource: inputSource
|
||||
};
|
||||
inputSources.push(inputSource);
|
||||
|
||||
controller.addEventListener('connected', (event: any) => {
|
||||
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);
|
||||
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);
|
||||
|
||||
const grip = renderer.xr.getControllerGrip?.(index);
|
||||
if (grip) {
|
||||
grip.addEventListener('connected', (event: any) => {
|
||||
rememberPointerInputMode(inputSource, event, 'controller');
|
||||
});
|
||||
bindOverlayActivity(grip, overlayVisibility);
|
||||
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();
|
||||
|
||||
return {
|
||||
beginSeekDrag: (controller: any, panel: VrControlPanel | undefined, onSeek: (progress: number) => void) => {
|
||||
const inputSource = getInputSourceByController(inputSources, controller);
|
||||
if (!inputSource || !panel?.seekBarHitAreaMesh) {
|
||||
activeSeekDrag = null;
|
||||
return;
|
||||
}
|
||||
|
||||
activeSeekDrag = { inputSource, onSeek, panel };
|
||||
},
|
||||
hideOverlays: () => overlayVisibility.hideImmediately(),
|
||||
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);
|
||||
if (isHovering) {
|
||||
overlayVisibility.show(timestamp);
|
||||
}
|
||||
overlayVisibility.update(timestamp);
|
||||
return isHovering;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createPointerRaycaster(): any {
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.near = 0.1;
|
||||
raycaster.far = POINTER_LENGTH;
|
||||
return raycaster;
|
||||
}
|
||||
|
||||
function getInputSourceByController(inputSources: VrInputSource[], controller: any): VrInputSource | undefined {
|
||||
return inputSources.find((inputSource) => inputSource.controller === controller);
|
||||
}
|
||||
|
||||
function updateInputPointerIntersections(
|
||||
inputSources: VrInputSource[],
|
||||
hoverTargets: any[],
|
||||
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 updateActiveSeekDrag(activeSeekDrag: ActiveSeekDrag | null, dragRaycaster: any, timestamp: number): void {
|
||||
if (!activeSeekDrag?.panel.seekBarHitAreaMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const aimRay = getInputSourceAimRay(activeSeekDrag.inputSource, timestamp, { preferLiveHandAim: true });
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 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();
|
||||
}
|
||||
328
src/vr180player/xr/pointer-overlays.ts
Normal file
328
src/vr180player/xr/pointer-overlays.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
|
||||
export type PointerOverlayInputSource = {
|
||||
controllerPointerOverlay: any;
|
||||
handPointerOverlay?: any;
|
||||
};
|
||||
|
||||
export type VrOverlayVisibilityOptions = {
|
||||
fadeDurationMs?: number;
|
||||
hideDelayMs?: number;
|
||||
};
|
||||
|
||||
export const INPUT_OVERLAY_HIDE_DELAY_MS = 2500;
|
||||
export const INPUT_OVERLAY_FADE_DURATION_MS = 200;
|
||||
export const INPUT_OVERLAY_RENDER_ORDER = 10000;
|
||||
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-metacarpal',
|
||||
'thumb-phalanx-proximal',
|
||||
'thumb-phalanx-distal',
|
||||
'thumb-tip',
|
||||
'index-finger-metacarpal',
|
||||
'index-finger-phalanx-proximal',
|
||||
'index-finger-phalanx-intermediate',
|
||||
'index-finger-phalanx-distal',
|
||||
'index-finger-tip',
|
||||
'middle-finger-metacarpal',
|
||||
'middle-finger-phalanx-proximal',
|
||||
'middle-finger-phalanx-intermediate',
|
||||
'middle-finger-phalanx-distal',
|
||||
'middle-finger-tip',
|
||||
'ring-finger-metacarpal',
|
||||
'ring-finger-phalanx-proximal',
|
||||
'ring-finger-phalanx-intermediate',
|
||||
'ring-finger-phalanx-distal',
|
||||
'ring-finger-tip',
|
||||
'pinky-finger-metacarpal',
|
||||
'pinky-finger-phalanx-proximal',
|
||||
'pinky-finger-phalanx-intermediate',
|
||||
'pinky-finger-phalanx-distal',
|
||||
'pinky-finger-tip'
|
||||
];
|
||||
|
||||
export class VrOverlayVisibility {
|
||||
private readonly fadeDurationMs: number;
|
||||
private readonly hideDelayMs: number;
|
||||
private readonly objects: any[] = [];
|
||||
private opacity = 0;
|
||||
private targetOpacity = 0;
|
||||
private visibleUntil = 0;
|
||||
|
||||
constructor({
|
||||
fadeDurationMs = INPUT_OVERLAY_FADE_DURATION_MS,
|
||||
hideDelayMs = INPUT_OVERLAY_HIDE_DELAY_MS
|
||||
}: VrOverlayVisibilityOptions = {}) {
|
||||
this.fadeDurationMs = fadeDurationMs;
|
||||
this.hideDelayMs = hideDelayMs;
|
||||
}
|
||||
|
||||
register(object: any): void {
|
||||
this.objects.push(object);
|
||||
this.setObjectVisible(object, this.targetOpacity > 0 || this.opacity > 0.001);
|
||||
this.setObjectOpacity(object, this.opacity);
|
||||
}
|
||||
|
||||
show(timestamp = performance.now()): void {
|
||||
this.visibleUntil = timestamp + this.hideDelayMs;
|
||||
this.targetOpacity = 1;
|
||||
this.objects.forEach((object) => this.setObjectVisible(object, true));
|
||||
}
|
||||
|
||||
hideImmediately(): void {
|
||||
this.visibleUntil = 0;
|
||||
this.opacity = 0;
|
||||
this.targetOpacity = 0;
|
||||
this.objects.forEach((object) => {
|
||||
this.setObjectOpacity(object, 0);
|
||||
this.setObjectVisible(object, false);
|
||||
});
|
||||
}
|
||||
|
||||
update(timestamp: number): void {
|
||||
if (this.targetOpacity > 0 && timestamp >= this.visibleUntil) {
|
||||
this.targetOpacity = 0;
|
||||
}
|
||||
|
||||
const shouldBeVisible = this.targetOpacity > 0 || this.opacity > 0.001;
|
||||
this.objects.forEach((object) => this.setObjectVisible(object, shouldBeVisible));
|
||||
|
||||
if (this.opacity === this.targetOpacity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fadeStep = this.fadeDurationMs <= 0
|
||||
? 1
|
||||
: Math.min(1, 16.67 / this.fadeDurationMs);
|
||||
const direction = this.opacity < this.targetOpacity ? 1 : -1;
|
||||
this.opacity = Math.max(0, Math.min(1, this.opacity + fadeStep * direction));
|
||||
|
||||
if ((direction > 0 && this.opacity >= this.targetOpacity) || (direction < 0 && this.opacity <= this.targetOpacity)) {
|
||||
this.opacity = this.targetOpacity;
|
||||
}
|
||||
|
||||
this.objects.forEach((object) => {
|
||||
this.setObjectOpacity(object, this.opacity);
|
||||
this.setObjectVisible(object, this.opacity > 0.001);
|
||||
});
|
||||
}
|
||||
|
||||
private setObjectVisible(object: any, isVisible: boolean): void {
|
||||
const objectVisible = isVisible && object.userData?.vrwpOverlayAvailable !== false;
|
||||
object.visible = objectVisible;
|
||||
object.traverse?.((child: any) => {
|
||||
child.visible = objectVisible;
|
||||
});
|
||||
}
|
||||
|
||||
private setObjectOpacity(object: any, opacity: number): void {
|
||||
object.traverse?.((child: any) => {
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
materials.filter(Boolean).forEach((material: any) => {
|
||||
material.opacity = opacity;
|
||||
material.transparent = true;
|
||||
material.depthTest = false;
|
||||
material.depthWrite = false;
|
||||
material.needsUpdate = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function bindOverlayActivity(target: any, overlayVisibility: VrOverlayVisibility): void {
|
||||
[
|
||||
'connected',
|
||||
'disconnected',
|
||||
'select',
|
||||
'selectend',
|
||||
'squeezestart',
|
||||
'squeeze',
|
||||
'squeezeend',
|
||||
'pinchstart',
|
||||
'pinchend'
|
||||
].forEach((eventName) => {
|
||||
target.addEventListener?.(eventName, () => overlayVisibility.show());
|
||||
});
|
||||
}
|
||||
|
||||
export function createPointerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
|
||||
const group = new THREE.Group();
|
||||
group.name = `vrPointerOverlay${index}`;
|
||||
|
||||
const pointerMaterial = createOverlayLineMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.75);
|
||||
const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0, 0, -POINTER_LENGTH)
|
||||
]);
|
||||
const pointerLine = new THREE.Line(pointerGeometry, pointerMaterial);
|
||||
pointerLine.name = `vrPointerRay${index}`;
|
||||
pointerLine.renderOrder = INPUT_OVERLAY_RENDER_ORDER;
|
||||
group.add(pointerLine);
|
||||
|
||||
const tipMaterial = createOverlayMeshMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.9);
|
||||
const tipMesh = new THREE.Mesh(new THREE.SphereGeometry(0.018, 12, 8), tipMaterial);
|
||||
tipMesh.name = `vrPointerTip${index}`;
|
||||
tipMesh.position.z = -POINTER_LENGTH;
|
||||
tipMesh.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
|
||||
group.add(tipMesh);
|
||||
|
||||
group.userData = {
|
||||
...group.userData,
|
||||
vrwpPointerLength: POINTER_LENGTH,
|
||||
vrwpPointerLine: pointerLine,
|
||||
vrwpPointerTip: tipMesh
|
||||
};
|
||||
|
||||
overlayVisibility.register(group);
|
||||
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}`;
|
||||
|
||||
const material = createOverlayLineMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.8);
|
||||
const outlineGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-0.045, -0.025, -0.08),
|
||||
new THREE.Vector3(0.045, -0.025, -0.08),
|
||||
new THREE.Vector3(0.055, 0.025, -0.02),
|
||||
new THREE.Vector3(0.025, 0.035, 0.05),
|
||||
new THREE.Vector3(-0.025, 0.035, 0.05),
|
||||
new THREE.Vector3(-0.055, 0.025, -0.02),
|
||||
new THREE.Vector3(-0.045, -0.025, -0.08)
|
||||
]);
|
||||
const outline = new THREE.Line(outlineGeometry, material);
|
||||
outline.name = `vrControllerOutline${index}`;
|
||||
outline.renderOrder = INPUT_OVERLAY_RENDER_ORDER;
|
||||
group.add(outline);
|
||||
|
||||
const originMaterial = createOverlayMeshMaterial(0xffffff, 0.75);
|
||||
const origin = new THREE.Mesh(new THREE.SphereGeometry(0.012, 10, 6), originMaterial);
|
||||
origin.name = `vrControllerOrigin${index}`;
|
||||
origin.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
|
||||
group.add(origin);
|
||||
|
||||
overlayVisibility.register(group);
|
||||
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 ? 0x8fd8ff : 0xffc16b, 0.85);
|
||||
joints.forEach(({ joint, name }) => {
|
||||
if (!joint || joint.userData?.vrwpHandOverlayMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTip = name.endsWith('tip');
|
||||
const isWrist = name === 'wrist';
|
||||
const radius = isWrist ? 0.014 : isTip ? 0.011 : 0.008;
|
||||
const marker = new THREE.Mesh(new THREE.SphereGeometry(radius, 10, 6), 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 {
|
||||
return Math.max(
|
||||
POINTER_MIN_LENGTH,
|
||||
Math.min(POINTER_LENGTH, distance - POINTER_HIT_SURFACE_OFFSET)
|
||||
);
|
||||
}
|
||||
|
||||
export 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
|
||||
};
|
||||
}
|
||||
|
||||
export function createOverlayLineMaterial(color: number, opacity: number): any {
|
||||
return new THREE.LineBasicMaterial({
|
||||
color,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
opacity,
|
||||
transparent: true
|
||||
});
|
||||
}
|
||||
|
||||
export function createOverlayMeshMaterial(color: number, opacity: number): any {
|
||||
return new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
opacity,
|
||||
transparent: true
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
import { drawLucideIcon } from '../dom/icons.js';
|
||||
import { createLucideButtonTexture, drawRoundedRect } from '../rendering/three-utils.js';
|
||||
import type { MediaCapabilities } from '../media/media-adapter.js';
|
||||
|
||||
type ButtonLayout = {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
name: string;
|
||||
size: number;
|
||||
texture: any;
|
||||
};
|
||||
import {
|
||||
createStaticVrButtonTexture,
|
||||
createVrButtonTexture,
|
||||
updateLoopButtonTexture,
|
||||
updatePlayPauseButtonTexture,
|
||||
updateVolumeButtonTexture,
|
||||
type VrButtonTexture
|
||||
} from './vr-panel-button-textures.js';
|
||||
import {
|
||||
VR_PANEL_BUTTON_LAYOUTS,
|
||||
WORLD_SEEK_BAR_WIDTH
|
||||
} from './vr-panel-layout.js';
|
||||
import {
|
||||
createButtonMesh,
|
||||
createPanelBackground,
|
||||
createSeekBarMeshes,
|
||||
createVrPanelGroup
|
||||
} from './vr-panel-meshes.js';
|
||||
|
||||
export type VrControlPanel = {
|
||||
exitButtonMesh: any;
|
||||
@@ -34,53 +41,6 @@ export type VrControlPanel = {
|
||||
volumeButtonTexture?: any;
|
||||
};
|
||||
|
||||
const FIGMA_PANEL_WIDTH_PX = 450;
|
||||
const FIGMA_PANEL_HEIGHT_PX = 132;
|
||||
const FIGMA_CORNER_RADIUS_PX = 30;
|
||||
const FIGMA_TITLE_FONT_SIZE_PX = 14;
|
||||
const FIGMA_TITLE_MARGIN_TOP_PX = 20;
|
||||
const FIGMA_SEEK_BAR_WIDTH_PX = 386;
|
||||
const FIGMA_SEEK_BAR_HEIGHT_PX = 5;
|
||||
const FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX = (FIGMA_PANEL_HEIGHT_PX / 2) - 54;
|
||||
|
||||
const FIGMA_PLAYPAUSE_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_PLAYPAUSE_BUTTON_X_PX = 225;
|
||||
const FIGMA_PLAYPAUSE_BUTTON_Y_PX = 90;
|
||||
|
||||
const FIGMA_REWIND_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_REWIND_BUTTON_X_PX = 169;
|
||||
const FIGMA_REWIND_BUTTON_Y_PX = 90;
|
||||
|
||||
const FIGMA_FORWARD_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_FORWARD_BUTTON_X_PX = 281;
|
||||
const FIGMA_FORWARD_BUTTON_Y_PX = 90;
|
||||
|
||||
const FIGMA_LOOP_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_LOOP_BUTTON_X_PX = 352;
|
||||
const FIGMA_LOOP_BUTTON_Y_PX = 90;
|
||||
|
||||
const FIGMA_EXIT_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_EXIT_BUTTON_X_PX = 42;
|
||||
const FIGMA_EXIT_BUTTON_Y_PX = 90;
|
||||
|
||||
const FIGMA_VOLUME_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_VOLUME_BUTTON_X_PX = 408;
|
||||
const FIGMA_VOLUME_BUTTON_Y_PX = 90;
|
||||
|
||||
const WORLD_PANEL_WIDTH = 1.5;
|
||||
const SCALE_FACTOR = WORLD_PANEL_WIDTH / FIGMA_PANEL_WIDTH_PX;
|
||||
const WORLD_PANEL_HEIGHT = FIGMA_PANEL_HEIGHT_PX * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_WIDTH = FIGMA_SEEK_BAR_WIDTH_PX * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_TRACK_HEIGHT = FIGMA_SEEK_BAR_HEIGHT_PX * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_PROGRESS_HEIGHT = (FIGMA_SEEK_BAR_HEIGHT_PX - 1) * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_Y_OFFSET = FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER = 12;
|
||||
|
||||
const PANEL_TEXTURE_WIDTH = 1024;
|
||||
const PANEL_TEXTURE_HEIGHT = Math.round(PANEL_TEXTURE_WIDTH * (FIGMA_PANEL_HEIGHT_PX / FIGMA_PANEL_WIDTH_PX));
|
||||
const VR_BUTTON_TEXTURE_SIZE = 128;
|
||||
const VR_BUTTON_ICON_SIZE = 82;
|
||||
|
||||
const DEFAULT_MEDIA_CAPABILITIES: MediaCapabilities = {
|
||||
audio: true,
|
||||
carousel: false,
|
||||
@@ -95,12 +55,9 @@ export function createVrControlPanel(
|
||||
title: string,
|
||||
mediaCapabilities: MediaCapabilities = DEFAULT_MEDIA_CAPABILITIES
|
||||
): VrControlPanel {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(0, 0.5, -1.8);
|
||||
group.rotation.x = 0;
|
||||
scene.add(group);
|
||||
|
||||
const group = createVrPanelGroup(scene);
|
||||
const interactables: any[] = [];
|
||||
|
||||
const panelMesh = createPanelBackground(title);
|
||||
group.add(panelMesh);
|
||||
interactables.push(panelMesh);
|
||||
@@ -109,139 +66,75 @@ export function createVrControlPanel(
|
||||
let seekBarProgressMesh;
|
||||
let seekBarHitAreaMesh;
|
||||
if (mediaCapabilities.timeline) {
|
||||
const seekBarTrackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
|
||||
const seekBarTrackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
|
||||
seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
|
||||
seekBarTrackMesh.name = 'seekBarTrackVisual';
|
||||
seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||
seekBarTrackMesh.position.z = 0.01;
|
||||
seekBarTrackMesh.renderOrder = 1;
|
||||
const seekBarMeshes = createSeekBarMeshes();
|
||||
seekBarTrackMesh = seekBarMeshes.trackMesh;
|
||||
seekBarProgressMesh = seekBarMeshes.progressMesh;
|
||||
seekBarHitAreaMesh = seekBarMeshes.hitAreaMesh;
|
||||
group.add(seekBarTrackMesh);
|
||||
|
||||
const seekBarProgressMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 });
|
||||
const seekBarProgressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT);
|
||||
seekBarProgressMesh = new THREE.Mesh(seekBarProgressGeometry, seekBarProgressMaterial);
|
||||
seekBarProgressMesh.name = 'seekBarProgressVisual';
|
||||
seekBarProgressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR;
|
||||
seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
|
||||
seekBarProgressMesh.position.z = 0.015;
|
||||
seekBarProgressMesh.scale.x = 0.001;
|
||||
seekBarProgressMesh.renderOrder = 2;
|
||||
group.add(seekBarProgressMesh);
|
||||
|
||||
const seekBarHitAreaGeometry = new THREE.PlaneGeometry(
|
||||
WORLD_SEEK_BAR_WIDTH,
|
||||
WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
|
||||
);
|
||||
const seekBarHitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
|
||||
seekBarHitAreaMesh = new THREE.Mesh(seekBarHitAreaGeometry, seekBarHitAreaMaterial);
|
||||
seekBarHitAreaMesh.name = 'seekBarHitArea';
|
||||
seekBarHitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||
seekBarHitAreaMesh.position.z = 0.012;
|
||||
seekBarHitAreaMesh.renderOrder = 2;
|
||||
group.add(seekBarHitAreaMesh);
|
||||
interactables.push(seekBarHitAreaMesh);
|
||||
}
|
||||
|
||||
let playPauseButtonCanvas;
|
||||
let playPauseButtonContext;
|
||||
let playPauseButtonTexture;
|
||||
let playPauseButton: VrButtonTexture | undefined;
|
||||
let playPauseButtonMesh;
|
||||
let loopButtonCanvas;
|
||||
let loopButtonContext;
|
||||
let loopButtonTexture;
|
||||
let loopButton: VrButtonTexture | undefined;
|
||||
let loopButtonMesh;
|
||||
let rewindButtonMesh;
|
||||
let forwardButtonMesh;
|
||||
if (mediaCapabilities.playback) {
|
||||
playPauseButtonCanvas = document.createElement('canvas');
|
||||
playPauseButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
|
||||
playPauseButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
|
||||
playPauseButtonContext = playPauseButtonCanvas.getContext('2d');
|
||||
playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas);
|
||||
playPauseButtonTexture.minFilter = THREE.LinearFilter;
|
||||
playPauseButton = createVrButtonTexture();
|
||||
playPauseButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_PLAYPAUSE_BUTTON_X_PX,
|
||||
centerY: FIGMA_PLAYPAUSE_BUTTON_Y_PX,
|
||||
name: 'vrPlayPauseButton',
|
||||
size: FIGMA_PLAYPAUSE_BUTTON_SIZE_PX,
|
||||
texture: playPauseButtonTexture
|
||||
...VR_PANEL_BUTTON_LAYOUTS.playPause,
|
||||
texture: playPauseButton.texture
|
||||
});
|
||||
group.add(playPauseButtonMesh);
|
||||
interactables.push(playPauseButtonMesh);
|
||||
|
||||
loopButtonCanvas = document.createElement('canvas');
|
||||
loopButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
|
||||
loopButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
|
||||
loopButtonContext = loopButtonCanvas.getContext('2d');
|
||||
loopButtonTexture = new THREE.CanvasTexture(loopButtonCanvas);
|
||||
loopButtonTexture.minFilter = THREE.LinearFilter;
|
||||
drawVrLoopButtonIcon(loopButtonContext, loopButtonCanvas, false);
|
||||
loopButton = createVrButtonTexture();
|
||||
updateLoopButtonTexture(loopButton, false);
|
||||
loopButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_LOOP_BUTTON_X_PX,
|
||||
centerY: FIGMA_LOOP_BUTTON_Y_PX,
|
||||
name: 'vrLoopButton',
|
||||
size: FIGMA_LOOP_BUTTON_SIZE_PX,
|
||||
texture: loopButtonTexture
|
||||
...VR_PANEL_BUTTON_LAYOUTS.loop,
|
||||
texture: loopButton.texture
|
||||
});
|
||||
group.add(loopButtonMesh);
|
||||
interactables.push(loopButtonMesh);
|
||||
|
||||
}
|
||||
|
||||
if (mediaCapabilities.navigation) {
|
||||
rewindButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_REWIND_BUTTON_X_PX,
|
||||
centerY: FIGMA_REWIND_BUTTON_Y_PX,
|
||||
name: 'vrRewindButton',
|
||||
size: FIGMA_REWIND_BUTTON_SIZE_PX,
|
||||
...VR_PANEL_BUTTON_LAYOUTS.rewind,
|
||||
texture: mediaCapabilities.carousel
|
||||
? createLucideButtonTexture('chevron-left', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
||||
: createLucideButtonTexture('rotate-ccw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
|
||||
? createStaticVrButtonTexture('chevron-left')
|
||||
: createStaticVrButtonTexture('rotate-ccw', '15')
|
||||
});
|
||||
group.add(rewindButtonMesh);
|
||||
interactables.push(rewindButtonMesh);
|
||||
|
||||
forwardButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_FORWARD_BUTTON_X_PX,
|
||||
centerY: FIGMA_FORWARD_BUTTON_Y_PX,
|
||||
name: 'vrForwardButton',
|
||||
size: FIGMA_FORWARD_BUTTON_SIZE_PX,
|
||||
...VR_PANEL_BUTTON_LAYOUTS.forward,
|
||||
texture: mediaCapabilities.carousel
|
||||
? createLucideButtonTexture('chevron-right', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
||||
: createLucideButtonTexture('rotate-cw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
|
||||
? createStaticVrButtonTexture('chevron-right')
|
||||
: createStaticVrButtonTexture('rotate-cw', '15')
|
||||
});
|
||||
group.add(forwardButtonMesh);
|
||||
interactables.push(forwardButtonMesh);
|
||||
}
|
||||
|
||||
const exitButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_EXIT_BUTTON_X_PX,
|
||||
centerY: FIGMA_EXIT_BUTTON_Y_PX,
|
||||
name: 'vrExitButton',
|
||||
size: FIGMA_EXIT_BUTTON_SIZE_PX,
|
||||
texture: createLucideButtonTexture('arrow-left', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
||||
...VR_PANEL_BUTTON_LAYOUTS.exit,
|
||||
texture: createStaticVrButtonTexture('arrow-left')
|
||||
});
|
||||
group.add(exitButtonMesh);
|
||||
interactables.push(exitButtonMesh);
|
||||
|
||||
let volumeButtonCanvas;
|
||||
let volumeButtonContext;
|
||||
let volumeButtonTexture;
|
||||
let volumeButton: VrButtonTexture | undefined;
|
||||
let volumeButtonMesh;
|
||||
if (mediaCapabilities.audio) {
|
||||
volumeButtonCanvas = document.createElement('canvas');
|
||||
volumeButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
|
||||
volumeButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
|
||||
volumeButtonContext = volumeButtonCanvas.getContext('2d');
|
||||
volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas);
|
||||
volumeButtonTexture.minFilter = THREE.LinearFilter;
|
||||
volumeButton = createVrButtonTexture();
|
||||
volumeButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_VOLUME_BUTTON_X_PX,
|
||||
centerY: FIGMA_VOLUME_BUTTON_Y_PX,
|
||||
name: 'vrVolumeButton',
|
||||
size: FIGMA_VOLUME_BUTTON_SIZE_PX,
|
||||
texture: volumeButtonTexture
|
||||
...VR_PANEL_BUTTON_LAYOUTS.volume,
|
||||
texture: volumeButton.texture
|
||||
});
|
||||
group.add(volumeButtonMesh);
|
||||
interactables.push(volumeButtonMesh);
|
||||
@@ -254,68 +147,47 @@ export function createVrControlPanel(
|
||||
forwardButtonMesh,
|
||||
group,
|
||||
interactables,
|
||||
loopButtonCanvas,
|
||||
loopButtonContext,
|
||||
loopButtonCanvas: loopButton?.canvas,
|
||||
loopButtonContext: loopButton?.context,
|
||||
loopButtonMesh,
|
||||
loopButtonTexture,
|
||||
playPauseButtonCanvas,
|
||||
playPauseButtonContext,
|
||||
loopButtonTexture: loopButton?.texture,
|
||||
playPauseButtonCanvas: playPauseButton?.canvas,
|
||||
playPauseButtonContext: playPauseButton?.context,
|
||||
playPauseButtonMesh,
|
||||
playPauseButtonTexture,
|
||||
playPauseButtonTexture: playPauseButton?.texture,
|
||||
rewindButtonMesh,
|
||||
seekBarHitAreaMesh,
|
||||
seekBarProgressMesh,
|
||||
seekBarTrackMesh,
|
||||
volumeButtonCanvas,
|
||||
volumeButtonContext,
|
||||
volumeButtonCanvas: volumeButton?.canvas,
|
||||
volumeButtonContext: volumeButton?.context,
|
||||
volumeButtonMesh,
|
||||
volumeButtonTexture
|
||||
volumeButtonTexture: volumeButton?.texture
|
||||
};
|
||||
}
|
||||
|
||||
export function updateVrPlayPauseButtonIcon(panel: VrControlPanel | undefined, isPausedOrEnded: boolean): void {
|
||||
if (!panel?.playPauseButtonContext || !panel.playPauseButtonTexture) return;
|
||||
|
||||
const ctx = panel.playPauseButtonContext;
|
||||
const canvas = panel.playPauseButtonCanvas;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||
drawLucideIcon(ctx, isPausedOrEnded ? 'play' : 'pause', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
|
||||
panel.playPauseButtonTexture.needsUpdate = true;
|
||||
updatePlayPauseButtonTexture({
|
||||
canvas: panel?.playPauseButtonCanvas,
|
||||
context: panel?.playPauseButtonContext,
|
||||
texture: panel?.playPauseButtonTexture
|
||||
}, isPausedOrEnded);
|
||||
}
|
||||
|
||||
export function updateVrLoopButtonIcon(panel: VrControlPanel | undefined, isLooping: boolean): void {
|
||||
if (!panel?.loopButtonContext || !panel.loopButtonTexture) return;
|
||||
|
||||
drawVrLoopButtonIcon(panel.loopButtonContext, panel.loopButtonCanvas, isLooping);
|
||||
panel.loopButtonTexture.needsUpdate = true;
|
||||
updateLoopButtonTexture({
|
||||
canvas: panel?.loopButtonCanvas,
|
||||
context: panel?.loopButtonContext,
|
||||
texture: panel?.loopButtonTexture
|
||||
}, isLooping);
|
||||
}
|
||||
|
||||
export function updateVrVolumeButtonIcon(panel: VrControlPanel | undefined, isMuted: boolean): void {
|
||||
if (!panel?.volumeButtonContext || !panel.volumeButtonTexture) return;
|
||||
|
||||
const ctx = panel.volumeButtonContext;
|
||||
const canvas = panel.volumeButtonCanvas;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||
drawLucideIcon(ctx, isMuted ? 'volume-x' : 'volume-2', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
|
||||
panel.volumeButtonTexture.needsUpdate = true;
|
||||
}
|
||||
|
||||
function drawVrLoopButtonIcon(
|
||||
ctx: CanvasRenderingContext2D | null | undefined,
|
||||
canvas: HTMLCanvasElement | undefined,
|
||||
isLooping: boolean
|
||||
): void {
|
||||
if (!ctx || !canvas) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (isLooping) {
|
||||
drawRoundedRect(ctx, 10, 10, canvas.width - 20, canvas.height - 20, 28, 'rgba(125, 211, 252, 0.24)', false);
|
||||
}
|
||||
|
||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||
drawLucideIcon(ctx, 'repeat', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, isLooping ? '#7dd3fc' : '#ffffff', 2);
|
||||
updateVolumeButtonTexture({
|
||||
canvas: panel?.volumeButtonCanvas,
|
||||
context: panel?.volumeButtonContext,
|
||||
texture: panel?.volumeButtonTexture
|
||||
}, isMuted);
|
||||
}
|
||||
|
||||
export function updateVrSeekBarAppearance(panel: VrControlPanel | undefined, progress: number | null): void {
|
||||
@@ -356,63 +228,3 @@ export function getSeekProgressFromIntersection(panel: VrControlPanel | undefine
|
||||
const normalizedPosition = (localPoint.x + WORLD_SEEK_BAR_WIDTH / 2) / WORLD_SEEK_BAR_WIDTH;
|
||||
return Math.max(0, Math.min(1, normalizedPosition));
|
||||
}
|
||||
|
||||
function createPanelBackground(title: string): any {
|
||||
const panelCanvas = document.createElement('canvas');
|
||||
panelCanvas.width = PANEL_TEXTURE_WIDTH;
|
||||
panelCanvas.height = PANEL_TEXTURE_HEIGHT;
|
||||
const panelCtx = panelCanvas.getContext('2d');
|
||||
|
||||
if (!panelCtx) {
|
||||
throw new Error('Unable to create 2D canvas context for VR control panel.');
|
||||
}
|
||||
|
||||
panelCtx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
const textureCornerRadius = FIGMA_CORNER_RADIUS_PX * (PANEL_TEXTURE_WIDTH / FIGMA_PANEL_WIDTH_PX);
|
||||
drawRoundedRect(panelCtx, 0, 0, PANEL_TEXTURE_WIDTH, PANEL_TEXTURE_HEIGHT, textureCornerRadius, true, false);
|
||||
|
||||
const titleFontSizeTexturePx = Math.round(FIGMA_TITLE_FONT_SIZE_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX));
|
||||
panelCtx.fillStyle = '#ffffff';
|
||||
panelCtx.font = `500 ${titleFontSizeTexturePx}px Helvetica, Arial, sans-serif`;
|
||||
panelCtx.textAlign = 'center';
|
||||
panelCtx.textBaseline = 'top';
|
||||
const titleMarginTopTexturePx = FIGMA_TITLE_MARGIN_TOP_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX);
|
||||
panelCtx.fillText(title, PANEL_TEXTURE_WIDTH / 2, titleMarginTopTexturePx);
|
||||
|
||||
const panelTexture = new THREE.CanvasTexture(panelCanvas);
|
||||
panelTexture.minFilter = THREE.LinearFilter;
|
||||
panelTexture.needsUpdate = true;
|
||||
|
||||
const panelMaterial = new THREE.MeshBasicMaterial({
|
||||
map: panelTexture,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false
|
||||
});
|
||||
const panelGeometry = new THREE.PlaneGeometry(WORLD_PANEL_WIDTH, WORLD_PANEL_HEIGHT);
|
||||
const panelMesh = new THREE.Mesh(panelGeometry, panelMaterial);
|
||||
panelMesh.name = 'vrControlPanelBackground';
|
||||
panelMesh.renderOrder = 0;
|
||||
|
||||
return panelMesh;
|
||||
}
|
||||
|
||||
function createButtonMesh({ centerX, centerY, name, size, texture }: ButtonLayout): any {
|
||||
const buttonWorldSize = size * SCALE_FACTOR;
|
||||
const buttonMaterial = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false
|
||||
});
|
||||
const buttonGeometry = new THREE.PlaneGeometry(buttonWorldSize, buttonWorldSize);
|
||||
const buttonMesh = new THREE.Mesh(buttonGeometry, buttonMaterial);
|
||||
buttonMesh.name = name;
|
||||
buttonMesh.renderOrder = 3;
|
||||
|
||||
const worldButtonOffsetX = (centerX - FIGMA_PANEL_WIDTH_PX / 2) * SCALE_FACTOR;
|
||||
const worldButtonOffsetY = -(centerY - FIGMA_PANEL_HEIGHT_PX / 2) * SCALE_FACTOR;
|
||||
buttonMesh.position.set(worldButtonOffsetX, worldButtonOffsetY, 0.02);
|
||||
|
||||
return buttonMesh;
|
||||
}
|
||||
|
||||
@@ -3,17 +3,13 @@ import {
|
||||
getSeekProgressFromIntersection,
|
||||
type VrControlPanel
|
||||
} from './vr-control-panel.js';
|
||||
import { getPalmAimSelectionRay } from './hand-aim.js';
|
||||
import {
|
||||
beginPalmAimSelection,
|
||||
computePalmAimRay,
|
||||
createPalmAimLatch,
|
||||
endPalmAimSelection,
|
||||
getPalmAimSelectionRay,
|
||||
recordStablePalmAimRay,
|
||||
type PalmAimLatch,
|
||||
type PalmAimRay,
|
||||
type VectorLike
|
||||
} from './hand-aim.js';
|
||||
getHandAimRay,
|
||||
toAimRay,
|
||||
type AimRay
|
||||
} from './hand-aim-three.js';
|
||||
import { shouldUseHandPointer } from './input-mode.js';
|
||||
|
||||
type VrControllerSelectionOptions = {
|
||||
beginSeekDrag?: (controller: any) => void;
|
||||
@@ -32,214 +28,8 @@ type VrControllerSelectionOptions = {
|
||||
vrPanel: VrControlPanel | undefined;
|
||||
};
|
||||
|
||||
type VrInputRig = {
|
||||
beginSeekDrag: (controller: any, panel: VrControlPanel | undefined, onSeek: (progress: number) => void) => void;
|
||||
hideOverlays: () => void;
|
||||
raycaster: any;
|
||||
showOverlays: (timestamp?: number) => void;
|
||||
update: (timestamp: number, hoverTargets?: any[]) => boolean;
|
||||
};
|
||||
|
||||
type AimRay = {
|
||||
direction: any;
|
||||
origin: any;
|
||||
};
|
||||
|
||||
type ActiveSeekDrag = {
|
||||
inputSource: VrInputSource;
|
||||
onSeek: (progress: number) => void;
|
||||
panel: VrControlPanel;
|
||||
};
|
||||
|
||||
type HandPointerOverlay = {
|
||||
fallbackPointerOverlay: any;
|
||||
hand: any;
|
||||
handAimLatch: PalmAimLatch;
|
||||
inputSource: VrInputSource;
|
||||
pointerOverlay: any;
|
||||
};
|
||||
|
||||
type PointerInputMode = 'controller' | 'hand';
|
||||
|
||||
type VrInputSource = {
|
||||
controller: any;
|
||||
controllerPointerOverlay: any;
|
||||
hand?: any;
|
||||
handAimLatch?: PalmAimLatch;
|
||||
handPointerOverlay?: any;
|
||||
pointerInputMode: PointerInputMode;
|
||||
};
|
||||
|
||||
type VrOverlayVisibilityOptions = {
|
||||
fadeDurationMs?: number;
|
||||
hideDelayMs?: number;
|
||||
};
|
||||
|
||||
const INPUT_OVERLAY_HIDE_DELAY_MS = 2500;
|
||||
const INPUT_OVERLAY_FADE_DURATION_MS = 200;
|
||||
const INPUT_OVERLAY_RENDER_ORDER = 10000;
|
||||
const POINTER_LENGTH = 5;
|
||||
const POINTER_MIN_LENGTH = 0.06;
|
||||
const POINTER_HIT_SURFACE_OFFSET = 0.015;
|
||||
const HAND_JOINT_NAMES = [
|
||||
'wrist',
|
||||
'thumb-metacarpal',
|
||||
'thumb-phalanx-proximal',
|
||||
'thumb-phalanx-distal',
|
||||
'thumb-tip',
|
||||
'index-finger-metacarpal',
|
||||
'index-finger-phalanx-proximal',
|
||||
'index-finger-phalanx-intermediate',
|
||||
'index-finger-phalanx-distal',
|
||||
'index-finger-tip',
|
||||
'middle-finger-metacarpal',
|
||||
'middle-finger-phalanx-proximal',
|
||||
'middle-finger-phalanx-intermediate',
|
||||
'middle-finger-phalanx-distal',
|
||||
'middle-finger-tip',
|
||||
'ring-finger-metacarpal',
|
||||
'ring-finger-phalanx-proximal',
|
||||
'ring-finger-phalanx-intermediate',
|
||||
'ring-finger-phalanx-distal',
|
||||
'ring-finger-tip',
|
||||
'pinky-finger-metacarpal',
|
||||
'pinky-finger-phalanx-proximal',
|
||||
'pinky-finger-phalanx-intermediate',
|
||||
'pinky-finger-phalanx-distal',
|
||||
'pinky-finger-tip'
|
||||
];
|
||||
const DEFAULT_RAY_DIRECTION = new THREE.Vector3(0, 0, -1);
|
||||
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 = new THREE.Raycaster();
|
||||
raycaster.near = 0.1;
|
||||
raycaster.far = POINTER_LENGTH;
|
||||
const hoverRaycaster = new THREE.Raycaster();
|
||||
hoverRaycaster.near = 0.1;
|
||||
hoverRaycaster.far = POINTER_LENGTH;
|
||||
const dragRaycaster = new THREE.Raycaster();
|
||||
dragRaycaster.near = 0.1;
|
||||
dragRaycaster.far = POINTER_LENGTH;
|
||||
let activeSeekDrag: ActiveSeekDrag | null = null;
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const controller = renderer.xr.getController(index);
|
||||
const controllerPointerOverlay = createPointerOverlay(index, overlayVisibility);
|
||||
const inputSource: VrInputSource = {
|
||||
controller,
|
||||
controllerPointerOverlay,
|
||||
pointerInputMode: 'controller'
|
||||
};
|
||||
controller.userData = {
|
||||
...controller.userData,
|
||||
vrwpInputSource: inputSource
|
||||
};
|
||||
inputSources.push(inputSource);
|
||||
controller.addEventListener('connected', (event: any) => {
|
||||
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);
|
||||
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);
|
||||
|
||||
const grip = renderer.xr.getControllerGrip?.(index);
|
||||
if (grip) {
|
||||
grip.addEventListener('connected', (event: any) => {
|
||||
rememberPointerInputMode(inputSource, event, 'controller');
|
||||
});
|
||||
bindOverlayActivity(grip, overlayVisibility);
|
||||
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();
|
||||
|
||||
return {
|
||||
beginSeekDrag: (controller: any, panel: VrControlPanel | undefined, onSeek: (progress: number) => void) => {
|
||||
const inputSource = getInputSourceByController(inputSources, controller);
|
||||
if (!inputSource || !panel?.seekBarHitAreaMesh) {
|
||||
activeSeekDrag = null;
|
||||
return;
|
||||
}
|
||||
|
||||
activeSeekDrag = { inputSource, onSeek, panel };
|
||||
},
|
||||
hideOverlays: () => overlayVisibility.hideImmediately(),
|
||||
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);
|
||||
if (isHovering) {
|
||||
overlayVisibility.show(timestamp);
|
||||
}
|
||||
overlayVisibility.update(timestamp);
|
||||
return isHovering;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function handleVrControllerSelect(event: any, options: VrControllerSelectionOptions): void {
|
||||
const controller = event.target;
|
||||
if (!options.raycaster) return;
|
||||
@@ -325,230 +115,6 @@ function applySelectionRay(controller: any, raycaster: any): void {
|
||||
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
|
||||
}
|
||||
|
||||
function getInputSourceByController(inputSources: VrInputSource[], controller: any): VrInputSource | undefined {
|
||||
return inputSources.find((inputSource) => inputSource.controller === controller);
|
||||
}
|
||||
|
||||
function updateInputPointerIntersections(
|
||||
inputSources: VrInputSource[],
|
||||
hoverTargets: any[],
|
||||
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 updateActiveSeekDrag(activeSeekDrag: ActiveSeekDrag | null, dragRaycaster: any, timestamp: number): void {
|
||||
if (!activeSeekDrag?.panel.seekBarHitAreaMesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const aimRay = getInputSourceAimRay(activeSeekDrag.inputSource, timestamp, { preferLiveHandAim: true });
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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, 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 rememberPointerInputMode(
|
||||
inputSource: VrInputSource,
|
||||
event: any,
|
||||
fallbackMode: PointerInputMode
|
||||
): void {
|
||||
const eventInputSource = event?.data?.inputSource || event?.inputSource || event?.data;
|
||||
const nextMode = getPointerInputMode(eventInputSource) || fallbackMode;
|
||||
inputSource.pointerInputMode = nextMode;
|
||||
inputSource.controller.userData = {
|
||||
...inputSource.controller.userData,
|
||||
vrwpInputSource: inputSource
|
||||
};
|
||||
}
|
||||
|
||||
function getPointerInputMode(eventInputSource: any): PointerInputMode | null {
|
||||
if (!eventInputSource) {
|
||||
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;
|
||||
}
|
||||
|
||||
function shouldUseHandPointer(inputSource: VrInputSource | undefined): boolean {
|
||||
return inputSource?.pointerInputMode === 'hand';
|
||||
}
|
||||
|
||||
function getSelectionHandAimRay(controller: any): AimRay | null {
|
||||
const latch = controller.userData?.vrwpHandAimLatch ||
|
||||
controller.userData?.vrwpHand?.userData?.vrwpAimLatch;
|
||||
@@ -559,328 +125,3 @@ function getSelectionHandAimRay(controller: any): AimRay | null {
|
||||
const palmAimRay = getPalmAimSelectionRay(latch, performance.now());
|
||||
return palmAimRay ? toAimRay(palmAimRay) : null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const origin = toThreeVector(palmAimRay.origin);
|
||||
const direction = toThreeVector(palmAimRay.direction);
|
||||
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 {
|
||||
const handedness = event?.data?.handedness ||
|
||||
event?.data?.inputSource?.handedness ||
|
||||
hand?.inputState?.handedness;
|
||||
|
||||
if (handedness !== 'left' && handedness !== 'right') {
|
||||
return;
|
||||
}
|
||||
|
||||
hand.userData = {
|
||||
...hand.userData,
|
||||
vrwpHandedness: handedness
|
||||
};
|
||||
}
|
||||
|
||||
function getHandedness(hand: any): string | undefined {
|
||||
return hand?.userData?.vrwpHandedness ||
|
||||
hand?.inputState?.handedness ||
|
||||
hand?.userData?.inputSource?.handedness;
|
||||
}
|
||||
|
||||
function getJointWorldPosition(joint: any): VectorLike | null {
|
||||
if (!joint?.getWorldPosition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
joint.updateMatrixWorld?.(true);
|
||||
return joint.getWorldPosition(new THREE.Vector3());
|
||||
}
|
||||
|
||||
function toThreeVector(vector: VectorLike): any {
|
||||
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 {
|
||||
private readonly fadeDurationMs: number;
|
||||
private readonly hideDelayMs: number;
|
||||
private readonly objects: any[] = [];
|
||||
private opacity = 0;
|
||||
private targetOpacity = 0;
|
||||
private visibleUntil = 0;
|
||||
|
||||
constructor({
|
||||
fadeDurationMs = INPUT_OVERLAY_FADE_DURATION_MS,
|
||||
hideDelayMs = INPUT_OVERLAY_HIDE_DELAY_MS
|
||||
}: VrOverlayVisibilityOptions = {}) {
|
||||
this.fadeDurationMs = fadeDurationMs;
|
||||
this.hideDelayMs = hideDelayMs;
|
||||
}
|
||||
|
||||
register(object: any): void {
|
||||
this.objects.push(object);
|
||||
this.setObjectVisible(object, this.targetOpacity > 0 || this.opacity > 0.001);
|
||||
this.setObjectOpacity(object, this.opacity);
|
||||
}
|
||||
|
||||
show(timestamp = performance.now()): void {
|
||||
this.visibleUntil = timestamp + this.hideDelayMs;
|
||||
this.targetOpacity = 1;
|
||||
this.objects.forEach((object) => this.setObjectVisible(object, true));
|
||||
}
|
||||
|
||||
hideImmediately(): void {
|
||||
this.visibleUntil = 0;
|
||||
this.opacity = 0;
|
||||
this.targetOpacity = 0;
|
||||
this.objects.forEach((object) => {
|
||||
this.setObjectOpacity(object, 0);
|
||||
this.setObjectVisible(object, false);
|
||||
});
|
||||
}
|
||||
|
||||
update(timestamp: number): void {
|
||||
if (this.targetOpacity > 0 && timestamp >= this.visibleUntil) {
|
||||
this.targetOpacity = 0;
|
||||
}
|
||||
|
||||
const shouldBeVisible = this.targetOpacity > 0 || this.opacity > 0.001;
|
||||
this.objects.forEach((object) => this.setObjectVisible(object, shouldBeVisible));
|
||||
|
||||
if (this.opacity === this.targetOpacity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fadeStep = this.fadeDurationMs <= 0
|
||||
? 1
|
||||
: Math.min(1, 16.67 / this.fadeDurationMs);
|
||||
const direction = this.opacity < this.targetOpacity ? 1 : -1;
|
||||
this.opacity = Math.max(0, Math.min(1, this.opacity + fadeStep * direction));
|
||||
|
||||
if ((direction > 0 && this.opacity >= this.targetOpacity) || (direction < 0 && this.opacity <= this.targetOpacity)) {
|
||||
this.opacity = this.targetOpacity;
|
||||
}
|
||||
|
||||
this.objects.forEach((object) => {
|
||||
this.setObjectOpacity(object, this.opacity);
|
||||
this.setObjectVisible(object, this.opacity > 0.001);
|
||||
});
|
||||
}
|
||||
|
||||
private setObjectVisible(object: any, isVisible: boolean): void {
|
||||
const objectVisible = isVisible && object.userData?.vrwpOverlayAvailable !== false;
|
||||
object.visible = objectVisible;
|
||||
object.traverse?.((child: any) => {
|
||||
child.visible = objectVisible;
|
||||
});
|
||||
}
|
||||
|
||||
private setObjectOpacity(object: any, opacity: number): void {
|
||||
object.traverse?.((child: any) => {
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
materials.filter(Boolean).forEach((material: any) => {
|
||||
material.opacity = opacity;
|
||||
material.transparent = true;
|
||||
material.depthTest = false;
|
||||
material.depthWrite = false;
|
||||
material.needsUpdate = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bindOverlayActivity(target: any, overlayVisibility: VrOverlayVisibility): void {
|
||||
[
|
||||
'connected',
|
||||
'disconnected',
|
||||
'select',
|
||||
'selectend',
|
||||
'squeezestart',
|
||||
'squeeze',
|
||||
'squeezeend',
|
||||
'pinchstart',
|
||||
'pinchend'
|
||||
].forEach((eventName) => {
|
||||
target.addEventListener?.(eventName, () => overlayVisibility.show());
|
||||
});
|
||||
}
|
||||
|
||||
function createPointerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
|
||||
const group = new THREE.Group();
|
||||
group.name = `vrPointerOverlay${index}`;
|
||||
|
||||
const pointerMaterial = createOverlayLineMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.75);
|
||||
const pointerGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0, 0, -POINTER_LENGTH)
|
||||
]);
|
||||
const pointerLine = new THREE.Line(pointerGeometry, pointerMaterial);
|
||||
pointerLine.name = `vrPointerRay${index}`;
|
||||
pointerLine.renderOrder = INPUT_OVERLAY_RENDER_ORDER;
|
||||
group.add(pointerLine);
|
||||
|
||||
const tipMaterial = createOverlayMeshMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.9);
|
||||
const tipMesh = new THREE.Mesh(new THREE.SphereGeometry(0.018, 12, 8), tipMaterial);
|
||||
tipMesh.name = `vrPointerTip${index}`;
|
||||
tipMesh.position.z = -POINTER_LENGTH;
|
||||
tipMesh.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
|
||||
group.add(tipMesh);
|
||||
|
||||
group.userData = {
|
||||
...group.userData,
|
||||
vrwpPointerLength: POINTER_LENGTH,
|
||||
vrwpPointerLine: pointerLine,
|
||||
vrwpPointerTip: tipMesh
|
||||
};
|
||||
|
||||
overlayVisibility.register(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
function createWorldPointerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
|
||||
const group = createPointerOverlay(index, overlayVisibility);
|
||||
group.name = `vrHandPointerOverlay${index}`;
|
||||
group.userData = {
|
||||
...group.userData,
|
||||
vrwpOverlayAvailable: false
|
||||
};
|
||||
return group;
|
||||
}
|
||||
|
||||
function createControllerOverlay(index: number, overlayVisibility: VrOverlayVisibility): any {
|
||||
const group = new THREE.Group();
|
||||
group.name = `vrControllerOverlay${index}`;
|
||||
|
||||
const material = createOverlayLineMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.8);
|
||||
const outlineGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-0.045, -0.025, -0.08),
|
||||
new THREE.Vector3(0.045, -0.025, -0.08),
|
||||
new THREE.Vector3(0.055, 0.025, -0.02),
|
||||
new THREE.Vector3(0.025, 0.035, 0.05),
|
||||
new THREE.Vector3(-0.025, 0.035, 0.05),
|
||||
new THREE.Vector3(-0.055, 0.025, -0.02),
|
||||
new THREE.Vector3(-0.045, -0.025, -0.08)
|
||||
]);
|
||||
const outline = new THREE.Line(outlineGeometry, material);
|
||||
outline.name = `vrControllerOutline${index}`;
|
||||
outline.renderOrder = INPUT_OVERLAY_RENDER_ORDER;
|
||||
group.add(outline);
|
||||
|
||||
const originMaterial = createOverlayMeshMaterial(0xffffff, 0.75);
|
||||
const origin = new THREE.Mesh(new THREE.SphereGeometry(0.012, 10, 6), originMaterial);
|
||||
origin.name = `vrControllerOrigin${index}`;
|
||||
origin.renderOrder = INPUT_OVERLAY_RENDER_ORDER + 1;
|
||||
group.add(origin);
|
||||
|
||||
overlayVisibility.register(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
function createHandOverlay(hand: any, index: number, overlayVisibility: VrOverlayVisibility): void {
|
||||
const joints = getHandJoints(hand);
|
||||
if (joints.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const material = createOverlayMeshMaterial(index === 0 ? 0x8fd8ff : 0xffc16b, 0.85);
|
||||
joints.forEach(({ joint, name }) => {
|
||||
if (!joint || joint.userData?.vrwpHandOverlayMarker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTip = name.endsWith('tip');
|
||||
const isWrist = name === 'wrist';
|
||||
const radius = isWrist ? 0.014 : isTip ? 0.011 : 0.008;
|
||||
const marker = new THREE.Mesh(new THREE.SphereGeometry(radius, 10, 6), 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);
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function createOverlayLineMaterial(color: number, opacity: number): any {
|
||||
return new THREE.LineBasicMaterial({
|
||||
color,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
opacity,
|
||||
transparent: true
|
||||
});
|
||||
}
|
||||
|
||||
function createOverlayMeshMaterial(color: number, opacity: number): any {
|
||||
return new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
opacity,
|
||||
transparent: true
|
||||
});
|
||||
}
|
||||
|
||||
123
src/vr180player/xr/vr-panel-button-textures.ts
Normal file
123
src/vr180player/xr/vr-panel-button-textures.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
import { drawLucideIcon, type LucideIconName } from '../dom/icons.js';
|
||||
import { createLucideButtonTexture, drawRoundedRect } from '../rendering/three-utils.js';
|
||||
import {
|
||||
FIGMA_CORNER_RADIUS_PX,
|
||||
FIGMA_PANEL_HEIGHT_PX,
|
||||
FIGMA_PANEL_WIDTH_PX,
|
||||
FIGMA_TITLE_FONT_SIZE_PX,
|
||||
FIGMA_TITLE_MARGIN_TOP_PX,
|
||||
PANEL_TEXTURE_HEIGHT,
|
||||
PANEL_TEXTURE_WIDTH,
|
||||
VR_BUTTON_ICON_SIZE,
|
||||
VR_BUTTON_TEXTURE_SIZE
|
||||
} from './vr-panel-layout.js';
|
||||
|
||||
export type VrButtonTextureControls = {
|
||||
canvas?: HTMLCanvasElement;
|
||||
context?: CanvasRenderingContext2D | null;
|
||||
texture?: any;
|
||||
};
|
||||
|
||||
export type VrButtonTexture = {
|
||||
canvas: HTMLCanvasElement;
|
||||
context: CanvasRenderingContext2D | null;
|
||||
texture: any;
|
||||
};
|
||||
|
||||
export function createVrButtonTexture(): VrButtonTexture {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = VR_BUTTON_TEXTURE_SIZE;
|
||||
canvas.height = VR_BUTTON_TEXTURE_SIZE;
|
||||
const context = canvas.getContext('2d');
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
|
||||
return {
|
||||
canvas,
|
||||
context,
|
||||
texture
|
||||
};
|
||||
}
|
||||
|
||||
export function createStaticVrButtonTexture(iconName: LucideIconName, label?: string): any {
|
||||
return createLucideButtonTexture(iconName, '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, label);
|
||||
}
|
||||
|
||||
export function createPanelBackgroundTexture(title: string): any {
|
||||
const panelCanvas = document.createElement('canvas');
|
||||
panelCanvas.width = PANEL_TEXTURE_WIDTH;
|
||||
panelCanvas.height = PANEL_TEXTURE_HEIGHT;
|
||||
const panelCtx = panelCanvas.getContext('2d');
|
||||
|
||||
if (!panelCtx) {
|
||||
throw new Error('Unable to create 2D canvas context for VR control panel.');
|
||||
}
|
||||
|
||||
panelCtx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
const textureCornerRadius = FIGMA_CORNER_RADIUS_PX * (PANEL_TEXTURE_WIDTH / FIGMA_PANEL_WIDTH_PX);
|
||||
drawRoundedRect(panelCtx, 0, 0, PANEL_TEXTURE_WIDTH, PANEL_TEXTURE_HEIGHT, textureCornerRadius, true, false);
|
||||
|
||||
const titleFontSizeTexturePx = Math.round(FIGMA_TITLE_FONT_SIZE_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX));
|
||||
panelCtx.fillStyle = '#ffffff';
|
||||
panelCtx.font = `500 ${titleFontSizeTexturePx}px Helvetica, Arial, sans-serif`;
|
||||
panelCtx.textAlign = 'center';
|
||||
panelCtx.textBaseline = 'top';
|
||||
const titleMarginTopTexturePx = FIGMA_TITLE_MARGIN_TOP_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX);
|
||||
panelCtx.fillText(title, PANEL_TEXTURE_WIDTH / 2, titleMarginTopTexturePx);
|
||||
|
||||
const panelTexture = new THREE.CanvasTexture(panelCanvas);
|
||||
panelTexture.minFilter = THREE.LinearFilter;
|
||||
panelTexture.needsUpdate = true;
|
||||
return panelTexture;
|
||||
}
|
||||
|
||||
export function updatePlayPauseButtonTexture(
|
||||
controls: VrButtonTextureControls,
|
||||
isPausedOrEnded: boolean
|
||||
): void {
|
||||
if (!controls.context || !controls.canvas || !controls.texture) return;
|
||||
|
||||
const { canvas, context, texture } = controls;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||
drawLucideIcon(context, isPausedOrEnded ? 'play' : 'pause', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function updateLoopButtonTexture(
|
||||
controls: VrButtonTextureControls,
|
||||
isLooping: boolean
|
||||
): void {
|
||||
if (!controls.context || !controls.canvas || !controls.texture) return;
|
||||
|
||||
drawVrLoopButtonIcon(controls.context, controls.canvas, isLooping);
|
||||
controls.texture.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function updateVolumeButtonTexture(
|
||||
controls: VrButtonTextureControls,
|
||||
isMuted: boolean
|
||||
): void {
|
||||
if (!controls.context || !controls.canvas || !controls.texture) return;
|
||||
|
||||
const { canvas, context, texture } = controls;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||
drawLucideIcon(context, isMuted ? 'volume-x' : 'volume-2', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
|
||||
function drawVrLoopButtonIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
isLooping: boolean
|
||||
): void {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (isLooping) {
|
||||
drawRoundedRect(ctx, 10, 10, canvas.width - 20, canvas.height - 20, 28, 'rgba(125, 211, 252, 0.24)', false);
|
||||
}
|
||||
|
||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||
drawLucideIcon(ctx, 'repeat', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, isLooping ? '#7dd3fc' : '#ffffff', 2);
|
||||
}
|
||||
74
src/vr180player/xr/vr-panel-layout.ts
Normal file
74
src/vr180player/xr/vr-panel-layout.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export type VrPanelButtonLayout = {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
name: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export const FIGMA_PANEL_WIDTH_PX = 450;
|
||||
export const FIGMA_PANEL_HEIGHT_PX = 132;
|
||||
export const FIGMA_CORNER_RADIUS_PX = 30;
|
||||
export const FIGMA_TITLE_FONT_SIZE_PX = 14;
|
||||
export const FIGMA_TITLE_MARGIN_TOP_PX = 20;
|
||||
export const FIGMA_SEEK_BAR_WIDTH_PX = 386;
|
||||
export const FIGMA_SEEK_BAR_HEIGHT_PX = 5;
|
||||
export const FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX = (FIGMA_PANEL_HEIGHT_PX / 2) - 54;
|
||||
|
||||
export const WORLD_PANEL_WIDTH = 1.5;
|
||||
export const SCALE_FACTOR = WORLD_PANEL_WIDTH / FIGMA_PANEL_WIDTH_PX;
|
||||
export const WORLD_PANEL_HEIGHT = FIGMA_PANEL_HEIGHT_PX * SCALE_FACTOR;
|
||||
export const WORLD_SEEK_BAR_WIDTH = FIGMA_SEEK_BAR_WIDTH_PX * SCALE_FACTOR;
|
||||
export const WORLD_SEEK_BAR_TRACK_HEIGHT = FIGMA_SEEK_BAR_HEIGHT_PX * SCALE_FACTOR;
|
||||
export const WORLD_SEEK_BAR_PROGRESS_HEIGHT = (FIGMA_SEEK_BAR_HEIGHT_PX - 1) * SCALE_FACTOR;
|
||||
export const WORLD_SEEK_BAR_Y_OFFSET = FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX * SCALE_FACTOR;
|
||||
export const WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER = 12;
|
||||
|
||||
export const PANEL_TEXTURE_WIDTH = 1024;
|
||||
export const PANEL_TEXTURE_HEIGHT = Math.round(PANEL_TEXTURE_WIDTH * (FIGMA_PANEL_HEIGHT_PX / FIGMA_PANEL_WIDTH_PX));
|
||||
export const VR_BUTTON_TEXTURE_SIZE = 128;
|
||||
export const VR_BUTTON_ICON_SIZE = 82;
|
||||
|
||||
export const VR_PANEL_POSITION = {
|
||||
x: 0,
|
||||
y: 0.5,
|
||||
z: -1.8
|
||||
};
|
||||
|
||||
export const VR_PANEL_BUTTON_LAYOUTS = {
|
||||
exit: {
|
||||
centerX: 42,
|
||||
centerY: 90,
|
||||
name: 'vrExitButton',
|
||||
size: 44
|
||||
},
|
||||
forward: {
|
||||
centerX: 281,
|
||||
centerY: 90,
|
||||
name: 'vrForwardButton',
|
||||
size: 44
|
||||
},
|
||||
loop: {
|
||||
centerX: 352,
|
||||
centerY: 90,
|
||||
name: 'vrLoopButton',
|
||||
size: 44
|
||||
},
|
||||
playPause: {
|
||||
centerX: 225,
|
||||
centerY: 90,
|
||||
name: 'vrPlayPauseButton',
|
||||
size: 44
|
||||
},
|
||||
rewind: {
|
||||
centerX: 169,
|
||||
centerY: 90,
|
||||
name: 'vrRewindButton',
|
||||
size: 44
|
||||
},
|
||||
volume: {
|
||||
centerX: 408,
|
||||
centerY: 90,
|
||||
name: 'vrVolumeButton',
|
||||
size: 44
|
||||
}
|
||||
} satisfies Record<string, VrPanelButtonLayout>;
|
||||
106
src/vr180player/xr/vr-panel-meshes.ts
Normal file
106
src/vr180player/xr/vr-panel-meshes.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
import {
|
||||
FIGMA_PANEL_HEIGHT_PX,
|
||||
FIGMA_PANEL_WIDTH_PX,
|
||||
SCALE_FACTOR,
|
||||
VR_PANEL_POSITION,
|
||||
WORLD_PANEL_HEIGHT,
|
||||
WORLD_PANEL_WIDTH,
|
||||
WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER,
|
||||
WORLD_SEEK_BAR_PROGRESS_HEIGHT,
|
||||
WORLD_SEEK_BAR_TRACK_HEIGHT,
|
||||
WORLD_SEEK_BAR_WIDTH,
|
||||
WORLD_SEEK_BAR_Y_OFFSET,
|
||||
type VrPanelButtonLayout
|
||||
} from './vr-panel-layout.js';
|
||||
import { createPanelBackgroundTexture } from './vr-panel-button-textures.js';
|
||||
|
||||
export type ButtonMeshOptions = VrPanelButtonLayout & {
|
||||
texture: any;
|
||||
};
|
||||
|
||||
export type SeekBarMeshes = {
|
||||
hitAreaMesh: any;
|
||||
progressMesh: any;
|
||||
trackMesh: any;
|
||||
};
|
||||
|
||||
export function createVrPanelGroup(scene: any): any {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(VR_PANEL_POSITION.x, VR_PANEL_POSITION.y, VR_PANEL_POSITION.z);
|
||||
group.rotation.x = 0;
|
||||
scene.add(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function createPanelBackground(title: string): any {
|
||||
const panelMaterial = new THREE.MeshBasicMaterial({
|
||||
map: createPanelBackgroundTexture(title),
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false
|
||||
});
|
||||
const panelGeometry = new THREE.PlaneGeometry(WORLD_PANEL_WIDTH, WORLD_PANEL_HEIGHT);
|
||||
const panelMesh = new THREE.Mesh(panelGeometry, panelMaterial);
|
||||
panelMesh.name = 'vrControlPanelBackground';
|
||||
panelMesh.renderOrder = 0;
|
||||
|
||||
return panelMesh;
|
||||
}
|
||||
|
||||
export function createSeekBarMeshes(): SeekBarMeshes {
|
||||
const trackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
|
||||
const trackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
|
||||
const trackMesh = new THREE.Mesh(trackGeometry, trackMaterial);
|
||||
trackMesh.name = 'seekBarTrackVisual';
|
||||
trackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||
trackMesh.position.z = 0.01;
|
||||
trackMesh.renderOrder = 1;
|
||||
|
||||
const progressMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 });
|
||||
const progressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT);
|
||||
const progressMesh = new THREE.Mesh(progressGeometry, progressMaterial);
|
||||
progressMesh.name = 'seekBarProgressVisual';
|
||||
progressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR;
|
||||
progressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
|
||||
progressMesh.position.z = 0.015;
|
||||
progressMesh.scale.x = 0.001;
|
||||
progressMesh.renderOrder = 2;
|
||||
|
||||
const hitAreaGeometry = new THREE.PlaneGeometry(
|
||||
WORLD_SEEK_BAR_WIDTH,
|
||||
WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
|
||||
);
|
||||
const hitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
|
||||
const hitAreaMesh = new THREE.Mesh(hitAreaGeometry, hitAreaMaterial);
|
||||
hitAreaMesh.name = 'seekBarHitArea';
|
||||
hitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||
hitAreaMesh.position.z = 0.012;
|
||||
hitAreaMesh.renderOrder = 2;
|
||||
|
||||
return {
|
||||
hitAreaMesh,
|
||||
progressMesh,
|
||||
trackMesh
|
||||
};
|
||||
}
|
||||
|
||||
export function createButtonMesh({ centerX, centerY, name, size, texture }: ButtonMeshOptions): any {
|
||||
const buttonWorldSize = size * SCALE_FACTOR;
|
||||
const buttonMaterial = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false
|
||||
});
|
||||
const buttonGeometry = new THREE.PlaneGeometry(buttonWorldSize, buttonWorldSize);
|
||||
const buttonMesh = new THREE.Mesh(buttonGeometry, buttonMaterial);
|
||||
buttonMesh.name = name;
|
||||
buttonMesh.renderOrder = 3;
|
||||
|
||||
const worldButtonOffsetX = (centerX - FIGMA_PANEL_WIDTH_PX / 2) * SCALE_FACTOR;
|
||||
const worldButtonOffsetY = -(centerY - FIGMA_PANEL_HEIGHT_PX / 2) * SCALE_FACTOR;
|
||||
buttonMesh.position.set(worldButtonOffsetX, worldButtonOffsetY, 0.02);
|
||||
|
||||
return buttonMesh;
|
||||
}
|
||||
71
tests/input-mode.test.mjs
Normal file
71
tests/input-mode.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getPointerInputMode,
|
||||
rememberPointerInputMode,
|
||||
shouldUseHandPointer
|
||||
} 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 detects controller sources', () => {
|
||||
assert.equal(getPointerInputMode({ gamepad: {} }), 'controller');
|
||||
assert.equal(getPointerInputMode({ targetRayMode: 'tracked-pointer' }), 'controller');
|
||||
});
|
||||
|
||||
test('getPointerInputMode returns null for unknown or gaze-like sources', () => {
|
||||
assert.equal(getPointerInputMode(null), null);
|
||||
assert.equal(getPointerInputMode(undefined), null);
|
||||
assert.equal(getPointerInputMode({ profiles: ['generic-trigger'] }), null);
|
||||
assert.equal(getPointerInputMode({ targetRayMode: 'gaze' }), null);
|
||||
});
|
||||
|
||||
test('rememberPointerInputMode reads input sources from supported event shapes', () => {
|
||||
const fromNestedInputSource = {};
|
||||
rememberPointerInputMode(fromNestedInputSource, { data: { inputSource: { hand: {} } } }, 'controller');
|
||||
assert.equal(fromNestedInputSource.pointerInputMode, 'hand');
|
||||
|
||||
const fromDirectInputSource = {};
|
||||
rememberPointerInputMode(fromDirectInputSource, { inputSource: { gamepad: {} } }, 'hand');
|
||||
assert.equal(fromDirectInputSource.pointerInputMode, 'controller');
|
||||
|
||||
const fromDataSource = {};
|
||||
rememberPointerInputMode(fromDataSource, { data: { targetRayMode: 'tracked-pointer' } }, 'hand');
|
||||
assert.equal(fromDataSource.pointerInputMode, 'controller');
|
||||
});
|
||||
|
||||
test('rememberPointerInputMode keeps fallback mode when the event is ambiguous', () => {
|
||||
const inputSource = { pointerInputMode: 'hand' };
|
||||
|
||||
rememberPointerInputMode(inputSource, { data: { targetRayMode: 'gaze' } }, 'controller');
|
||||
|
||||
assert.equal(inputSource.pointerInputMode, 'controller');
|
||||
});
|
||||
|
||||
test('rememberPointerInputMode stores the input source on controller userData', () => {
|
||||
const inputSource = {
|
||||
controller: {
|
||||
userData: {
|
||||
existing: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rememberPointerInputMode(inputSource, { data: { inputSource: { hand: {} } } }, 'controller');
|
||||
|
||||
assert.equal(inputSource.pointerInputMode, 'hand');
|
||||
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