@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.
@@ -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
- });