@map-gesture-controls/core 0.1.8 → 0.2.0
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.
- package/README.md +14 -5
- package/dist/GestureController.d.ts +2 -0
- package/dist/GestureController.js +16 -2
- package/dist/GestureStateMachine.d.ts +3 -0
- package/dist/GestureStateMachine.js +68 -11
- package/dist/WebcamOverlay.d.ts +6 -2
- package/dist/WebcamOverlay.js +40 -4
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +6 -0
- package/dist/gestureClassifier.d.ts +18 -3
- package/dist/gestureClassifier.js +73 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +162 -118
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/dist/GestureStateMachine.test.d.ts +0 -1
- package/dist/GestureStateMachine.test.js +0 -232
- package/dist/gestureClassifier.test.d.ts +0 -1
- package/dist/gestureClassifier.test.js +0 -98
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import { GestureStateMachine } from './GestureStateMachine.js';
|
|
3
|
-
import { LANDMARKS } from './constants.js';
|
|
4
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
5
|
-
const FAST_TUNING = {
|
|
6
|
-
actionDwellMs: 0, // dwell=0: transition fires on the SECOND frame (first sets timer)
|
|
7
|
-
releaseGraceMs: 0, // grace=0: idle on the SECOND frame after release
|
|
8
|
-
panDeadzonePx: 0,
|
|
9
|
-
zoomDeadzoneRatio: 0,
|
|
10
|
-
smoothingAlpha: 1, // no smoothing, raw values pass through
|
|
11
|
-
minDetectionConfidence: 0.65,
|
|
12
|
-
minTrackingConfidence: 0.65,
|
|
13
|
-
minPresenceConfidence: 0.60,
|
|
14
|
-
};
|
|
15
|
-
function makeLandmarks(overrides = {}) {
|
|
16
|
-
const lm = Array.from({ length: 21 }, () => ({ x: 0.5, y: 0.5, z: 0 }));
|
|
17
|
-
for (const [idx, vals] of Object.entries(overrides)) {
|
|
18
|
-
lm[Number(idx)] = { ...lm[Number(idx)], ...vals };
|
|
19
|
-
}
|
|
20
|
-
return lm;
|
|
21
|
-
}
|
|
22
|
-
function makeHand(gesture, landmarks = makeLandmarks()) {
|
|
23
|
-
return { handedness: 'Right', score: 1, landmarks, gesture };
|
|
24
|
-
}
|
|
25
|
-
function makeFrame(timestamp, leftHand, rightHand) {
|
|
26
|
-
const hands = [leftHand, rightHand].filter(Boolean);
|
|
27
|
-
return { timestamp, hands, leftHand, rightHand };
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Drive the FSM into 'panning' with dwell=0 (requires two identical frames).
|
|
31
|
-
* Pan = left fist only.
|
|
32
|
-
*/
|
|
33
|
-
function enterPanning(fsm, hand = makeHand('fist')) {
|
|
34
|
-
fsm.update(makeFrame(0, hand, null)); // starts dwell timer
|
|
35
|
-
fsm.update(makeFrame(0, hand, null)); // dwell elapsed → panning
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Drive the FSM into 'zooming' with dwell=0 (requires two identical frames).
|
|
39
|
-
* Zoom = right fist only.
|
|
40
|
-
*/
|
|
41
|
-
function enterZooming(fsm, right = makeHand('fist')) {
|
|
42
|
-
fsm.update(makeFrame(0, null, right));
|
|
43
|
-
fsm.update(makeFrame(0, null, right));
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Drive the FSM into 'rotating' with dwell=0 (requires two identical frames).
|
|
47
|
-
* Rotate = both fists.
|
|
48
|
-
*/
|
|
49
|
-
function enterRotating(fsm, left = makeHand('fist'), right = makeHand('fist')) {
|
|
50
|
-
fsm.update(makeFrame(0, left, right));
|
|
51
|
-
fsm.update(makeFrame(0, left, right));
|
|
52
|
-
}
|
|
53
|
-
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
54
|
-
describe('GestureStateMachine', () => {
|
|
55
|
-
let fsm;
|
|
56
|
-
beforeEach(() => {
|
|
57
|
-
fsm = new GestureStateMachine(FAST_TUNING);
|
|
58
|
-
});
|
|
59
|
-
// ── Initial state ──────────────────────────────────────────────────────────
|
|
60
|
-
it('starts in idle mode', () => {
|
|
61
|
-
expect(fsm.getMode()).toBe('idle');
|
|
62
|
-
});
|
|
63
|
-
// ── idle → panning ─────────────────────────────────────────────────────────
|
|
64
|
-
it('transitions to panning when left fist is held (two frames, dwell=0)', () => {
|
|
65
|
-
const fistHand = makeHand('fist');
|
|
66
|
-
fsm.update(makeFrame(0, fistHand, null)); // starts dwell
|
|
67
|
-
const out = fsm.update(makeFrame(0, fistHand, null)); // 0 >= 0 → panning
|
|
68
|
-
expect(out.mode).toBe('panning');
|
|
69
|
-
});
|
|
70
|
-
// ── idle → zooming ─────────────────────────────────────────────────────────
|
|
71
|
-
it('transitions to zooming when right fist is held (two frames, dwell=0)', () => {
|
|
72
|
-
const right = makeHand('fist');
|
|
73
|
-
fsm.update(makeFrame(0, null, right));
|
|
74
|
-
const out = fsm.update(makeFrame(0, null, right));
|
|
75
|
-
expect(out.mode).toBe('zooming');
|
|
76
|
-
});
|
|
77
|
-
// ── idle → rotating ────────────────────────────────────────────────────────
|
|
78
|
-
it('transitions to rotating when both fists are held (two frames, dwell=0)', () => {
|
|
79
|
-
const left = makeHand('fist');
|
|
80
|
-
const right = makeHand('fist');
|
|
81
|
-
fsm.update(makeFrame(0, left, right));
|
|
82
|
-
const out = fsm.update(makeFrame(0, left, right));
|
|
83
|
-
expect(out.mode).toBe('rotating');
|
|
84
|
-
});
|
|
85
|
-
it('prefers rotating over zooming when both fists are held', () => {
|
|
86
|
-
const left = makeHand('fist');
|
|
87
|
-
const right = makeHand('fist');
|
|
88
|
-
fsm.update(makeFrame(0, left, right));
|
|
89
|
-
const out = fsm.update(makeFrame(0, left, right));
|
|
90
|
-
// both fists = rotating, not zooming
|
|
91
|
-
expect(out.mode).toBe('rotating');
|
|
92
|
-
});
|
|
93
|
-
// ── panning → idle ─────────────────────────────────────────────────────────
|
|
94
|
-
it('returns to idle when the left fist is released (releaseGraceMs=0)', () => {
|
|
95
|
-
enterPanning(fsm);
|
|
96
|
-
expect(fsm.getMode()).toBe('panning');
|
|
97
|
-
fsm.update(makeFrame(1, null, null)); // starts release timer
|
|
98
|
-
const out = fsm.update(makeFrame(1, null, null)); // 0 >= 0 → idle
|
|
99
|
-
expect(out.mode).toBe('idle');
|
|
100
|
-
});
|
|
101
|
-
// ── zooming → idle ─────────────────────────────────────────────────────────
|
|
102
|
-
it('returns to idle when right fist is released (releaseGraceMs=0)', () => {
|
|
103
|
-
enterZooming(fsm);
|
|
104
|
-
expect(fsm.getMode()).toBe('zooming');
|
|
105
|
-
fsm.update(makeFrame(1, null, null)); // starts release timer
|
|
106
|
-
const out = fsm.update(makeFrame(1, null, null)); // 0 >= 0 → idle
|
|
107
|
-
expect(out.mode).toBe('idle');
|
|
108
|
-
});
|
|
109
|
-
// ── rotating → idle ────────────────────────────────────────────────────────
|
|
110
|
-
it('returns to idle when both fists are released (releaseGraceMs=0)', () => {
|
|
111
|
-
enterRotating(fsm);
|
|
112
|
-
expect(fsm.getMode()).toBe('rotating');
|
|
113
|
-
fsm.update(makeFrame(1, null, null)); // starts release timer
|
|
114
|
-
const out = fsm.update(makeFrame(1, null, null)); // 0 >= 0 → idle
|
|
115
|
-
expect(out.mode).toBe('idle');
|
|
116
|
-
});
|
|
117
|
-
// ── panDelta output ────────────────────────────────────────────────────────
|
|
118
|
-
it('emits no panDelta on the first panning frame (no previous position)', () => {
|
|
119
|
-
const fistHand = makeHand('fist');
|
|
120
|
-
fsm.update(makeFrame(0, fistHand, null)); // dwell starts
|
|
121
|
-
const out = fsm.update(makeFrame(0, fistHand, null)); // enters panning, sets prevPos
|
|
122
|
-
expect(out.panDelta).toBeNull();
|
|
123
|
-
});
|
|
124
|
-
it('emits panDelta once a previous position is established', () => {
|
|
125
|
-
const wristPos1 = { x: 0.3, y: 0.4, z: 0 };
|
|
126
|
-
const wristPos2 = { x: 0.35, y: 0.45, z: 0 };
|
|
127
|
-
const lm1 = makeLandmarks({ [LANDMARKS.WRIST]: wristPos1 });
|
|
128
|
-
const lm2 = makeLandmarks({ [LANDMARKS.WRIST]: wristPos2 });
|
|
129
|
-
// Frame 1: start dwell (idle)
|
|
130
|
-
fsm.update(makeFrame(0, makeHand('fist', lm1), null));
|
|
131
|
-
// Frame 2: transition fires → panning, but buildOutput returns from idle branch (panDelta=null)
|
|
132
|
-
fsm.update(makeFrame(0, makeHand('fist', lm1), null));
|
|
133
|
-
// Frame 3: first real panning frame, sets prevPanPos = wristPos1, no delta yet
|
|
134
|
-
fsm.update(makeFrame(1, makeHand('fist', lm1), null));
|
|
135
|
-
// Frame 4: second panning frame, emits delta
|
|
136
|
-
const out = fsm.update(makeFrame(2, makeHand('fist', lm2), null));
|
|
137
|
-
expect(out.mode).toBe('panning');
|
|
138
|
-
expect(out.panDelta).not.toBeNull();
|
|
139
|
-
expect(out.panDelta.x).toBeCloseTo(wristPos2.x - wristPos1.x);
|
|
140
|
-
expect(out.panDelta.y).toBeCloseTo(wristPos2.y - wristPos1.y);
|
|
141
|
-
});
|
|
142
|
-
// ── zoomDelta output ───────────────────────────────────────────────────────
|
|
143
|
-
it('emits no zoomDelta on the first zooming frame', () => {
|
|
144
|
-
const right = makeHand('fist');
|
|
145
|
-
fsm.update(makeFrame(0, null, right));
|
|
146
|
-
const out = fsm.update(makeFrame(0, null, right)); // enters zooming, sets prevZoomDist
|
|
147
|
-
expect(out.zoomDelta).toBeNull();
|
|
148
|
-
});
|
|
149
|
-
it('emits zoomDelta once a previous position is established', () => {
|
|
150
|
-
// Right wrist starts lower (y=0.6), then moves up (y=0.4) → zoom in (positive delta)
|
|
151
|
-
const lmR1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.6, z: 0 } });
|
|
152
|
-
const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.4, z: 0 } });
|
|
153
|
-
// Frame 1+2: dwell + transition (zooming entered on frame 2, but output comes from idle branch)
|
|
154
|
-
fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
|
|
155
|
-
fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
|
|
156
|
-
// Frame 3: first real zooming frame, sets prevZoomDist, no delta yet
|
|
157
|
-
fsm.update(makeFrame(1, null, makeHand('fist', lmR1)));
|
|
158
|
-
// Frame 4: hand moved up → zoom in (positive delta)
|
|
159
|
-
const out = fsm.update(makeFrame(2, null, makeHand('fist', lmR2)));
|
|
160
|
-
expect(out.mode).toBe('zooming');
|
|
161
|
-
expect(out.zoomDelta).not.toBeNull();
|
|
162
|
-
expect(out.zoomDelta).toBeGreaterThan(0); // hand moved up = zoom in
|
|
163
|
-
});
|
|
164
|
-
it('emits negative zoomDelta when hand moves down (zoom out)', () => {
|
|
165
|
-
const lmR1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.4, z: 0 } });
|
|
166
|
-
const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.6, z: 0 } });
|
|
167
|
-
fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
|
|
168
|
-
fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
|
|
169
|
-
fsm.update(makeFrame(1, null, makeHand('fist', lmR1)));
|
|
170
|
-
const out = fsm.update(makeFrame(2, null, makeHand('fist', lmR2)));
|
|
171
|
-
expect(out.zoomDelta).not.toBeNull();
|
|
172
|
-
expect(out.zoomDelta).toBeLessThan(0); // hand moved down = zoom out
|
|
173
|
-
});
|
|
174
|
-
// ── rotateDelta output ─────────────────────────────────────────────────────
|
|
175
|
-
it('emits no rotateDelta on the first rotating frame', () => {
|
|
176
|
-
const left = makeHand('fist');
|
|
177
|
-
const right = makeHand('fist');
|
|
178
|
-
fsm.update(makeFrame(0, left, right));
|
|
179
|
-
const out = fsm.update(makeFrame(0, left, right)); // enters rotating, sets prevRotateAngle
|
|
180
|
-
expect(out.rotateDelta).toBeNull();
|
|
181
|
-
});
|
|
182
|
-
it('emits rotateDelta once a previous angle is established', () => {
|
|
183
|
-
// Left wrist at (0.2, 0.5), right wrist at (0.8, 0.5) → angle = 0 (horizontal)
|
|
184
|
-
const lmL1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.2, y: 0.5, z: 0 } });
|
|
185
|
-
const lmR1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.8, y: 0.5, z: 0 } });
|
|
186
|
-
// Tilt clockwise: right wrist drops, left wrist rises → angle increases
|
|
187
|
-
const lmL2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.2, y: 0.4, z: 0 } });
|
|
188
|
-
const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.8, y: 0.6, z: 0 } });
|
|
189
|
-
fsm.update(makeFrame(0, makeHand('fist', lmL1), makeHand('fist', lmR1)));
|
|
190
|
-
fsm.update(makeFrame(0, makeHand('fist', lmL1), makeHand('fist', lmR1)));
|
|
191
|
-
fsm.update(makeFrame(1, makeHand('fist', lmL1), makeHand('fist', lmR1)));
|
|
192
|
-
const out = fsm.update(makeFrame(2, makeHand('fist', lmL2), makeHand('fist', lmR2)));
|
|
193
|
-
expect(out.mode).toBe('rotating');
|
|
194
|
-
expect(out.rotateDelta).not.toBeNull();
|
|
195
|
-
});
|
|
196
|
-
// ── reset ──────────────────────────────────────────────────────────────────
|
|
197
|
-
it('reset() returns the FSM to idle', () => {
|
|
198
|
-
enterPanning(fsm);
|
|
199
|
-
expect(fsm.getMode()).toBe('panning');
|
|
200
|
-
fsm.reset();
|
|
201
|
-
expect(fsm.getMode()).toBe('idle');
|
|
202
|
-
});
|
|
203
|
-
it('reset() returns the FSM to idle from rotating', () => {
|
|
204
|
-
enterRotating(fsm);
|
|
205
|
-
expect(fsm.getMode()).toBe('rotating');
|
|
206
|
-
fsm.reset();
|
|
207
|
-
expect(fsm.getMode()).toBe('idle');
|
|
208
|
-
});
|
|
209
|
-
// ── dwell timer ────────────────────────────────────────────────────────────
|
|
210
|
-
it('does NOT transition before actionDwellMs elapses', () => {
|
|
211
|
-
const slowFsm = new GestureStateMachine({ ...FAST_TUNING, actionDwellMs: 200 });
|
|
212
|
-
const fistHand = makeHand('fist');
|
|
213
|
-
const out1 = slowFsm.update(makeFrame(0, fistHand, null));
|
|
214
|
-
expect(out1.mode).toBe('idle');
|
|
215
|
-
const out2 = slowFsm.update(makeFrame(100, fistHand, null)); // 100 ms < 200 ms
|
|
216
|
-
expect(out2.mode).toBe('idle');
|
|
217
|
-
const out3 = slowFsm.update(makeFrame(200, fistHand, null)); // 200 ms >= 200 ms
|
|
218
|
-
expect(out3.mode).toBe('panning');
|
|
219
|
-
});
|
|
220
|
-
// ── release grace period ───────────────────────────────────────────────────
|
|
221
|
-
it('stays in panning during release grace period', () => {
|
|
222
|
-
const graceFsm = new GestureStateMachine({ ...FAST_TUNING, releaseGraceMs: 100 });
|
|
223
|
-
enterPanning(graceFsm);
|
|
224
|
-
expect(graceFsm.getMode()).toBe('panning');
|
|
225
|
-
const out1 = graceFsm.update(makeFrame(1, null, null)); // grace starts
|
|
226
|
-
expect(out1.mode).toBe('panning');
|
|
227
|
-
const out2 = graceFsm.update(makeFrame(50, null, null)); // 50 ms < 100 ms
|
|
228
|
-
expect(out2.mode).toBe('panning');
|
|
229
|
-
const out3 = graceFsm.update(makeFrame(101, null, null)); // 101 ms >= 100 ms
|
|
230
|
-
expect(out3.mode).toBe('idle');
|
|
231
|
-
});
|
|
232
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,98 +0,0 @@
|
|
|
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
|
-
});
|