handible
Version:
Revolutionary hand tracking and gesture control for the web. Transform any webcam into a powerful 3D controller with MediaPipe and Three.js.
1,179 lines (979 loc) • 43.5 kB
JavaScript
import * as THREE from "three";
import { getSceneObjects } from "./sceneManager.js";
import { cleanupHandTracking, getHandTrackingData, setupHandTracking } from "./handTracking.js";
import { audioSystem } from "./audioSystem.js";
// Loading System for Scene Transitions
class SceneLoadingSystem {
constructor() {
this.overlay = null;
this.gaugeFill = null;
this.loadingText = null;
this.loadingSubtitle = null;
this.progress = 0;
this.isActive = false;
}
init() {
this.overlay = document.getElementById('sceneLoadingOverlay');
this.gaugeFill = document.getElementById('gaugeFill');
this.loadingText = document.getElementById('loadingText');
this.loadingSubtitle = document.getElementById('loadingSubtitle');
}
show(sceneName = '') {
if (!this.overlay) this.init();
this.isActive = true;
this.progress = 0;
// Update text based on scene
const sceneDisplayNames = {
'whiteboard': 'Demo Scene',
'table': 'Table Scene',
'simple': 'Simple Scene'
};
this.loadingText.textContent = `Loading ${sceneDisplayNames[sceneName] || 'Scene'}`;
this.loadingSubtitle.textContent = 'Initializing environment...';
// Show overlay with fade in
this.overlay.classList.add('active');
this.updateGauge(0);
}
hide() {
if (!this.overlay) return;
this.isActive = false;
this.overlay.classList.remove('active');
}
updateGauge(progress) {
if (!this.gaugeFill || !this.isActive) return;
this.progress = Math.max(0, Math.min(100, progress));
const rotation = (this.progress / 100) * 360;
this.gaugeFill.style.transform = `rotate(${-90 + rotation}deg)`;
}
setSubtitle(text) {
if (this.loadingSubtitle) {
this.loadingSubtitle.textContent = text;
}
}
// Simulate realistic loading progress with stages
async simulateProgress(totalDuration = 2000) {
if (!this.isActive) return;
const stages = [
{ progress: 15, text: 'Disposing old scene...' },
{ progress: 35, text: 'Loading scene assets...' },
{ progress: 60, text: 'Setting up lighting...' },
{ progress: 80, text: 'Initializing hand tracking...' },
{ progress: 95, text: 'Finalizing setup...' },
{ progress: 100, text: 'Ready!' }
];
for (const stage of stages) {
if (!this.isActive) break;
this.setSubtitle(stage.text);
await this.animateToProgress(stage.progress, totalDuration / stages.length);
// Small delay between stages for better UX
await new Promise(resolve => setTimeout(resolve, 100));
}
}
async animateToProgress(targetProgress, duration) {
const startProgress = this.progress;
const progressDiff = targetProgress - startProgress;
const startTime = Date.now();
return new Promise(resolve => {
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function for smooth animation
const easeProgress = 1 - Math.pow(1 - progress, 3);
const currentProgress = startProgress + (progressDiff * easeProgress);
this.updateGauge(currentProgress);
if (progress >= 1) {
resolve();
} else {
requestAnimationFrame(animate);
}
};
animate();
});
}
}
// Create global instance
const sceneLoader = new SceneLoadingSystem();
// Core state management
const raycaster = new THREE.Raycaster();
let grabbedObject = null;
let sceneCache = {};
/**
* Surface interaction system for handling cursor interactions with flat surfaces
*/
class SurfaceInteractionSystem {
constructor() {
this.surfaces = new Map();
this.hoveredButtons = new Map(); // Store hovered button per hand-surface combination
}
registerSurface(surface, config) {
const defaultConfig = {
width: 1,
height: 1,
cursorScaleFactor: 2.5,
buttonHoverThreshold: 0.4,
getNormal: () => new THREE.Vector3(0, 0, 1),
getButtonFilter: () => true,
handleCursorPosition: null
};
this.surfaces.set(surface.uuid, {
surface,
config: { ...defaultConfig, ...config }
});
}
getSurfaceConfig(surface) {
return this.surfaces.get(surface.uuid)?.config;
}
getHoveredButton(surface, handIndex) {
const key = `${surface.uuid}-${handIndex}`;
return this.hoveredButtons.get(key);
}
setHoveredButton(surface, handIndex, button) {
const key = `${surface.uuid}-${handIndex}`;
if (button) {
this.hoveredButtons.set(key, button);
} else {
this.hoveredButtons.delete(key);
}
}
updateCursorOnSurface(handIndex, handLandmarks, surface, cone) {
const config = this.getSurfaceConfig(surface);
if (!config) return false;
const cursorPoint = handLandmarks[3];
let worldPos;
// Check for chessboard first
const chessboard = surface.getObjectByProperty('isChessboard', true);
if (chessboard) {
worldPos = this.handleChessboardInteraction(handIndex, cursorPoint, surface, chessboard, cone);
} else if (config.handleCursorPosition) {
worldPos = config.handleCursorPosition(cursorPoint, surface, config);
} else {
const scaledX = cursorPoint.x * config.cursorScaleFactor;
const scaledY = cursorPoint.y * config.cursorScaleFactor;
const clampedX = Math.max(-config.width / 2, Math.min(config.width / 2, scaledX));
const clampedY = Math.max(-config.height / 2, Math.min(config.height / 2, scaledY));
const localPos = new THREE.Vector3(clampedX, clampedY, 0);
worldPos = localPos.applyMatrix4(surface.matrixWorld);
}
if (!worldPos) return false;
const normal = config.getNormal(surface);
const coneDirection = normal.clone().negate();
cone.position.copy(worldPos).add(normal.clone().multiplyScalar(CONE_HEIGHT));
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), coneDirection);
cone.visible = true;
// Update grabbed object position if any
if (grabbedObject && grabbedObject.userData.handIndex === handIndex) {
if (grabbedObject.userData.isKnob) {
this.handleKnobMovement(grabbedObject, cone, surface);
} else {
grabbedObject.position.copy(cone.position);
}
}
const buttons = surface.children.filter(config.getButtonFilter);
this.handleButtonInteractions(handIndex, cone, surface, buttons, config.buttonHoverThreshold);
return true;
}
handleButtonInteractions(handIndex, cone, surface, buttons, threshold) {
// Reset previously hovered button if it's no longer in the buttons list
const previousHovered = this.getHoveredButton(surface, handIndex);
if (previousHovered && !buttons.includes(previousHovered)) {
this.resetButtonState(previousHovered);
this.setHoveredButton(surface, handIndex, null);
}
// Find closest button within threshold
let closestButton = null;
let minDistance = Infinity;
for (const button of buttons) {
if (!button.userData.defaultPosition) {
button.userData.defaultPosition = button.position.clone();
}
const buttonWorldPos = new THREE.Vector3();
button.getWorldPosition(buttonWorldPos);
const distanceToButton = cone.position.distanceTo(buttonWorldPos);
if (distanceToButton < threshold) {
if (!isPinchingState[handIndex]) {
this.setButtonHoverState(button);
}
if (distanceToButton < minDistance) {
minDistance = distanceToButton;
closestButton = button;
}
} else if (button === previousHovered) {
this.resetButtonState(button);
}
}
// Update hover state
this.setHoveredButton(surface, handIndex, closestButton);
// Update cone position if hovering over a button
if (closestButton) {
const buttonWorldPos = new THREE.Vector3();
closestButton.getWorldPosition(buttonWorldPos);
const normal = this.getSurfaceConfig(surface)?.getNormal(surface) || new THREE.Vector3(0, 0, 1);
const buttonTop = buttonWorldPos.clone().add(normal.clone().multiplyScalar(0.05));
cone.position.copy(buttonTop).add(normal.clone().multiplyScalar(CONE_HEIGHT));
}
}
setButtonHoverState(button) {
if (!button) return;
button.scale.set(1.1, 1.1, 1.1);
button.material.color.set(button.userData.hoverColor || 0xffa500);
}
resetButtonState(button) {
if (!button) return;
button.scale.set(1, 1, 1);
button.material.color.set(button.userData.defaultColor || 0xffffff);
}
handleChessboardInteraction(handIndex, cursorPoint, surface, chessboard, cone) {
const scaledX = cursorPoint.x * CHESSBOARD_SCALE_FACTOR;
const scaledZ = -cursorPoint.y * CHESSBOARD_SCALE_FACTOR;
// Map to grid 0-7
const col = Math.floor((scaledX + 1) * (CHESSBOARD_SIZE / 2));
const row = Math.floor((scaledZ + 1) * (CHESSBOARD_SIZE / 2));
const clampedCol = Math.max(0, Math.min(CHESSBOARD_SIZE - 1, col));
const clampedRow = Math.max(0, Math.min(CHESSBOARD_SIZE - 1, row));
// Get square and its world position
const squareIndex = clampedRow * CHESSBOARD_SIZE + clampedCol;
const square = chessboard.children[squareIndex];
if (!square) return null;
// Reset previous square colors
chessboard.children.forEach(s => {
if (s !== square) {
// Store original color if not already stored
if (!s.userData.defaultColor) {
s.userData.defaultColor = s.material.color.clone();
}
s.material.color.set(s.userData.defaultColor);
s.userData.isHighlighted = false; // Reset highlight flag
}
});
// Store original color if not already stored
if (!square.userData.defaultColor) {
square.userData.defaultColor = square.material.color.clone();
}
// Highlight current square
const currentColor = square.material.color.clone();
if (!square.userData.isHighlighted) {
square.userData.isHighlighted = true;
square.userData.lastColor = currentColor;
square.material.color.set(HIGHLIGHT_COLOR);
}
// Get world position for cursor
const worldPos = new THREE.Vector3();
square.getWorldPosition(worldPos);
// Store last snapped square
lastSnappedSquarePerHand[handIndex] = { row: clampedRow, col: clampedCol, square };
// Update grabbable objects on the chessboard
const grabbableObjects = chessboard.children.filter(obj => obj.userData.isGrabbable);
grabbableObjects.forEach(obj => {
// Ensure we preserve the original color
if (!obj.userData.defaultColor) {
obj.userData.defaultColor = obj.material.color.clone();
}
if (!obj.userData.handIndex) {
obj.material.color.copy(obj.userData.defaultColor);
}
});
return worldPos;
}
handleKnobMovement(knob, cone, surface) {
if (!knob.userData.isKnob) return;
raycaster.set(cone.position, smoothedRayDirections[knob.userData.handIndex]);
const intersects = raycaster.intersectObject(surface);
if (intersects.length > 0) {
const intersectPoint = intersects[0].point;
const localPos = intersectPoint.clone().applyMatrix4(surface.matrixWorld.clone().invert());
localPos.x = Math.max(-1.5, Math.min(1.5, localPos.x));
localPos.y = -0.5;
localPos.z = 0.1;
knob.position.copy(localPos.applyMatrix4(surface.matrixWorld));
}
}
}
// Create single instance of surface system
const surfaceSystem = new SurfaceInteractionSystem();
// Export surface system for advanced usage
export { surfaceSystem as SurfaceInteractionSystem };
// Visual elements arrays
const rayVisualsPerHand = [];
const coneVisualsPerHand = [];
const smoothedRayOrigins = [];
const smoothedRayDirections = [];
// Gesture state
export const isPinchingState = Array(2).fill(false);
const EMA_ALPHA = 0.35; // Match hand tracking smoothing
const GRAB_SCALE_FACTOR = 3; // Scale grabbed object for visual feedback
const CLOSE_DISTANCE_THRESHOLD = 3.0; // Increased further to ensure interactions trigger
const SPHERE_RADIUS = 0.05; // Size of the created sphere
const CONE_RADIUS = 0.05; // Radius of the cone base
const CONE_HEIGHT = 0.1; // Height of the cone
const WHITEBOARD_WIDTH = 5; // Matches whiteboard width in threeSetup.js
const WHITEBOARD_HEIGHT = 3; // Matches whiteboard height in threeSetup.js
const TABLE_WIDTH = 3;
const TABLE_DEPTH = 2;
const TABLE_CURSOR_SCALE_FACTOR = 2.5; // Adjust as needed; higher = more coverage on table
const UI_PANEL_WIDTH = 1.0;
const UI_PANEL_HEIGHT = 0.6;
const CURSOR_SCALE_FACTOR = 2.5; // Adjust as needed to fit webcam FOV to whiteboard; higher = more coverage
const BUTTON_HOVER_THRESHOLD = 0.4; // Increased to account for 3D button size
const UIBUTTON_HOVER_THRESHOLD = 0.2; // Threshold for UI button hover (reduced for more precise interaction)
const UI_CURSOR_THRESHOLD = 1.5; // Distance threshold for right hand to UI panel
const UI_CURSOR_SENSITIVITY = 1.0; // Controls sensitivity of wrist rotation for UI cursor
const UI_CURSOR_ROTATION_OFFSET = -Math.PI / 6; // Rotation offset only for UI panel cursor
const KNOB_HOVER_THRESHOLD = 0.6; // Increased for easier knob grabbing
const CHESSBOARD_SIZE = 8; // 8x8 grid
const CHESSBOARD_SCALE_FACTOR = 4; // Adjust sensitivity to cover the chessboard; higher = more grid coverage
const HIGHLIGHT_COLOR = 0xffff00; // Yellow for snapped square
const ORANGE_COLOR = 0xffa500; // Orange for selected square
export const lastSnappedSquarePerHand = Array(2).fill(null); // {row, col, square} per hand
let onPinchStartCallbacks = []; // New: Array for user callbacks
let onPinchEndCallbacks = []; // New: Array for user callbacks
// New: Simple registration functions
export function registerOnPinchStart(callback) {
onPinchStartCallbacks.push(callback);
}
export function registerOnPinchEnd(callback) {
onPinchEndCallbacks.push(callback);
}
// Initialize ray and cone visuals for each hand
export function initGestureControl(scene, numHands) {
for (let i = 0; i < numHands; i++) {
// Cone visual (no initial rotation; we'll set quaternion dynamically)
const coneGeometry = new THREE.ConeGeometry(CONE_RADIUS, CONE_HEIGHT, 32);
const coneMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 }); // Red cone
const cone = new THREE.Mesh(coneGeometry, coneMaterial);
cone.visible = false;
scene.add(cone);
coneVisualsPerHand.push(cone);
smoothedRayOrigins.push(new THREE.Vector3());
smoothedRayDirections.push(new THREE.Vector3(0, 0, -1));
}
}
// returns boolean whether the hand is pinching
export function isPinching2D(rawLandmarks, videoWidth, videoHeight, thresholdPixels = 30) {
const thumbTip = rawLandmarks[4];
const indexTip = rawLandmarks[8];
const thumbX = thumbTip.x * videoWidth;
const thumbY = thumbTip.y * videoHeight;
const indexX = indexTip.x * videoWidth;
const indexY = indexTip.y * videoHeight;
const dx = thumbX - indexX;
const dy = thumbY - indexY;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance < thresholdPixels;
}
export function onPinchStart(handIndex, handedness, isUIActive) {
// Call user callbacks
onPinchStartCallbacks.forEach(cb => cb(handIndex, handedness));
console.log("pinch Start", handIndex, handedness, isUIActive);
// console.log(`Hand ${handIndex} pinch START, isUIActive: ${isUIActive}, handedness: ${handedness}`);
const { scene } = getSceneObjects();
const { smoothedLandmarksPerHand } = getHandTrackingData();
const handLandmarks = smoothedLandmarksPerHand[handIndex];
const cone = coneVisualsPerHand[handIndex];
// Skip if cursor disabled
if (isUIActive && handedness === 'Left') {
console.log(`Hand ${handIndex} skipped: left hand with UI active`);
return; // Disable for left hand when UI open
}
let buttons = [];
let normal = new THREE.Vector3();
let triggeredButton = false;
// right hand UI cursor logic
if (isUIActive && handedness === 'Right') {
const panel = scene.children.find(obj => obj.isMesh && obj.material?.color?.getHex() === 0xbffbff); // color matches UI panel
if (panel) {
const wrist = handLandmarks[0];
const distanceToPanel = wrist.distanceTo(panel.position);
if (distanceToPanel >= UI_CURSOR_THRESHOLD) {
return; // Skip if right hand not close to UI
}
buttons = panel.children.filter(obj => obj.userData.isUIButton);
normal = new THREE.Vector3(0, 0, 1).applyQuaternion(panel.quaternion).normalize();
} else {
// UI panel not found
return;
}
} else {
const wall = scene.children.find(obj => obj.userData.isWall);
if (wall) {
buttons = wall.children.filter(obj => obj.userData.isButton);
normal = new THREE.Vector3(0, 0, 1).applyQuaternion(wall.quaternion).normalize();
} else {
const table = scene.children.find(obj => obj.userData.isTable);
if (table) {
buttons = table.children.filter(obj => obj.userData.isButton); // If buttons on table
normal = new THREE.Vector3(0, 1, 0).applyQuaternion(table.quaternion).normalize(); // Up for table
} else {
return; // No interactive surface
}
}
}
// Check for button interactions based on cone proximity
buttons.forEach(button => {
const buttonWorldPos = new THREE.Vector3();
button.getWorldPosition(buttonWorldPos);
const distanceToButton = cone.position.distanceTo(buttonWorldPos);
// Use UIBUTTON_HOVER_THRESHOLD for UI buttons, BUTTON_HOVER_THRESHOLD for others
const threshold = button.userData.isUIButton ? UIBUTTON_HOVER_THRESHOLD : BUTTON_HOVER_THRESHOLD;
if (distanceToButton < threshold) {
// Play button click sound
audioSystem.createClickSound();
// Press effect: move button "down" along its local z (towards board/panel)
button.position.z -= 0.05; // Depress by half height
button.material.color.set(button.userData.activeColor);
console.log("Button pressed:", button);
// Trigger scene switch if this is a scene-switching button
const action = button.userData.action;
if (action && action.startsWith('switchTo')) {
// Extract scene name from action, e.g., "switchToSimpleScene" -> "simple"
let sceneName = action.replace('switchTo', '').replace('Scene', '').toLowerCase();
// Handle special case for the main scene
if (sceneName === 'three') {
sceneName = 'whiteboard';
}
audioSystem.createSuccessSound(); // Play success sound for scene switch
switchToScene(sceneName);
}
triggeredButton = true;
}
});
grabNearestObject(handIndex, handedness, isUIActive, triggeredButton); // Pass checks
// Reset buttons after press
if (triggeredButton) {
buttons.forEach(button => {
setTimeout(() => {
button.position.copy(button.userData.defaultPosition);
button.material.color.set(button.userData.defaultColor);
}, 200); // 200ms press duration
});
}
}
export function onPinchEnd(handIndex) {
// Call user callbacks
onPinchEndCallbacks.forEach(cb => cb(handIndex, handedness));
console.log(`Hand ${handIndex} pinch END`);
releaseObject(handIndex);
}
// New: Cache function (call in initGestureControl or on scene switch)
function cacheSceneObjects(scene) {
sceneCache.wall = scene.children.find(obj => obj.userData.isWall);
sceneCache.table = scene.children.find(obj => obj.userData.isTable);
sceneCache.chessboard = scene.getObjectByProperty('isChessboard', true);
sceneCache.uiPanel = scene.children.find(obj => obj.isMesh && obj.material?.color?.getHex() === 0xbffbff);
console.log('Scene objects cached'); // Debug
}
// Ray calculation functions
function calculateRayOrigin(handLandmarks) {
return handLandmarks[3].clone(); // Use thumb IP as ray origin
}
function calculateRayDirection(handLandmarks) {
const wrist = handLandmarks[0];
const middleTip = handLandmarks[12];
return new THREE.Vector3()
.subVectors(middleTip, wrist)
.normalize();
}
function smoothRayTransform(handIndex, rayOrigin, rayDirection) {
smoothedRayOrigins[handIndex].lerp(rayOrigin, EMA_ALPHA);
smoothedRayDirections[handIndex].lerp(rayDirection, EMA_ALPHA);
return {
origin: smoothedRayOrigins[handIndex],
direction: smoothedRayDirections[handIndex]
};
}
// UI Panel interaction
function shouldSkipUIInteraction(handedness, isUIActive, wrist, panel) {
if (!isUIActive) return false;
if (handedness === 'Left') return true;
// Right hand should always be able to interact with UI panel when UI is active
if (handedness === 'Right') return false;
return false;
}
function getUIPanelIntersection(wrist, rayDirection, panel) {
// Apply UI-specific rotation offset
const adjustedDirection = rayDirection.clone();
const rotationMatrix = new THREE.Matrix4().makeRotationX(UI_CURSOR_ROTATION_OFFSET);
adjustedDirection.applyMatrix4(rotationMatrix).normalize();
// Raycast to panel
raycaster.set(wrist, adjustedDirection);
const intersects = raycaster.intersectObject(panel);
return intersects.length > 0 ? intersects[0] : null;
}
function calculatePanelInteractionPoint(intersection, panel) {
if (!intersection) return null;
// Convert to local coordinates for clamping
const localPos = intersection.point.clone().applyMatrix4(panel.matrixWorld.clone().invert());
// Clamp to panel bounds
const clampedX = Math.max(-UI_PANEL_WIDTH / 2, Math.min(UI_PANEL_WIDTH / 2, localPos.x * UI_CURSOR_SENSITIVITY));
const clampedY = Math.max(-UI_PANEL_HEIGHT / 2, Math.min(UI_PANEL_HEIGHT / 2, localPos.y * UI_CURSOR_SENSITIVITY));
localPos.set(clampedX, clampedY, 0);
return localPos.applyMatrix4(panel.matrixWorld);
}
// Visual feedback
function updateConeVisual(cone, position, direction) {
if (!position) {
cone.visible = false;
return;
}
cone.visible = true;
cone.position.copy(position);
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction);
}
// UI Panel interaction handler
function handleUIPanelInteraction(handIndex, wristPosition, smoothedRay, panel, cone) {
const intersection = getUIPanelIntersection(wristPosition, smoothedRay.direction, panel);
if (!intersection) {
cone.visible = false;
return;
}
const interactionPoint = calculatePanelInteractionPoint(intersection, panel);
if (!interactionPoint) {
cone.visible = false;
return;
}
// Get panel normal for visual feedback
const panelNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(panel.quaternion).normalize();
updateConeVisual(cone, interactionPoint, panelNormal.clone().negate());
// Handle button interactions
handleUIButtonInteractions(handIndex, cone, panel);
}
// UI Button interaction handler
function handleUIButtonInteractions(handIndex, cone, panel) {
handleButtonInteractions(handIndex, cone, panel, panel.children, UIBUTTON_HOVER_THRESHOLD);
}
// Wall interaction handler
function handleWallInteraction(handIndex, handLandmarks, wall, cone) {
// Calculate cursor position on wall
const cursorPoint = handLandmarks[3];
const scaledX = cursorPoint.x * CURSOR_SCALE_FACTOR;
const scaledY = cursorPoint.y * CURSOR_SCALE_FACTOR;
// Clamp to whiteboard boundaries
const clampedX = Math.max(-WHITEBOARD_WIDTH / 2, Math.min(WHITEBOARD_WIDTH / 2, scaledX));
const clampedY = Math.max(-WHITEBOARD_HEIGHT / 2, Math.min(WHITEBOARD_HEIGHT / 2, scaledY));
// Convert to world space
const localPos = new THREE.Vector3(clampedX, clampedY, 0);
const worldPos = localPos.applyMatrix4(wall.matrixWorld);
// Calculate wall normal and update cone
const wallNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(wall.quaternion).normalize();
const coneDirection = wallNormal.clone().negate();
// Position cone at the correct height
cone.position.copy(worldPos).add(wallNormal.clone().multiplyScalar(CONE_HEIGHT));
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), coneDirection);
cone.visible = true;
// Handle button interactions
handleButtonInteractions(handIndex, cone, wall, wall.children, BUTTON_HOVER_THRESHOLD);
// Handle knob interactions
handleKnobInteractions(handIndex, cone, wall);
}
// Generic button interaction handler
function handleButtonInteractions(handIndex, cone, parent, children, threshold) {
const buttons = children.filter(obj => obj.userData.isButton || obj.userData.isUIButton);
let hoveredButton = null;
let minDistance = Infinity;
// Track which buttons this specific hand is hovering over
const handHoverKey = `hand-${handIndex}`;
buttons.forEach(button => {
// Store original button position if not already stored
if (!button.userData.defaultPosition) {
button.userData.defaultPosition = button.position.clone();
}
const buttonWorldPos = new THREE.Vector3();
button.getWorldPosition(buttonWorldPos);
const distanceToButton = cone.position.distanceTo(buttonWorldPos);
if (distanceToButton < threshold) {
if (!isPinchingState[handIndex]) {
// Only set hover state if this hand is the closest to this button
if (!button.userData.hoveredByHand || button.userData.hoveredByHand === handHoverKey) {
button.scale.set(1.1, 1.1, 1.1);
button.material.color.set(button.userData.hoverColor || 0xffa500);
button.userData.hoveredByHand = handHoverKey;
}
}
if (distanceToButton < minDistance) {
minDistance = distanceToButton;
hoveredButton = button;
}
} else {
// Only reset if this hand was the one hovering over this button
if (button.userData.hoveredByHand === handHoverKey) {
button.scale.set(1, 1, 1);
button.material.color.set(button.userData.defaultColor || 0xffffff);
delete button.userData.hoveredByHand;
}
}
});
// Handle button snapping
if (hoveredButton) {
const buttonWorldPos = new THREE.Vector3();
hoveredButton.getWorldPosition(buttonWorldPos);
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(parent.quaternion).normalize();
const buttonTop = buttonWorldPos.clone().add(normal.clone().multiplyScalar(0.05));
cone.position.copy(buttonTop).add(normal.clone().multiplyScalar(CONE_HEIGHT));
}
}
// Knob interaction handler
function handleKnobInteractions(handIndex, cone, wall) {
const knobs = wall.children.filter(obj => obj.userData.isKnob);
knobs.forEach(knob => {
const knobWorldPos = new THREE.Vector3();
knob.getWorldPosition(knobWorldPos);
const distanceToKnob = cone.position.distanceTo(knobWorldPos);
if (distanceToKnob < KNOB_HOVER_THRESHOLD) {
if (!isPinchingState[handIndex]) {
knob.scale.set(1.1, 1.1, 1.1);
knob.material.color.set(knob.userData.hoverColor || 0xffa500);
}
} else {
knob.scale.set(1, 1, 1);
knob.material.color.set(knob.userData.defaultColor || 0xffffff);
}
});
}
export function updateRaycast(handIndex, handedness, isUIActive) {
// Get scene objects and hand data
const { scene } = getSceneObjects();
const { smoothedLandmarksPerHand } = getHandTrackingData();
const handLandmarks = smoothedLandmarksPerHand[handIndex];
const cone = coneVisualsPerHand[handIndex];
// Early return if no valid landmarks
if (!handLandmarks || handLandmarks.length === 0) {
cone.visible = false;
return;
}
// Calculate and smooth ray properties
const rayOrigin = calculateRayOrigin(handLandmarks);
const rayDirection = calculateRayDirection(handLandmarks);
const smoothedRay = smoothRayTransform(handIndex, rayOrigin, rayDirection);
// Get interaction surfaces
const panel = scene.children.find(obj => obj.isMesh && obj.material?.color?.getHex() === 0xbffbff);
const wallObj = scene.children.find(obj => obj.userData.isWall);
const tableObj = scene.children.find(obj => obj.userData.isTable);
// If UI is not active, ensure the cone is not visible unless interacting with another surface
if (!isUIActive) {
cone.visible = false;
}
// Check if we should skip UI interaction
if (shouldSkipUIInteraction(handedness, isUIActive, handLandmarks[0], panel)) {
cone.visible = false;
return;
}
// Register surfaces if not already registered
if (panel && !surfaceSystem.getSurfaceConfig(panel)) {
surfaceSystem.registerSurface(panel, {
width: UI_PANEL_WIDTH,
height: UI_PANEL_HEIGHT,
cursorScaleFactor: UI_CURSOR_SENSITIVITY,
buttonHoverThreshold: UIBUTTON_HOVER_THRESHOLD,
getNormal: (surface) => new THREE.Vector3(0, 0, 1).applyQuaternion(surface.quaternion).normalize(),
getButtonFilter: (obj) => obj.userData.isUIButton
});
}
if (wallObj && !surfaceSystem.getSurfaceConfig(wallObj)) {
surfaceSystem.registerSurface(wallObj, {
width: WHITEBOARD_WIDTH,
height: WHITEBOARD_HEIGHT,
cursorScaleFactor: CURSOR_SCALE_FACTOR,
buttonHoverThreshold: BUTTON_HOVER_THRESHOLD,
getNormal: (surface) => new THREE.Vector3(0, 0, 1).applyQuaternion(surface.quaternion).normalize(),
getButtonFilter: (obj) => obj.userData.isButton || obj.userData.isKnob
});
}
if (tableObj && !surfaceSystem.getSurfaceConfig(tableObj)) {
surfaceSystem.registerSurface(tableObj, {
width: TABLE_WIDTH,
height: TABLE_DEPTH,
cursorScaleFactor: TABLE_CURSOR_SCALE_FACTOR,
buttonHoverThreshold: BUTTON_HOVER_THRESHOLD,
getNormal: (surface) => new THREE.Vector3(0, 1, 0).applyQuaternion(surface.quaternion).normalize(),
getButtonFilter: (obj) => obj.userData.isButton,
handleCursorPosition: (cursorPoint, surface, config) => {
const scaledX = cursorPoint.x * config.cursorScaleFactor;
const scaledZ = -cursorPoint.y * config.cursorScaleFactor;
const clampedX = Math.max(-config.width / 2, Math.min(config.width / 2, scaledX));
const clampedZ = Math.max(-config.height / 2, Math.min(config.height / 2, scaledZ));
const localPos = new THREE.Vector3(clampedX, 0.1, clampedZ);
return localPos.applyMatrix4(surface.matrixWorld);
}
});
}
// Handle surface interactions
if (isUIActive && handedness === 'Right' && panel) {
if (surfaceSystem.updateCursorOnSurface(handIndex, handLandmarks, panel, cone)) {
return;
}
}
if (wallObj) {
if (surfaceSystem.updateCursorOnSurface(handIndex, handLandmarks, wallObj, cone)) {
return;
}
}
if (tableObj) {
if (surfaceSystem.updateCursorOnSurface(handIndex, handLandmarks, tableObj, cone)) {
return;
}
}
// Fallback for scenes without specific surfaces (like simpleScene)
// This prevents the app from crashing if no interactive surfaces are found.
// It provides a basic raycasting interaction in 3D space.
if (!wallObj && !tableObj && !panel) {
// Standard 3D space raycasting
raycaster.set(smoothedRay.origin, smoothedRay.direction);
// Update cone position and orientation
const conePosition = smoothedRay.origin.clone().add(smoothedRay.direction.clone().multiplyScalar(1.5));
cone.position.copy(conePosition);
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), smoothedRay.direction);
cone.visible = true;
// Basic object intersection test (optional)
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
// You could add hover effects here for simple scenes
}
return;
}
}
// Add these functions at the top level
function handleChessboardInteraction(handIndex, handLandmarks, table, chessboard, cone) {
const cursorPoint = handLandmarks[3]; // Thumb IP
const scaledX = cursorPoint.x * CHESSBOARD_SCALE_FACTOR;
const scaledZ = -cursorPoint.y * CHESSBOARD_SCALE_FACTOR; // Negated for vertical direction
// Map to grid 0-7
const col = Math.floor((scaledX + 1) * (CHESSBOARD_SIZE / 2));
const row = Math.floor((scaledZ + 1) * (CHESSBOARD_SIZE / 2));
const clampedCol = Math.max(0, Math.min(CHESSBOARD_SIZE - 1, col));
const clampedRow = Math.max(0, Math.min(CHESSBOARD_SIZE - 1, row));
// Get square and its world position
const squareIndex = clampedRow * CHESSBOARD_SIZE + clampedCol;
const square = chessboard.children[squareIndex];
if (square) {
const worldPos = new THREE.Vector3();
square.getWorldPosition(worldPos);
// Highlight the square
square.material.color.set(HIGHLIGHT_COLOR);
// Set cone position to square center (above surface)
const normal = new THREE.Vector3(0, 1, 0).applyQuaternion(table.quaternion).normalize();
cone.position.copy(worldPos);
// Cone direction: point upwards
const coneDirection = normal;
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), coneDirection);
cone.visible = true;
// If grabbedObject, move it to snapped position
if (grabbedObject && grabbedObject.userData.handIndex === handIndex) {
console.log(grabbedObject.position.y, worldPos.y, cone.position.y);
}
}
// Store last snapped for pinch
lastSnappedSquarePerHand[handIndex] = { row: clampedRow, col: clampedCol, square };
}
function handleTableInteraction(handIndex, handLandmarks, table, cone) {
const cursorPoint = handLandmarks[3];
const scaledX = cursorPoint.x * TABLE_CURSOR_SCALE_FACTOR;
const scaledZ = -cursorPoint.y * TABLE_CURSOR_SCALE_FACTOR;
const clampedX = Math.max(-TABLE_WIDTH / 2, Math.min(TABLE_WIDTH / 2, scaledX));
const clampedZ = Math.max(-TABLE_DEPTH / 2, Math.min(TABLE_DEPTH / 2, scaledZ));
const localPos = new THREE.Vector3(clampedX, 0.1, clampedZ);
const worldPos = localPos.applyMatrix4(table.matrixWorld);
const normal = new THREE.Vector3(0, 1, 0).applyQuaternion(table.quaternion).normalize();
cone.position.copy(worldPos);
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), normal);
cone.visible = true;
if (grabbedObject && grabbedObject.userData.handIndex === handIndex) {
grabbedObject.position.copy(cone.position);
}
}
// If the grabbed object is the knob, constrain its movement horizontally along the slider track
function handleKnobMovement(handIndex, cone, wall) {
if (grabbedObject && grabbedObject.userData.isKnob && grabbedObject.userData.handIndex === handIndex) {
raycaster.set(cone.position, smoothedRayDirections[handIndex]); // Use cursor position for raycasting
const intersects = raycaster.intersectObject(wall);
if (intersects.length > 0) {
const intersectPoint = intersects[0].point;
const localPos = intersectPoint.clone().applyMatrix4(wall.matrixWorld.clone().invert());
localPos.x = Math.max(-1.5, Math.min(1.5, localPos.x)); // Clamp to slider track width (3 units wide)
localPos.y = -0.5; // Fixed y position of slider
localPos.z = 0.1; // Fixed z position (above board)
grabbedObject.position.copy(localPos.applyMatrix4(wall.matrixWorld));
}
}
}
// If an object is grabbed (non-knob), move it to the cursor position
function handleGrabbedObjectMovement(handIndex, cone) {
if (grabbedObject && !grabbedObject.userData.isKnob && grabbedObject.userData.handIndex === handIndex) {
grabbedObject.position.copy(cone.position);
}
}
export function grabNearestObject(handIndex, handedness, isUIActive, triggeredButton) {
if (isUIActive || triggeredButton) {
return; // Internal check: Skip if UI or button context
}
const { scene } = getSceneObjects();
const { smoothedLandmarksPerHand, landmarkVisualsPerHand, connectionVisualsPerHand } = getHandTrackingData();
const handLandmarks = smoothedLandmarksPerHand[handIndex];
if (!handLandmarks || handLandmarks.length === 0) {
return; // Guard: No landmarks
}
const cone = coneVisualsPerHand[handIndex];
if (!cone || !cone.visible) {
return; // No cone, skip
}
const conePosition = new THREE.Vector3();
cone.getWorldPosition(conePosition); // Get cone's world position (cursor tip)
// Find nearest grabbable object near cone
const grabbableObjects = [];
scene.traverse(obj => {
if (obj.userData?.isGrabbable &&
!obj.userData?.isWall &&
!landmarkVisualsPerHand.flat().includes(obj) &&
!connectionVisualsPerHand.flat().includes(obj)) {
// Calculate distance
const objPos = new THREE.Vector3();
obj.getWorldPosition(objPos);
grabbableObjects.push({ obj, distance: conePosition.distanceTo(objPos) });
}
});
// Sort by distance and get the closest
grabbableObjects.sort((a, b) => a.distance - b.distance);
const closestObject = grabbableObjects[0];
if (!closestObject || closestObject.distance > 0.5) { // Threshold, adjust as needed
return;
}
// Grab the nearest
grabbedObject = closestObject.obj;
// Ensure userData exists and store original state
if (!grabbedObject.userData) {
grabbedObject.userData = {};
}
// Store original parent and re-parent to scene for smooth world-space movement
grabbedObject.userData.originalParent = grabbedObject.parent;
const worldPosition = new THREE.Vector3();
grabbedObject.getWorldPosition(worldPosition);
scene.add(grabbedObject); // Temporarily move to world space
grabbedObject.position.copy(worldPosition); // Set position in world space
// Store original color if not already stored
if (grabbedObject.material && !grabbedObject.userData.defaultColor) {
grabbedObject.userData.defaultColor = grabbedObject.material.color.clone();
}
// Store original scale if needed
if (!grabbedObject.userData.originalScale) {
grabbedObject.userData.originalScale = grabbedObject.scale.clone();
}
// Update visual feedback
if (grabbedObject.material) {
grabbedObject.material.color.set(0xffa500); // Orange for grabbed state
}
// Associate with hand
grabbedObject.userData.handIndex = handIndex;
if (grabbedObject) {
console.log("grabbedObject");
}
// if (grabbedObject.userData.isKnob) {
// grabbedObject.material.color.set(grabbedObject.userData.activeColor);
// }
// console.log(`Hand ${handIndex} grabbed: ${grabbedObject.name || grabbedObject.id}`);
}
function releaseObject(handIndex) {
if (grabbedObject && grabbedObject.userData.handIndex === handIndex) {
// Restore original color if it was stored
if (grabbedObject.material) {
if (grabbedObject.userData.defaultColor) {
grabbedObject.material.color.copy(grabbedObject.userData.defaultColor);
} else {
// Default to red if no stored color
grabbedObject.material.color.set(0xff0000);
}
}
// Restore original scale if needed
if (grabbedObject.userData.originalScale) {
grabbedObject.scale.copy(grabbedObject.userData.originalScale);
}
// Re-parent the object to its original parent (e.g., the chessboard)
if (grabbedObject.userData.originalParent) {
const worldPosition = new THREE.Vector3();
grabbedObject.getWorldPosition(worldPosition);
grabbedObject.userData.originalParent.add(grabbedObject);
// Convert world position back to local position relative to the new parent
const localPosition = grabbedObject.userData.originalParent.worldToLocal(worldPosition);
grabbedObject.position.copy(localPosition);
delete grabbedObject.userData.originalParent;
}
// Clean up hand association
delete grabbedObject.userData.handIndex;
grabbedObject = null;
}
}
async function switchToScene(sceneName) {
// Show loading overlay
sceneLoader.show(sceneName);
// Start progress simulation
const progressPromise = sceneLoader.simulateProgress(2500);
let { scene, camera, renderer, controls } = getSceneObjects();
// Reset surface system state
surfaceSystem.surfaces.clear();
surfaceSystem.hoveredButtons.clear();
// Small delay to let loading animation start
await new Promise(resolve => setTimeout(resolve, 200));
// Dispose old scene resources
scene.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (Array.isArray(child.material)) child.material.forEach(mat => mat.dispose());
else child.material.dispose();
}
});
while (scene.children.length > 0) {
scene.remove(scene.children[0]);
}
renderer.dispose();
controls.dispose();
// Clear hand tracking state (visuals arrays, counters, etc.)
cleanupHandTracking(scene);
// Clear gesture control state (visuals and smoothed arrays)
coneVisualsPerHand.length = 0;
smoothedRayOrigins.length = 0;
smoothedRayDirections.length = 0;
isPinchingState.fill(false);
grabbedObject = null; // Clear any grabbed reference
let setupFunction;
switch (sceneName) {
case 'whiteboard':
const { setupThreeScene } = await import('./threeSetup.js');
setupFunction = setupThreeScene;
break;
case 'table':
const { setupTableScene } = await import('./tableSetup.js');
setupFunction = setupTableScene;
break;
case 'simple':
const { setupSimpleScene } = await import('./simpleScene.js');
setupFunction = setupSimpleScene;
break;
default:
console.error(`Unknown scene: ${sceneName}`);
sceneLoader.hide();
return;
}
setupFunction();
// Re-setup hand tracking and gestures with new scene
const { scene: newScene } = getSceneObjects();
await setupHandTracking(newScene);
initGestureControl(newScene, 2); // Re-add cones and reset gesture state
// Wait for progress animation to complete
await progressPromise;
// Small delay before hiding to show "Ready!" message
await new Promise(resolve => setTimeout(resolve, 300));
// Hide loading overlay
sceneLoader.hide();
}
export function getRayVisualsPerHand() {
return rayVisualsPerHand;
}
export function getConeVisualsPerHand() {
return coneVisualsPerHand;
}
// Export useful constants for configuration
export {
BUTTON_HOVER_THRESHOLD,
UIBUTTON_HOVER_THRESHOLD,
UI_CURSOR_THRESHOLD,
CHESSBOARD_SIZE,
HIGHLIGHT_COLOR,
sceneLoader // Export loading system for external use
};