diff --git a/src/App.tsx b/src/App.tsx
index 5879bcd..d9c766b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,13 +1,15 @@
import { Suspense } from "react";
import { Canvas } from "@react-three/fiber";
import { Crosshair } from "@/components/ui/Crosshair";
+import { HandTrackingOverlay } from "@/components/ui/HandTrackingOverlay";
+import { HandTrackingProvider } from "@/components/ui/HandTrackingProvider";
import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { DebugPerf } from "@/utils/debug/DebugPerf";
import { World } from "@/world/World";
function App(): React.JSX.Element {
return (
- <>
+
- >
+
+
);
}
diff --git a/src/components/3d/GrabbableObject.tsx b/src/components/3d/GrabbableObject.tsx
index 0499484..3d14a55 100644
--- a/src/components/3d/GrabbableObject.tsx
+++ b/src/components/3d/GrabbableObject.tsx
@@ -21,6 +21,7 @@ import {
GRAB_THROW_BOOST_STEP,
} from "@/data/grabConfig";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
+import { useHandTrackingSnapshot } from "@/hooks/useHandTrackingSnapshot";
import type { ColliderShape, Vector3Tuple } from "@/types/3d";
interface GrabbableObjectProps {
@@ -28,6 +29,7 @@ interface GrabbableObjectProps {
children: React.ReactNode;
colliders?: ColliderShape;
label?: string;
+ handControlled?: boolean;
}
// Shared params let one debug folder drive every instance.
@@ -42,14 +44,18 @@ const ZERO_ANGULAR_VELOCITY = { x: 0, y: 0, z: 0 };
const _holdTarget = new THREE.Vector3();
const _currentPos = new THREE.Vector3();
const _velocity = new THREE.Vector3();
+const _handNdc = new THREE.Vector3();
+const _handDirection = new THREE.Vector3();
export function GrabbableObject({
position,
children,
colliders = GRAB_DEFAULT_COLLIDERS,
label = GRAB_DEFAULT_LABEL,
+ handControlled = false,
}: GrabbableObjectProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
+ const { hands } = useHandTrackingSnapshot();
const rbRef = useRef(null);
const isHolding = useRef(false);
@@ -84,10 +90,25 @@ export function GrabbableObject({
});
useFrame(() => {
- if (!isHolding.current || !rbRef.current) return;
+ if (!rbRef.current) return;
- camera.getWorldDirection(_holdTarget);
- _holdTarget.multiplyScalar(params.holdDistance).add(camera.position);
+ const pinchingHand = handControlled
+ ? hands.find((hand) => hand.isPinch)
+ : undefined;
+
+ if (!isHolding.current && !pinchingHand) return;
+
+ if (pinchingHand) {
+ _handNdc.set((1 - pinchingHand.x) * 2 - 1, -pinchingHand.y * 2 + 1, 0.5);
+ _handNdc.unproject(camera);
+ _handDirection.subVectors(_handNdc, camera.position).normalize();
+ _holdTarget
+ .copy(camera.position)
+ .addScaledVector(_handDirection, params.holdDistance);
+ } else {
+ camera.getWorldDirection(_holdTarget);
+ _holdTarget.multiplyScalar(params.holdDistance).add(camera.position);
+ }
const t = rbRef.current.translation();
_currentPos.set(t.x, t.y, t.z);
diff --git a/src/components/ui/HandTrackingOverlay.tsx b/src/components/ui/HandTrackingOverlay.tsx
new file mode 100644
index 0000000..c8492fd
--- /dev/null
+++ b/src/components/ui/HandTrackingOverlay.tsx
@@ -0,0 +1,24 @@
+import { useHandTrackingSnapshot } from "@/hooks/useHandTrackingSnapshot";
+
+export function HandTrackingOverlay(): React.JSX.Element | null {
+ const { hands, status, serverStatus, error } = useHandTrackingSnapshot();
+
+ if (status === "idle") {
+ return null;
+ }
+
+ const pinching = hands.some((hand) => hand.isPinch);
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/HandTrackingProvider.tsx b/src/components/ui/HandTrackingProvider.tsx
new file mode 100644
index 0000000..842aa68
--- /dev/null
+++ b/src/components/ui/HandTrackingProvider.tsx
@@ -0,0 +1,26 @@
+import type { ReactNode } from "react";
+import { useSceneMode } from "@/hooks/debug/useSceneMode";
+import {
+ HAND_TRACKING_IDLE_SNAPSHOT,
+ HandTrackingContext,
+} from "@/hooks/useHandTrackingSnapshot";
+import { useRemoteHandTracking } from "@/hooks/useRemoteHandTracking";
+import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
+
+export function HandTrackingProvider({
+ children,
+}: {
+ children: ReactNode;
+}): React.JSX.Element {
+ const sceneMode = useSceneMode();
+ const enabled = isDebugEnabled() && sceneMode === "physics";
+ const snapshot = useRemoteHandTracking({ enabled });
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/data/handTrackingConfig.ts b/src/data/handTrackingConfig.ts
new file mode 100644
index 0000000..be44a47
--- /dev/null
+++ b/src/data/handTrackingConfig.ts
@@ -0,0 +1,20 @@
+export const HAND_TRACKING_LOCAL_WS_URL = "ws://localhost:8000/ws";
+export const HAND_TRACKING_PROD_WS_URL = "wss://handtracking.la-fabrik.fr/ws";
+
+export const HAND_TRACKING_FRAME_WIDTH = 320;
+export const HAND_TRACKING_FRAME_HEIGHT = 240;
+export const HAND_TRACKING_TARGET_FPS = 10;
+export const HAND_TRACKING_JPEG_QUALITY = 0.55;
+export const HAND_TRACKING_RESPONSE_TIMEOUT_MS = 1_500;
+
+export function getHandTrackingWsUrl(): string {
+ const configuredUrl = import.meta.env.VITE_HAND_TRACKING_WS_URL;
+
+ if (configuredUrl) {
+ return configuredUrl;
+ }
+
+ return import.meta.env.DEV
+ ? HAND_TRACKING_LOCAL_WS_URL
+ : HAND_TRACKING_PROD_WS_URL;
+}
diff --git a/src/hooks/useHandTrackingSnapshot.ts b/src/hooks/useHandTrackingSnapshot.ts
new file mode 100644
index 0000000..e220853
--- /dev/null
+++ b/src/hooks/useHandTrackingSnapshot.ts
@@ -0,0 +1,17 @@
+import { createContext, useContext } from "react";
+import type { HandTrackingSnapshot } from "@/types/handTracking";
+
+export const HAND_TRACKING_IDLE_SNAPSHOT: HandTrackingSnapshot = {
+ hands: [],
+ status: "idle",
+ serverStatus: null,
+ error: null,
+};
+
+export const HandTrackingContext = createContext(
+ HAND_TRACKING_IDLE_SNAPSHOT,
+);
+
+export function useHandTrackingSnapshot(): HandTrackingSnapshot {
+ return useContext(HandTrackingContext);
+}
diff --git a/src/hooks/useRemoteHandTracking.ts b/src/hooks/useRemoteHandTracking.ts
new file mode 100644
index 0000000..b91ad86
--- /dev/null
+++ b/src/hooks/useRemoteHandTracking.ts
@@ -0,0 +1,231 @@
+import { useEffect, useRef, useState } from "react";
+import {
+ HAND_TRACKING_FRAME_HEIGHT,
+ HAND_TRACKING_FRAME_WIDTH,
+ HAND_TRACKING_JPEG_QUALITY,
+ HAND_TRACKING_RESPONSE_TIMEOUT_MS,
+ HAND_TRACKING_TARGET_FPS,
+ getHandTrackingWsUrl,
+} from "@/data/handTrackingConfig";
+import type {
+ HandTrackingFrameMessage,
+ HandTrackingServerMessage,
+ HandTrackingSnapshot,
+} from "@/types/handTracking";
+
+interface UseRemoteHandTrackingOptions {
+ enabled: boolean;
+ websocketUrl?: string;
+}
+
+const INITIAL_SNAPSHOT: HandTrackingSnapshot = {
+ hands: [],
+ status: "idle",
+ serverStatus: null,
+ error: null,
+};
+
+function getBase64Payload(dataUrl: string): string {
+ return dataUrl.slice(dataUrl.indexOf(",") + 1);
+}
+
+export function useRemoteHandTracking({
+ enabled,
+ websocketUrl = getHandTrackingWsUrl(),
+}: UseRemoteHandTrackingOptions): HandTrackingSnapshot {
+ const [snapshot, setSnapshot] =
+ useState(INITIAL_SNAPSHOT);
+ const videoRef = useRef(null);
+ const canvasRef = useRef(null);
+ const streamRef = useRef(null);
+ const wsRef = useRef(null);
+ const sendIntervalRef = useRef(null);
+ const responseTimeoutRef = useRef(null);
+ const waitingForResponseRef = useRef(false);
+
+ useEffect(() => {
+ if (!enabled) {
+ return undefined;
+ }
+
+ let cancelled = false;
+
+ const clearResponseTimeout = (): void => {
+ if (responseTimeoutRef.current === null) return;
+ window.clearTimeout(responseTimeoutRef.current);
+ responseTimeoutRef.current = null;
+ };
+
+ const cleanup = (): void => {
+ if (sendIntervalRef.current !== null) {
+ window.clearInterval(sendIntervalRef.current);
+ sendIntervalRef.current = null;
+ }
+
+ clearResponseTimeout();
+ waitingForResponseRef.current = false;
+ wsRef.current?.close();
+ wsRef.current = null;
+
+ streamRef.current?.getTracks().forEach((track) => track.stop());
+ streamRef.current = null;
+ videoRef.current = null;
+ canvasRef.current = null;
+ };
+
+ const markResponseReceived = (): void => {
+ waitingForResponseRef.current = false;
+ clearResponseTimeout();
+ };
+
+ const sendFrame = (): void => {
+ const ws = wsRef.current;
+ const video = videoRef.current;
+ const canvas = canvasRef.current;
+ const context = canvas?.getContext("2d");
+
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ if (!video || !canvas || !context) return;
+ if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
+ if (waitingForResponseRef.current) return;
+
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
+ const dataUrl = canvas.toDataURL(
+ "image/jpeg",
+ HAND_TRACKING_JPEG_QUALITY,
+ );
+ const message: HandTrackingFrameMessage = {
+ type: "frame",
+ timestamp: Date.now(),
+ width: canvas.width,
+ height: canvas.height,
+ image: getBase64Payload(dataUrl),
+ };
+
+ waitingForResponseRef.current = true;
+ ws.send(JSON.stringify(message));
+ responseTimeoutRef.current = window.setTimeout(() => {
+ waitingForResponseRef.current = false;
+ responseTimeoutRef.current = null;
+ }, HAND_TRACKING_RESPONSE_TIMEOUT_MS);
+ };
+
+ const start = async (): Promise => {
+ await Promise.resolve();
+ if (cancelled) return;
+
+ setSnapshot({
+ hands: [],
+ status: "connecting",
+ serverStatus: null,
+ error: null,
+ });
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: {
+ width: HAND_TRACKING_FRAME_WIDTH,
+ height: HAND_TRACKING_FRAME_HEIGHT,
+ facingMode: "user",
+ },
+ audio: false,
+ });
+
+ if (cancelled) {
+ stream.getTracks().forEach((track) => track.stop());
+ return;
+ }
+
+ const video = document.createElement("video");
+ video.muted = true;
+ video.playsInline = true;
+ video.srcObject = stream;
+ await video.play();
+
+ const canvas = document.createElement("canvas");
+ canvas.width = HAND_TRACKING_FRAME_WIDTH;
+ canvas.height = HAND_TRACKING_FRAME_HEIGHT;
+
+ const ws = new WebSocket(websocketUrl);
+ ws.onopen = () => {
+ setSnapshot((current) => ({
+ ...current,
+ status: "connected",
+ error: null,
+ }));
+ };
+ ws.onmessage = (event) => {
+ markResponseReceived();
+ const data = JSON.parse(event.data) as HandTrackingServerMessage;
+
+ if (data.type === "hands") {
+ setSnapshot((current) => ({
+ ...current,
+ hands: data.hands,
+ serverStatus: null,
+ error: null,
+ }));
+ return;
+ }
+
+ if (data.type === "status") {
+ setSnapshot((current) => ({
+ ...current,
+ serverStatus: data.status,
+ }));
+ return;
+ }
+
+ setSnapshot((current) => ({
+ ...current,
+ hands: [],
+ status: "error",
+ error: data.message,
+ }));
+ };
+ ws.onerror = () => {
+ markResponseReceived();
+ setSnapshot((current) => ({
+ ...current,
+ status: "error",
+ error: "Hand tracking WebSocket error",
+ }));
+ };
+ ws.onclose = () => {
+ markResponseReceived();
+ setSnapshot((current) => ({
+ ...current,
+ status: cancelled ? "idle" : "disconnected",
+ }));
+ };
+
+ streamRef.current = stream;
+ videoRef.current = video;
+ canvasRef.current = canvas;
+ wsRef.current = ws;
+ sendIntervalRef.current = window.setInterval(
+ sendFrame,
+ 1_000 / HAND_TRACKING_TARGET_FPS,
+ );
+ } catch (error) {
+ if (cancelled) return;
+ setSnapshot({
+ hands: [],
+ status: "error",
+ serverStatus: null,
+ error:
+ error instanceof Error ? error.message : "Hand tracking failed",
+ });
+ }
+ };
+
+ void start();
+
+ return () => {
+ cancelled = true;
+ cleanup();
+ };
+ }, [enabled, websocketUrl]);
+
+ return snapshot;
+}
diff --git a/src/index.css b/src/index.css
index 8e0c2e2..f1c7e4d 100644
--- a/src/index.css
+++ b/src/index.css
@@ -79,3 +79,30 @@ canvas {
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.03em;
}
+
+.hand-tracking-overlay {
+ position: fixed;
+ right: 16px;
+ bottom: 16px;
+ z-index: 20;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 180px;
+ padding: 12px;
+ color: rgba(255, 255, 255, 0.9);
+ background: rgba(4, 7, 13, 0.78);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 8px;
+ font-size: 12px;
+ pointer-events: none;
+}
+
+.hand-tracking-overlay strong {
+ color: white;
+ font-size: 13px;
+}
+
+.hand-tracking-overlay__error {
+ color: #fca5a5;
+}
diff --git a/src/types/handTracking.ts b/src/types/handTracking.ts
new file mode 100644
index 0000000..f3971df
--- /dev/null
+++ b/src/types/handTracking.ts
@@ -0,0 +1,55 @@
+export interface HandTrackingHand {
+ x: number;
+ y: number;
+ z: number;
+ handedness: string;
+ isPinch: boolean;
+ pinchDistance: number;
+ score: number;
+}
+
+export type HandTrackingStatus =
+ | "idle"
+ | "connecting"
+ | "connected"
+ | "disconnected"
+ | "error";
+
+export interface HandTrackingSnapshot {
+ hands: HandTrackingHand[];
+ status: HandTrackingStatus;
+ serverStatus: string | null;
+ error: string | null;
+}
+
+export interface HandTrackingFrameMessage {
+ type: "frame";
+ timestamp: number;
+ width: number;
+ height: number;
+ image: string;
+}
+
+export interface HandTrackingHandsMessage {
+ type: "hands";
+ timestamp: number;
+ hands: HandTrackingHand[];
+}
+
+export interface HandTrackingStatusMessage {
+ type: "status";
+ timestamp: number;
+ status: string;
+}
+
+export interface HandTrackingErrorMessage {
+ type: "error";
+ timestamp: number;
+ hands: HandTrackingHand[];
+ message: string;
+}
+
+export type HandTrackingServerMessage =
+ | HandTrackingHandsMessage
+ | HandTrackingStatusMessage
+ | HandTrackingErrorMessage;
diff --git a/src/world/debug/TestScene.tsx b/src/world/debug/TestScene.tsx
index 1bf51cf..95afab0 100644
--- a/src/world/debug/TestScene.tsx
+++ b/src/world/debug/TestScene.tsx
@@ -54,6 +54,7 @@ export function TestScene({