PalmLab Clay Lab
Shape a 3D clay sphere with two hands. The webcam feeds MediaPipe's hand landmarker in the browser, pinches become stretch and squash, and React Three Fiber deforms 1,617 vertices every frame. It opens in a demo mode that needs no camera, loads the vision model only when you ask for it, and ships with tests, lint and a Pages deploy.
The Clay Lab in demo mode



Two hands, three states
| Hand signal | Effect on the clay |
|---|---|
| Midpoint of both hand centres | Position (x, y, depth) |
| Distance between hands ÷ 0.34 | Scale, clamped to 0.45 – 2.8 |
| Circular mean of both wrist angles | Roll |
| One pinch (thumb tip to index tip < 0.085) | Stretch toward the pinch |
| Two pinches | Compress along the axis between them |
| No pinch | Idle: relax back to a sphere |
An earlier version had three visual modes, called Sculpt, DJ and Physics. They were cut so the app does one interaction well. The gesture classifier still recognises open palm, fist and point, but only pinch changes anything.
Privacy is built in. There is no server. The demo runs with no permission prompt, and the 153 kB vision bundle, the WASM runtime and the 7.8 MB model download only after you press Use camera.
Pure domain, one adapter, one renderer
GestureFrame, so everything downstream does not care which one is running. The domain modules import neither React nor the DOM, which is what makes them testable without a camera.From pinch to deformation
// gestures.ts:39-50 export function classifyGesture(landmarks, handedness = 'Right') { if (landmarks.length < 21) return 'neutral' const pinchDistance = distance(landmarks[4], landmarks[8]) if (pinchDistance < 0.085) return 'pinch' const extended = countExtendedFingers(landmarks, handedness) if (extended >= 4) return 'open-palm' if (extended === 0) return 'fist' ... }
One threshold, one frame. No hysteresis, no smoothing, not scaled to hand size.
// Stage.tsx:26-37 · stretch toward the pinch const direction = normalizeDirection( (point.x - controls.position.x) * 1.7, (controls.position.y - point.y) * 1.7) const projection = Math.max(0, x*direction.x + y*direction.y) const influence = Math.min(1, projection / 1.12) return { x: x + direction.x * influence * 0.58, y: y + direction.y * influence * 0.58, z: z * (1 + influence * 0.12) } // Stage.tsx:88 · the only smoothing in the pipeline const blend = Math.min(1, delta * (reducedMotion ? 24 : 9))
The y term is mirrored, and the result is applied inside a group that is already rotated by the roll.
point.y − position.y and rotating the direction by −roll before use, or deforming a child mesh that isn't rotated.Build and bundle
Verified locally on Node 24: npm ci, typecheck, lint and build are clean; 10 of 10 tests pass in 123 ms. Vite warns that the 1,091 kB main chunk is over 500 kB. CI runs typecheck, tests, lint and build on every push, and a Dependabot PR for a dev-only vitest advisory is open.