@map-gesture-controls/core 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ import { COLORS, LANDMARKS } from './constants.js';
2
+ // MediaPipe hand connection pairs (landmark index pairs)
3
+ const HAND_CONNECTIONS = [
4
+ [0, 1], [1, 2], [2, 3], [3, 4], // thumb
5
+ [0, 5], [5, 6], [6, 7], [7, 8], // index
6
+ [0, 9], [9, 10], [10, 11], [11, 12], // middle
7
+ [0, 13], [13, 14], [14, 15], [15, 16], // ring
8
+ [0, 17], [17, 18], [18, 19], [19, 20], // pinky
9
+ [5, 9], [9, 13], [13, 17], // palm cross
10
+ ];
11
+ const FINGERTIP_LANDMARKS = [
12
+ LANDMARKS.THUMB_TIP,
13
+ LANDMARKS.INDEX_TIP,
14
+ LANDMARKS.MIDDLE_TIP,
15
+ LANDMARKS.RING_TIP,
16
+ LANDMARKS.PINKY_TIP,
17
+ ];
18
+ /**
19
+ * WebcamOverlay
20
+ *
21
+ * Manages a DOM container with:
22
+ * - <video> element (webcam feed)
23
+ * - <canvas> for landmark drawing
24
+ * - mode badge
25
+ *
26
+ * Supports 'corner', 'full', and 'hidden' modes.
27
+ */
28
+ export class WebcamOverlay {
29
+ constructor(config) {
30
+ Object.defineProperty(this, "container", {
31
+ enumerable: true,
32
+ configurable: true,
33
+ writable: true,
34
+ value: void 0
35
+ });
36
+ Object.defineProperty(this, "canvas", {
37
+ enumerable: true,
38
+ configurable: true,
39
+ writable: true,
40
+ value: void 0
41
+ });
42
+ Object.defineProperty(this, "ctx", {
43
+ enumerable: true,
44
+ configurable: true,
45
+ writable: true,
46
+ value: void 0
47
+ });
48
+ Object.defineProperty(this, "badge", {
49
+ enumerable: true,
50
+ configurable: true,
51
+ writable: true,
52
+ value: void 0
53
+ });
54
+ Object.defineProperty(this, "config", {
55
+ enumerable: true,
56
+ configurable: true,
57
+ writable: true,
58
+ value: void 0
59
+ });
60
+ this.config = config;
61
+ this.container = document.createElement('div');
62
+ this.container.className = 'ol-gesture-overlay';
63
+ this.applyContainerStyles();
64
+ this.canvas = document.createElement('canvas');
65
+ this.canvas.className = 'ol-gesture-canvas';
66
+ this.canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;';
67
+ this.badge = document.createElement('div');
68
+ this.badge.className = 'ol-gesture-badge ol-gesture-badge--idle';
69
+ this.badge.textContent = 'Idle';
70
+ this.container.appendChild(this.canvas);
71
+ this.container.appendChild(this.badge);
72
+ const ctx = this.canvas.getContext('2d');
73
+ if (!ctx)
74
+ throw new Error('Cannot get 2D canvas context');
75
+ this.ctx = ctx;
76
+ }
77
+ /** Attach video element produced by GestureController */
78
+ attachVideo(video) {
79
+ video.className = 'ol-gesture-video';
80
+ video.style.cssText =
81
+ 'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;transform:scaleX(-1);';
82
+ this.container.insertBefore(video, this.canvas);
83
+ }
84
+ /** Mount the overlay into the given parent (usually document.body or map container) */
85
+ mount(parent) {
86
+ parent.appendChild(this.container);
87
+ }
88
+ unmount() {
89
+ this.container.parentElement?.removeChild(this.container);
90
+ }
91
+ /** Called each frame with the latest gesture frame and mode. */
92
+ render(frame, mode) {
93
+ this.updateBadge(mode);
94
+ const w = this.config.width;
95
+ const h = this.config.height;
96
+ this.canvas.width = w;
97
+ this.canvas.height = h;
98
+ this.ctx.clearRect(0, 0, w, h);
99
+ if (frame === null)
100
+ return;
101
+ for (const hand of frame.hands) {
102
+ this.drawSkeleton(hand.landmarks, mode, hand.gesture === 'fist');
103
+ }
104
+ }
105
+ drawSkeleton(landmarks, mode, isActionHand) {
106
+ const { ctx } = this;
107
+ const w = this.config.width;
108
+ const h = this.config.height;
109
+ // Mirror x because video is mirrored
110
+ const px = (lm) => (1 - lm.x) * w;
111
+ const py = (lm) => lm.y * h;
112
+ // Draw connections
113
+ ctx.strokeStyle = COLORS.connection;
114
+ ctx.lineWidth = 1.5;
115
+ for (const [a, b] of HAND_CONNECTIONS) {
116
+ if (!landmarks[a] || !landmarks[b])
117
+ continue;
118
+ ctx.beginPath();
119
+ ctx.moveTo(px(landmarks[a]), py(landmarks[a]));
120
+ ctx.lineTo(px(landmarks[b]), py(landmarks[b]));
121
+ ctx.stroke();
122
+ }
123
+ // Draw landmark dots
124
+ for (let i = 0; i < landmarks.length; i++) {
125
+ const lm = landmarks[i];
126
+ const isTip = FINGERTIP_LANDMARKS.includes(i);
127
+ const color = mode !== 'idle' && isTip
128
+ ? COLORS.fingertipGlow
129
+ : COLORS.landmark;
130
+ ctx.beginPath();
131
+ ctx.arc(px(lm), py(lm), isTip ? 5 : 3, 0, Math.PI * 2);
132
+ ctx.fillStyle = color;
133
+ ctx.fill();
134
+ // Glow effect on fingertips when active
135
+ if (mode !== 'idle' && isTip) {
136
+ ctx.shadowBlur = isActionHand ? 12 : 6;
137
+ ctx.shadowColor = COLORS.fingertipGlow;
138
+ ctx.fill();
139
+ ctx.shadowBlur = 0;
140
+ }
141
+ }
142
+ }
143
+ updateBadge(mode) {
144
+ const labels = {
145
+ idle: 'Idle',
146
+ panning: 'Pan',
147
+ zooming: 'Zoom',
148
+ };
149
+ this.badge.textContent = labels[mode];
150
+ this.badge.className = `ol-gesture-badge ol-gesture-badge--${mode}`;
151
+ }
152
+ applyContainerStyles() {
153
+ const { mode, position, width, height, opacity } = this.config;
154
+ this.container.style.cssText = '';
155
+ this.container.style.position = 'fixed';
156
+ this.container.style.zIndex = '9999';
157
+ this.container.style.overflow = 'hidden';
158
+ this.container.style.borderRadius = '8px';
159
+ this.container.style.opacity = String(opacity);
160
+ this.container.style.display = mode === 'hidden' ? 'none' : 'block';
161
+ if (mode === 'corner') {
162
+ this.container.style.width = `${width}px`;
163
+ this.container.style.height = `${height}px`;
164
+ const margin = '16px';
165
+ if (position === 'bottom-right') {
166
+ this.container.style.bottom = margin;
167
+ this.container.style.right = margin;
168
+ }
169
+ else if (position === 'bottom-left') {
170
+ this.container.style.bottom = margin;
171
+ this.container.style.left = margin;
172
+ }
173
+ else if (position === 'top-right') {
174
+ this.container.style.top = margin;
175
+ this.container.style.right = margin;
176
+ }
177
+ else {
178
+ this.container.style.top = margin;
179
+ this.container.style.left = margin;
180
+ }
181
+ }
182
+ else if (mode === 'full') {
183
+ this.container.style.top = '0';
184
+ this.container.style.left = '0';
185
+ this.container.style.width = '100vw';
186
+ this.container.style.height = '100vh';
187
+ this.container.style.borderRadius = '0';
188
+ }
189
+ }
190
+ updateConfig(config) {
191
+ Object.assign(this.config, config);
192
+ this.applyContainerStyles();
193
+ }
194
+ }
@@ -0,0 +1,26 @@
1
+ import type { WebcamConfig, TuningConfig } from './types.js';
2
+ export declare const DEFAULT_WEBCAM_CONFIG: WebcamConfig;
3
+ export declare const DEFAULT_TUNING_CONFIG: TuningConfig;
4
+ export declare const LANDMARKS: {
5
+ readonly WRIST: 0;
6
+ readonly THUMB_TIP: 4;
7
+ readonly INDEX_TIP: 8;
8
+ readonly INDEX_MCP: 5;
9
+ readonly MIDDLE_TIP: 12;
10
+ readonly MIDDLE_MCP: 9;
11
+ readonly RING_TIP: 16;
12
+ readonly RING_MCP: 13;
13
+ readonly PINKY_TIP: 20;
14
+ readonly PINKY_MCP: 17;
15
+ };
16
+ export declare const FINGERTIP_INDICES: readonly [8, 12, 16, 20];
17
+ export declare const FINGER_BASE_INDICES: readonly [5, 9, 13, 17];
18
+ export declare const COLORS: {
19
+ readonly idle: "#888888";
20
+ readonly panning: "#00ccff";
21
+ readonly zooming: "#00ffcc";
22
+ readonly landmark: "rgba(255,255,255,0.6)";
23
+ readonly connection: "rgba(255,255,255,0.3)";
24
+ readonly fingertipGlow: "#4488ff";
25
+ };
26
+ export declare const MEDIAPIPE_WASM_URL = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm";
@@ -0,0 +1,54 @@
1
+ export const DEFAULT_WEBCAM_CONFIG = {
2
+ enabled: true,
3
+ mode: 'corner',
4
+ opacity: 0.85,
5
+ position: 'bottom-right',
6
+ width: 320,
7
+ height: 240,
8
+ };
9
+ export const DEFAULT_TUNING_CONFIG = {
10
+ actionDwellMs: 80,
11
+ releaseGraceMs: 150,
12
+ panDeadzonePx: 10,
13
+ zoomDeadzoneRatio: 0.005,
14
+ smoothingAlpha: 0.5,
15
+ minDetectionConfidence: 0.65,
16
+ minTrackingConfidence: 0.65,
17
+ minPresenceConfidence: 0.60,
18
+ };
19
+ // MediaPipe landmark indices
20
+ export const LANDMARKS = {
21
+ WRIST: 0,
22
+ THUMB_TIP: 4,
23
+ INDEX_TIP: 8,
24
+ INDEX_MCP: 5,
25
+ MIDDLE_TIP: 12,
26
+ MIDDLE_MCP: 9,
27
+ RING_TIP: 16,
28
+ RING_MCP: 13,
29
+ PINKY_TIP: 20,
30
+ PINKY_MCP: 17,
31
+ };
32
+ // Fingertip indices for open-palm detection
33
+ export const FINGERTIP_INDICES = [
34
+ LANDMARKS.INDEX_TIP,
35
+ LANDMARKS.MIDDLE_TIP,
36
+ LANDMARKS.RING_TIP,
37
+ LANDMARKS.PINKY_TIP,
38
+ ];
39
+ export const FINGER_BASE_INDICES = [
40
+ LANDMARKS.INDEX_MCP,
41
+ LANDMARKS.MIDDLE_MCP,
42
+ LANDMARKS.RING_MCP,
43
+ LANDMARKS.PINKY_MCP,
44
+ ];
45
+ // Visual colours
46
+ export const COLORS = {
47
+ idle: '#888888',
48
+ panning: '#00ccff',
49
+ zooming: '#00ffcc',
50
+ landmark: 'rgba(255,255,255,0.6)',
51
+ connection: 'rgba(255,255,255,0.3)',
52
+ fingertipGlow: '#4488ff',
53
+ };
54
+ export const MEDIAPIPE_WASM_URL = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm';
@@ -0,0 +1,22 @@
1
+ import type { HandLandmark, GestureType } from './types.js';
2
+ /**
3
+ * Returns the apparent 2D size of the hand: wrist-to-middle-MCP distance
4
+ * in normalised screen space (0–1). Used as a depth proxy — hand grows
5
+ * larger as it moves toward the camera.
6
+ */
7
+ export declare function getHandSize(landmarks: HandLandmark[]): number;
8
+ /**
9
+ * Returns the Euclidean distance between the index fingertips of two hands
10
+ * in normalised screen space (0–1). Used as the two-hand zoom metric —
11
+ * hands moving apart = positive delta = zoom in.
12
+ */
13
+ export declare function getTwoHandDistance(landmarksA: HandLandmark[], landmarksB: HandLandmark[]): number;
14
+ /**
15
+ * Classify the gesture for a single hand from its landmarks.
16
+ *
17
+ * Priority: fist > openPalm > none
18
+ *
19
+ * Fist: most fingers curled
20
+ * Open palm: all fingers extended
21
+ */
22
+ export declare function classifyGesture(landmarks: HandLandmark[]): GestureType;
@@ -0,0 +1,110 @@
1
+ import { LANDMARKS, FINGERTIP_INDICES, FINGER_BASE_INDICES } from './constants.js';
2
+ /**
3
+ * Euclidean distance between two landmarks (ignoring Z).
4
+ */
5
+ function dist(a, b) {
6
+ const dx = a.x - b.x;
7
+ const dy = a.y - b.y;
8
+ return Math.sqrt(dx * dx + dy * dy);
9
+ }
10
+ /**
11
+ * Returns true if all four fingers (index–pinky) are extended AND spread apart.
12
+ *
13
+ * "Extended": tip is farther from the wrist than its MCP joint (allows slightly
14
+ * bent fingers — the 0.9 factor gives ~10% slack).
15
+ *
16
+ * "Spread": the minimum distance between any two adjacent fingertips (index–middle,
17
+ * middle–ring, ring–pinky) must be at least spreadThreshold × handSize. Using the
18
+ * minimum rather than the mean catches duck/pinch shapes: in a duck the adjacent tips
19
+ * touch (~2–8% of handSize) even though the outer pair (index↔pinky) stays far apart.
20
+ * A natural open hand keeps even adjacent tips ~20–35% of handSize apart.
21
+ * Threshold of 0.18 separates the two with comfortable margin.
22
+ */
23
+ function areAllFingersExtended(landmarks) {
24
+ const wrist = landmarks[LANDMARKS.WRIST];
25
+ for (let i = 0; i < FINGERTIP_INDICES.length; i++) {
26
+ const tip = landmarks[FINGERTIP_INDICES[i]];
27
+ const mcp = landmarks[FINGER_BASE_INDICES[i]];
28
+ if (dist(tip, wrist) < dist(mcp, wrist) * 0.9) {
29
+ return false;
30
+ }
31
+ }
32
+ // Spread check: minimum pairwise distance between adjacent fingertips vs hand size.
33
+ // Using the minimum (not mean) catches duck/pinch shapes where adjacent fingertips
34
+ // touch — the mean can stay high because the outer pair (index↔pinky) is still far
35
+ // apart, but the minimum collapses to near zero when any two tips cluster together.
36
+ // A natural open hand has even adjacent tips ~20–35% of handSize apart.
37
+ // A duck has adjacent tips at ~2–8% of handSize. Threshold of 0.18 separates them.
38
+ const handSize = getHandSize(landmarks);
39
+ if (handSize === 0)
40
+ return false;
41
+ const tips = FINGERTIP_INDICES.map((idx) => landmarks[idx]);
42
+ let minDist = Infinity;
43
+ for (let i = 0; i < tips.length - 1; i++) {
44
+ const d = dist(tips[i], tips[i + 1]); // only adjacent pairs (index-middle, middle-ring, ring-pinky)
45
+ if (d < minDist)
46
+ minDist = d;
47
+ }
48
+ const spreadThreshold = 0.18; // fraction of handSize; tune up/down as needed
49
+ return minDist >= handSize * spreadThreshold;
50
+ }
51
+ /**
52
+ * Returns true if all four fingers (index–pinky) are curled.
53
+ * A finger is "curled" when its tip is closer to the wrist than its MCP joint.
54
+ */
55
+ function areAllFingersCurled(landmarks) {
56
+ const wrist = landmarks[LANDMARKS.WRIST];
57
+ let curledCount = 0;
58
+ for (let i = 0; i < FINGERTIP_INDICES.length; i++) {
59
+ const tip = landmarks[FINGERTIP_INDICES[i]];
60
+ const mcp = landmarks[FINGER_BASE_INDICES[i]];
61
+ if (dist(tip, wrist) < dist(mcp, wrist) * 1.1) {
62
+ curledCount++;
63
+ }
64
+ }
65
+ // At least 3 of 4 fingers must be curled to count as fist
66
+ return curledCount >= 3;
67
+ }
68
+ /**
69
+ * Returns the apparent 2D size of the hand: wrist-to-middle-MCP distance
70
+ * in normalised screen space (0–1). Used as a depth proxy — hand grows
71
+ * larger as it moves toward the camera.
72
+ */
73
+ export function getHandSize(landmarks) {
74
+ const wrist = landmarks[LANDMARKS.WRIST];
75
+ const middleMcp = landmarks[LANDMARKS.MIDDLE_MCP];
76
+ if (!wrist || !middleMcp)
77
+ return 0;
78
+ return dist(wrist, middleMcp);
79
+ }
80
+ /**
81
+ * Returns the Euclidean distance between the index fingertips of two hands
82
+ * in normalised screen space (0–1). Used as the two-hand zoom metric —
83
+ * hands moving apart = positive delta = zoom in.
84
+ */
85
+ export function getTwoHandDistance(landmarksA, landmarksB) {
86
+ const tipA = landmarksA[LANDMARKS.INDEX_TIP];
87
+ const tipB = landmarksB[LANDMARKS.INDEX_TIP];
88
+ if (!tipA || !tipB)
89
+ return 0;
90
+ return dist(tipA, tipB);
91
+ }
92
+ /**
93
+ * Classify the gesture for a single hand from its landmarks.
94
+ *
95
+ * Priority: fist > openPalm > none
96
+ *
97
+ * Fist: most fingers curled
98
+ * Open palm: all fingers extended
99
+ */
100
+ export function classifyGesture(landmarks) {
101
+ if (landmarks.length < 21)
102
+ return 'none';
103
+ if (areAllFingersCurled(landmarks)) {
104
+ return 'fist';
105
+ }
106
+ if (areAllFingersExtended(landmarks)) {
107
+ return 'openPalm';
108
+ }
109
+ return 'none';
110
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { classifyGesture, getTwoHandDistance } from './gestureClassifier.js';
3
+ import { LANDMARKS } from './constants.js';
4
+ /**
5
+ * Build a flat array of 21 landmarks, all at (0.5, 0.5, 0).
6
+ * Individual landmarks can be overridden via the `overrides` map.
7
+ */
8
+ function makeLandmarks(overrides = {}) {
9
+ const lm = Array.from({ length: 21 }, () => ({ x: 0.5, y: 0.5, z: 0 }));
10
+ for (const [idx, vals] of Object.entries(overrides)) {
11
+ lm[Number(idx)] = { ...lm[Number(idx)], ...vals };
12
+ }
13
+ return lm;
14
+ }
15
+ /**
16
+ * Build an open-palm hand.
17
+ *
18
+ * Strategy: place wrist at origin, MCP joints at y=0.3, fingertips at y=0.1
19
+ * (tips are farther from the wrist than MCPs) and spread them horizontally
20
+ * so adjacent-tip distance >> 18% of hand size.
21
+ *
22
+ * Hand size = dist(wrist, middle-MCP) = dist({0,0},{0,0.3}) = 0.3
23
+ * Adjacent tip spread = 0.1 per finger → 0.1 > 0.3 * 0.18 = 0.054 ✓
24
+ */
25
+ function makeOpenPalmLandmarks() {
26
+ const lm = makeLandmarks();
27
+ // Wrist at origin
28
+ lm[LANDMARKS.WRIST] = { x: 0.5, y: 0.8, z: 0 };
29
+ // MCP joints
30
+ lm[LANDMARKS.INDEX_MCP] = { x: 0.3, y: 0.5, z: 0 };
31
+ lm[LANDMARKS.MIDDLE_MCP] = { x: 0.4, y: 0.5, z: 0 };
32
+ lm[LANDMARKS.RING_MCP] = { x: 0.5, y: 0.5, z: 0 };
33
+ lm[LANDMARKS.PINKY_MCP] = { x: 0.6, y: 0.5, z: 0 };
34
+ // Fingertips — extended (farther from wrist than MCPs) and spread
35
+ lm[LANDMARKS.INDEX_TIP] = { x: 0.2, y: 0.1, z: 0 };
36
+ lm[LANDMARKS.MIDDLE_TIP] = { x: 0.35, y: 0.1, z: 0 };
37
+ lm[LANDMARKS.RING_TIP] = { x: 0.5, y: 0.1, z: 0 };
38
+ lm[LANDMARKS.PINKY_TIP] = { x: 0.65, y: 0.1, z: 0 };
39
+ return lm;
40
+ }
41
+ /**
42
+ * Build a fist hand.
43
+ *
44
+ * Strategy: wrist at (0.5, 0.8), MCP joints at (0.5, 0.5),
45
+ * fingertips curled back to (0.5, 0.7) — closer to wrist than MCPs.
46
+ *
47
+ * dist(tip, wrist) ≈ 0.1 < dist(mcp, wrist) * 1.1 ≈ 0.33 ✓
48
+ */
49
+ function makeFistLandmarks() {
50
+ const lm = makeLandmarks();
51
+ lm[LANDMARKS.WRIST] = { x: 0.5, y: 0.8, z: 0 };
52
+ lm[LANDMARKS.INDEX_MCP] = { x: 0.4, y: 0.5, z: 0 };
53
+ lm[LANDMARKS.MIDDLE_MCP] = { x: 0.45, y: 0.5, z: 0 };
54
+ lm[LANDMARKS.RING_MCP] = { x: 0.5, y: 0.5, z: 0 };
55
+ lm[LANDMARKS.PINKY_MCP] = { x: 0.55, y: 0.5, z: 0 };
56
+ lm[LANDMARKS.INDEX_TIP] = { x: 0.4, y: 0.72, z: 0 };
57
+ lm[LANDMARKS.MIDDLE_TIP] = { x: 0.45, y: 0.72, z: 0 };
58
+ lm[LANDMARKS.RING_TIP] = { x: 0.5, y: 0.72, z: 0 };
59
+ lm[LANDMARKS.PINKY_TIP] = { x: 0.55, y: 0.72, z: 0 };
60
+ return lm;
61
+ }
62
+ // ─── classifyGesture ──────────────────────────────────────────────────────────
63
+ describe('classifyGesture', () => {
64
+ it('returns "none" when fewer than 21 landmarks are provided', () => {
65
+ expect(classifyGesture([])).toBe('none');
66
+ expect(classifyGesture(makeLandmarks().slice(0, 20))).toBe('none');
67
+ });
68
+ it('classifies an open palm as "openPalm"', () => {
69
+ expect(classifyGesture(makeOpenPalmLandmarks())).toBe('openPalm');
70
+ });
71
+ it('classifies a fist as "fist"', () => {
72
+ expect(classifyGesture(makeFistLandmarks())).toBe('fist');
73
+ });
74
+ it('returns "none" for an ambiguous / neutral hand', () => {
75
+ // All landmarks at the same point — fingers neither extended nor curled
76
+ expect(classifyGesture(makeLandmarks())).toBe('none');
77
+ });
78
+ it('prefers "fist" over "openPalm" when both criteria match', () => {
79
+ // The function checks fist first, so a fist should never become openPalm.
80
+ const fist = makeFistLandmarks();
81
+ expect(classifyGesture(fist)).toBe('fist');
82
+ });
83
+ });
84
+ // ─── getTwoHandDistance ───────────────────────────────────────────────────────
85
+ describe('getTwoHandDistance', () => {
86
+ it('returns the Euclidean distance between the two index fingertips', () => {
87
+ const handA = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.0, y: 0.0, z: 0 } });
88
+ const handB = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.3, y: 0.4, z: 0 } });
89
+ expect(getTwoHandDistance(handA, handB)).toBeCloseTo(0.5);
90
+ });
91
+ it('returns 0 when the index fingertips are at the same position', () => {
92
+ const hand = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.5, y: 0.5, z: 0 } });
93
+ expect(getTwoHandDistance(hand, hand)).toBe(0);
94
+ });
95
+ it('returns 0 when landmarks array is empty (missing tips)', () => {
96
+ expect(getTwoHandDistance([], [])).toBe(0);
97
+ });
98
+ });
@@ -0,0 +1,7 @@
1
+ export { GestureController } from './GestureController.js';
2
+ export { GestureStateMachine } from './GestureStateMachine.js';
3
+ export type { StateMachineOutput } from './GestureStateMachine.js';
4
+ export { WebcamOverlay } from './WebcamOverlay.js';
5
+ export { classifyGesture, getHandSize, getTwoHandDistance } from './gestureClassifier.js';
6
+ export type { GestureMode, GestureType, HandednessLabel, GestureFrame, DetectedHand, WebcamConfig, TuningConfig, SmoothedPoint, Point2D, HandLandmark, } from './types.js';
7
+ export { DEFAULT_WEBCAM_CONFIG, DEFAULT_TUNING_CONFIG, LANDMARKS, COLORS, } from './constants.js';