diff --git a/src/components/ui/HandTrackingFallback.tsx b/src/components/ui/HandTrackingFallback.tsx
index b5d2d53..e874992 100644
--- a/src/components/ui/HandTrackingFallback.tsx
+++ b/src/components/ui/HandTrackingFallback.tsx
@@ -4,29 +4,70 @@ import {
type HandTrackingGloveHandedness,
} from "@/hooks/handTracking/useHandTrackingGloveStatus";
-// Simple schematic silhouettes used as a last-resort fallback when the
-// rigged glove model has failed to load. Both icons share the same
-// 48x48 viewBox and the same stroke/fill rules from the .css.
+// Hand silhouettes used as a last-resort fallback when the rigged glove
+// model has failed to load. Both icons share a 100x120 viewBox so finger
+// lengths and the thumb angle stay anatomically readable.
const OpenHandShape = (): React.JSX.Element => (
- <>
-
-
-
-
-
-
- >
+
);
const FistShape = (): React.JSX.Element => (
<>
-
-
-
-
-
-
+
+
+
+
+
+
>
);
@@ -66,7 +107,7 @@ export function HandTrackingFallback(): React.JSX.Element | null {
= [
+// MediaPipe indexes the 21 hand landmarks predictably:
+// 0 wrist, 1-4 thumb (base→tip), 5-8 index, 9-12 middle, 13-16 ring, 17-20 pinky.
+const FINGER_LANDMARKS: Array = [
+ [1, 2, 3, 4],
+ [5, 6, 7, 8],
+ [9, 10, 11, 12],
+ [13, 14, 15, 16],
+ [17, 18, 19, 20],
+];
+const SKELETON_BONES: Array<[number, number]> = [
[0, 1],
[1, 2],
[2, 3],
@@ -26,70 +34,187 @@ const HAND_CONNECTIONS: Array<[number, number]> = [
[0, 17],
];
-const LANDMARK_FILL = "#67e8f9"; // cyan-300, opaque interior
-const LANDMARK_STROKE = "#0c4a6e"; // sky-900, dark blue outline
-const LANDMARK_STROKE_FIST = "#1e3a8a"; // blue-900, thicker accent when fist
-const CONNECTION_STROKE = "#ffffff"; // white bones
-const INDEX_TIP_LANDMARK = 8;
+const HAND_FILL = "#bfdbfe"; // blue-200, light interior
+const HAND_OUTLINE_COLOR = "#1e3a8a"; // blue-900, crisp dark outline
+const HAND_OUTLINE_RADIUS = 2; // px
+// Shrink the rendered hand around its centroid. Grab/physics keep using raw
+// landmarks elsewhere, so the silhouette is just visually smaller.
+const RENDER_SCALE = 0.65;
+const FINGER_THICKNESS_FACTOR = 0.08; // fraction of (scaled) hand length
+const WRIST_HALF_WIDTH = 0.28;
+const SKELETON_STROKE = "rgba(30, 58, 138, 0.22)";
+const SKELETON_DOT_FILL = "rgba(30, 58, 138, 0.35)";
+const FILTER_ID = "hand-tracking-outline";
export function HandTrackingVisualizer(): React.JSX.Element | null {
const { hands, status } = useHandTrackingSnapshot();
- const showHandTrackingSvg = useDebugStore((debug) =>
- debug.getShowHandTrackingSvg(),
- );
- const gloves = useHandTrackingGloveStatus((state) => state.gloves);
- const hasLoadedGlove = Object.values(gloves).some(
- (gloveStatus) => gloveStatus === "loaded",
+ const showHandTrackingModel = useDebugStore((debug) =>
+ debug.getShowHandTrackingModel(),
);
- if (
- status === "idle" ||
- hands.length === 0 ||
- (hasLoadedGlove && !showHandTrackingSvg)
- ) {
+ if (status === "idle" || hands.length === 0 || showHandTrackingModel) {
return null;
}
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
return (
+
+ {/* Dilate the merged alpha of all child shapes by HAND_OUTLINE_RADIUS
+ and subtract the original to get a 1-ring outline. Lets the palm
+ polygon and the five finger tubes share a single crisp outline
+ with no internal seams where they overlap. */}
+
+
+
+
+
+
+
+
+
+
+
+
{hands.map((hand, handIndex) => {
const landmarks = hand.landmarks;
- if (landmarks.length === 0) return null;
+ if (landmarks.length < 21) return null;
- const landmarkStroke = hand.isFist
- ? LANDMARK_STROKE_FIST
- : LANDMARK_STROKE;
+ // Centroid of all 21 landmarks in pixel space (mirrored x).
+ let cx = 0;
+ let cy = 0;
+ for (const lm of landmarks) {
+ cx += (1 - lm.x) * viewportWidth;
+ cy += lm.y * viewportHeight;
+ }
+ cx /= landmarks.length;
+ cy /= landmarks.length;
+
+ // Render coordinates: shrink each landmark toward the centroid.
+ const px = (i: number): number => {
+ const lm = landmarks[i];
+ return lm
+ ? cx + ((1 - lm.x) * viewportWidth - cx) * RENDER_SCALE
+ : cx;
+ };
+ const py = (i: number): number => {
+ const lm = landmarks[i];
+ return lm ? cy + (lm.y * viewportHeight - cy) * RENDER_SCALE : cy;
+ };
+
+ const handLengthPx = Math.hypot(px(12) - px(0), py(12) - py(0));
+ const fingerThickness = Math.max(
+ 6,
+ handLengthPx * FINGER_THICKNESS_FACTOR,
+ );
+ const halfFingerThickness = fingerThickness / 2;
+ const dotRadius = Math.max(1.2, fingerThickness * 0.1);
+
+ // Perpendicular to the palm centerline (wrist → middle MCP), used to
+ // place two synthetic wrist corners on either side of landmark 0.
+ const cdx = px(9) - px(0);
+ const cdy = py(9) - py(0);
+ const clen = Math.hypot(cdx, cdy) || 1;
+ const perpX = -cdy / clen;
+ const perpY = cdx / clen;
+ const thumbSide =
+ (px(1) - px(0)) * perpX + (py(1) - py(0)) * perpY >= 0 ? 1 : -1;
+ const wristHalfWidth = handLengthPx * WRIST_HALF_WIDTH;
+ const wristThumbX = px(0) + perpX * wristHalfWidth * thumbSide;
+ const wristThumbY = py(0) + perpY * wristHalfWidth * thumbSide;
+ const wristPinkyX = px(0) - perpX * wristHalfWidth * thumbSide;
+ const wristPinkyY = py(0) - perpY * wristHalfWidth * thumbSide;
+
+ // Palm outline: straight L between adjacent MCPs along the top (no
+ // inter-finger dip — the morphology dilation rounds the MCP corners),
+ // rounded heel via two Q curves bowing out to the synthetic wrist
+ // corners.
+ const palmD = [
+ `M ${px(1)} ${py(1)}`,
+ `L ${px(5)} ${py(5)}`,
+ `L ${px(9)} ${py(9)}`,
+ `L ${px(13)} ${py(13)}`,
+ `L ${px(17)} ${py(17)}`,
+ `Q ${wristPinkyX} ${wristPinkyY}, ${px(0)} ${py(0)}`,
+ `Q ${wristThumbX} ${wristThumbY}, ${px(1)} ${py(1)}`,
+ "Z",
+ ].join(" ");
+
+ // Each finger path starts halfFingerThickness inside the palm (toward
+ // the next joint), so the rounded base cap sits hidden inside the palm
+ // fill instead of bulging below the MCP.
+ const fingerPathD = (joints: readonly number[]): string => {
+ const baseIdx = joints[0];
+ const nextIdx = joints[1];
+ if (baseIdx === undefined || nextIdx === undefined) return "";
+ const baseX = px(baseIdx);
+ const baseY = py(baseIdx);
+ const nextX = px(nextIdx);
+ const nextY = py(nextIdx);
+ const dx = nextX - baseX;
+ const dy = nextY - baseY;
+ const dlen = Math.hypot(dx, dy) || 1;
+ const sx = baseX + (dx / dlen) * halfFingerThickness;
+ const sy = baseY + (dy / dlen) * halfFingerThickness;
+ return joints
+ .map((idx, k) =>
+ k === 0 ? `M ${sx} ${sy}` : `L ${px(idx)} ${py(idx)}`,
+ )
+ .join(" ");
+ };
return (
- {HAND_CONNECTIONS.map(([from, to]) => {
- const fromPoint = landmarks[from];
- const toPoint = landmarks[to];
- if (!fromPoint || !toPoint) return null;
-
- return (
-
+
+ {FINGER_LANDMARKS.map((joints, fingerIndex) => (
+
- );
- })}
+ ))}
+
- {landmarks.map((landmark, landmarkIndex) => (
+ {SKELETON_BONES.map(([from, to]) => (
+
+ ))}
+ {landmarks.map((_, landmarkIndex) => (
))}
diff --git a/src/index.css b/src/index.css
index 5a347ec..b797892 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1799,7 +1799,8 @@ canvas {
width: 100vw;
height: 100vh;
pointer-events: none;
- filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.55));
+ opacity: 0.8;
+ filter: drop-shadow(0 0 4px rgba(96, 165, 250, 0.3));
}
.hand-tracking-fallback {
@@ -1813,12 +1814,13 @@ canvas {
.hand-tracking-fallback__icon {
position: absolute;
- width: 96px;
+ width: 80px;
height: 96px;
fill: #67e8f9;
stroke: #0c4a6e;
- stroke-width: 2;
+ stroke-width: 3;
stroke-linejoin: round;
+ stroke-linecap: round;
filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.55));
}