@map-gesture-controls/core 0.1.7 → 0.1.9

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.
@@ -28,6 +28,7 @@ function makeFrame(timestamp, leftHand, rightHand) {
28
28
  }
29
29
  /**
30
30
  * Drive the FSM into 'panning' with dwell=0 (requires two identical frames).
31
+ * Pan = left fist only.
31
32
  */
32
33
  function enterPanning(fsm, hand = makeHand('fist')) {
33
34
  fsm.update(makeFrame(0, hand, null)); // starts dwell timer
@@ -35,8 +36,27 @@ function enterPanning(fsm, hand = makeHand('fist')) {
35
36
  }
36
37
  /**
37
38
  * Drive the FSM into 'zooming' with dwell=0 (requires two identical frames).
39
+ * Zoom = right fist only.
38
40
  */
39
- function enterZooming(fsm, left = makeHand('openPalm'), right = makeHand('openPalm')) {
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.
47
+ * Requires ESCALATION_FRAMES (3) consecutive both-active frames, then one
48
+ * more for the dwell to fire (dwell=0 means it fires on the frame that meets
49
+ * the threshold, so 3 stable frames + 1 dwell frame = 4 total).
50
+ * Rotate = both fists.
51
+ */
52
+ function enterRotating(fsm, left = makeHand('fist'), right = makeHand('fist')) {
53
+ // Frame 1: both active, dwell timer starts (idle branch, no transition yet).
54
+ // Frame 2: dwell fires → transitionTo('zooming'), resets leftActiveFrames to 0.
55
+ // Frames 3-5: leftActiveFrames counts 1 → 2 → 3 (= ESCALATION_FRAMES).
56
+ // Frame 5: bothStable=true → zooming branch escalates to rotating.
57
+ fsm.update(makeFrame(0, left, right));
58
+ fsm.update(makeFrame(0, left, right));
59
+ fsm.update(makeFrame(0, left, right));
40
60
  fsm.update(makeFrame(0, left, right));
41
61
  fsm.update(makeFrame(0, left, right));
42
62
  }
@@ -51,22 +71,53 @@ describe('GestureStateMachine', () => {
51
71
  expect(fsm.getMode()).toBe('idle');
52
72
  });
53
73
  // ── idle → panning ─────────────────────────────────────────────────────────
54
- it('transitions to panning after the dwell elapses (two frames, dwell=0)', () => {
74
+ it('transitions to panning when left fist is held (two frames, dwell=0)', () => {
55
75
  const fistHand = makeHand('fist');
56
76
  fsm.update(makeFrame(0, fistHand, null)); // starts dwell
57
77
  const out = fsm.update(makeFrame(0, fistHand, null)); // 0 >= 0 → panning
58
78
  expect(out.mode).toBe('panning');
59
79
  });
60
80
  // ── idle → zooming ─────────────────────────────────────────────────────────
61
- it('transitions to zooming when two open palms are held (two frames, dwell=0)', () => {
62
- const left = makeHand('openPalm');
63
- const right = makeHand('openPalm');
81
+ it('transitions to zooming when right fist is held (two frames, dwell=0)', () => {
82
+ const right = makeHand('fist');
83
+ fsm.update(makeFrame(0, null, right));
84
+ const out = fsm.update(makeFrame(0, null, right));
85
+ expect(out.mode).toBe('zooming');
86
+ });
87
+ // ── idle → rotating ────────────────────────────────────────────────────────
88
+ it('transitions to rotating when both fists are held stably (5 frames: dwell + 3 escalation)', () => {
89
+ const left = makeHand('fist');
90
+ const right = makeHand('fist');
91
+ fsm.update(makeFrame(0, left, right));
92
+ fsm.update(makeFrame(0, left, right));
93
+ fsm.update(makeFrame(0, left, right));
64
94
  fsm.update(makeFrame(0, left, right));
65
95
  const out = fsm.update(makeFrame(0, left, right));
66
- expect(out.mode).toBe('zooming');
96
+ expect(out.mode).toBe('rotating');
97
+ });
98
+ it('prefers rotating over zooming once both fists are held stably', () => {
99
+ const left = makeHand('fist');
100
+ const right = makeHand('fist');
101
+ fsm.update(makeFrame(0, left, right));
102
+ fsm.update(makeFrame(0, left, right));
103
+ fsm.update(makeFrame(0, left, right));
104
+ fsm.update(makeFrame(0, left, right));
105
+ const out = fsm.update(makeFrame(0, left, right));
106
+ // both fists stable for ESCALATION_FRAMES = rotating, not zooming
107
+ expect(out.mode).toBe('rotating');
108
+ });
109
+ it('does NOT transition to rotating if second hand only appears for 1-2 frames (noise guard)', () => {
110
+ const left = makeHand('fist');
111
+ const right = makeHand('fist');
112
+ // Only 2 frames with both hands — below ESCALATION_FRAMES threshold
113
+ fsm.update(makeFrame(0, left, null)); // establish left pan
114
+ fsm.update(makeFrame(0, left, null)); // → panning
115
+ fsm.update(makeFrame(1, left, right)); // right appears — 1 frame, not stable yet
116
+ const out = fsm.update(makeFrame(1, left, right)); // 2 frames — still below threshold
117
+ expect(out.mode).toBe('panning');
67
118
  });
68
119
  // ── panning → idle ─────────────────────────────────────────────────────────
69
- it('returns to idle when the fist is released (releaseGraceMs=0)', () => {
120
+ it('returns to idle when the left fist is released (releaseGraceMs=0)', () => {
70
121
  enterPanning(fsm);
71
122
  expect(fsm.getMode()).toBe('panning');
72
123
  fsm.update(makeFrame(1, null, null)); // starts release timer
@@ -74,13 +125,21 @@ describe('GestureStateMachine', () => {
74
125
  expect(out.mode).toBe('idle');
75
126
  });
76
127
  // ── zooming → idle ─────────────────────────────────────────────────────────
77
- it('returns to idle when open palms are released (releaseGraceMs=0)', () => {
128
+ it('returns to idle when right fist is released (releaseGraceMs=0)', () => {
78
129
  enterZooming(fsm);
79
130
  expect(fsm.getMode()).toBe('zooming');
80
131
  fsm.update(makeFrame(1, null, null)); // starts release timer
81
132
  const out = fsm.update(makeFrame(1, null, null)); // 0 >= 0 → idle
82
133
  expect(out.mode).toBe('idle');
83
134
  });
135
+ // ── rotating → idle ────────────────────────────────────────────────────────
136
+ it('returns to idle when both fists are released (releaseGraceMs=0)', () => {
137
+ enterRotating(fsm);
138
+ expect(fsm.getMode()).toBe('rotating');
139
+ fsm.update(makeFrame(1, null, null)); // starts release timer
140
+ const out = fsm.update(makeFrame(1, null, null)); // 0 >= 0 → idle
141
+ expect(out.mode).toBe('idle');
142
+ });
84
143
  // ── panDelta output ────────────────────────────────────────────────────────
85
144
  it('emits no panDelta on the first panning frame (no previous position)', () => {
86
145
  const fistHand = makeHand('fist');
@@ -108,27 +167,63 @@ describe('GestureStateMachine', () => {
108
167
  });
109
168
  // ── zoomDelta output ───────────────────────────────────────────────────────
110
169
  it('emits no zoomDelta on the first zooming frame', () => {
111
- const left = makeHand('openPalm');
112
- const right = makeHand('openPalm');
113
- fsm.update(makeFrame(0, left, right));
114
- const out = fsm.update(makeFrame(0, left, right)); // enters zooming, sets prevZoomDist
170
+ const right = makeHand('fist');
171
+ fsm.update(makeFrame(0, null, right));
172
+ const out = fsm.update(makeFrame(0, null, right)); // enters zooming, sets prevZoomDist
115
173
  expect(out.zoomDelta).toBeNull();
116
174
  });
117
- it('emits zoomDelta once a previous distance is established', () => {
118
- const lmL1 = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.4, y: 0.5, z: 0 } });
119
- const lmR1 = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.6, y: 0.5, z: 0 } });
120
- const lmL2 = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.3, y: 0.5, z: 0 } });
121
- const lmR2 = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.7, y: 0.5, z: 0 } });
175
+ it('emits zoomDelta once a previous position is established', () => {
176
+ // Right wrist starts lower (y=0.6), then moves up (y=0.4) zoom in (positive delta)
177
+ const lmR1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.6, z: 0 } });
178
+ const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.4, z: 0 } });
122
179
  // Frame 1+2: dwell + transition (zooming entered on frame 2, but output comes from idle branch)
123
- fsm.update(makeFrame(0, makeHand('openPalm', lmL1), makeHand('openPalm', lmR1)));
124
- fsm.update(makeFrame(0, makeHand('openPalm', lmL1), makeHand('openPalm', lmR1)));
180
+ fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
181
+ fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
125
182
  // Frame 3: first real zooming frame, sets prevZoomDist, no delta yet
126
- fsm.update(makeFrame(1, makeHand('openPalm', lmL1), makeHand('openPalm', lmR1)));
127
- // Frame 4: wider spread → positive delta
128
- const out = fsm.update(makeFrame(2, makeHand('openPalm', lmL2), makeHand('openPalm', lmR2)));
183
+ fsm.update(makeFrame(1, null, makeHand('fist', lmR1)));
184
+ // Frame 4: hand moved up zoom in (positive delta)
185
+ const out = fsm.update(makeFrame(2, null, makeHand('fist', lmR2)));
129
186
  expect(out.mode).toBe('zooming');
130
187
  expect(out.zoomDelta).not.toBeNull();
131
- expect(out.zoomDelta).toBeGreaterThan(0);
188
+ expect(out.zoomDelta).toBeGreaterThan(0); // hand moved up = zoom in
189
+ });
190
+ it('emits negative zoomDelta when hand moves down (zoom out)', () => {
191
+ const lmR1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.4, z: 0 } });
192
+ const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.6, z: 0 } });
193
+ fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
194
+ fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
195
+ fsm.update(makeFrame(1, null, makeHand('fist', lmR1)));
196
+ const out = fsm.update(makeFrame(2, null, makeHand('fist', lmR2)));
197
+ expect(out.zoomDelta).not.toBeNull();
198
+ expect(out.zoomDelta).toBeLessThan(0); // hand moved down = zoom out
199
+ });
200
+ // ── rotateDelta output ─────────────────────────────────────────────────────
201
+ it('emits no rotateDelta on the first rotating frame', () => {
202
+ // enterRotating drives into rotating mode (5 frames); the escalation frame
203
+ // itself returns null (no prevAngle yet). The *next* frame sets prevAngle,
204
+ // still no delta. Only the frame after that can emit a delta.
205
+ enterRotating(fsm);
206
+ // First full frame inside rotating — sets prevRotateAngle, no delta yet
207
+ const out = fsm.update(makeFrame(1, makeHand('fist'), makeHand('fist')));
208
+ expect(out.rotateDelta).toBeNull();
209
+ });
210
+ it('emits rotateDelta once a previous angle is established', () => {
211
+ // Left wrist at (0.2, 0.5), right wrist at (0.8, 0.5) → angle = 0 (horizontal)
212
+ const lmL1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.2, y: 0.5, z: 0 } });
213
+ const lmR1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.8, y: 0.5, z: 0 } });
214
+ // Tilt clockwise: right wrist drops, left wrist rises → angle increases
215
+ const lmL2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.2, y: 0.4, z: 0 } });
216
+ const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.8, y: 0.6, z: 0 } });
217
+ // Get into rotating mode (5 frames with lmL1/lmR1)
218
+ for (let i = 0; i < 5; i++) {
219
+ fsm.update(makeFrame(0, makeHand('fist', lmL1), makeHand('fist', lmR1)));
220
+ }
221
+ // First frame inside rotating: sets prevRotateAngle, no delta
222
+ fsm.update(makeFrame(1, makeHand('fist', lmL1), makeHand('fist', lmR1)));
223
+ // Second frame: emits delta
224
+ const out = fsm.update(makeFrame(2, makeHand('fist', lmL2), makeHand('fist', lmR2)));
225
+ expect(out.mode).toBe('rotating');
226
+ expect(out.rotateDelta).not.toBeNull();
132
227
  });
133
228
  // ── reset ──────────────────────────────────────────────────────────────────
134
229
  it('reset() returns the FSM to idle', () => {
@@ -137,6 +232,12 @@ describe('GestureStateMachine', () => {
137
232
  fsm.reset();
138
233
  expect(fsm.getMode()).toBe('idle');
139
234
  });
235
+ it('reset() returns the FSM to idle from rotating', () => {
236
+ enterRotating(fsm);
237
+ expect(fsm.getMode()).toBe('rotating');
238
+ fsm.reset();
239
+ expect(fsm.getMode()).toBe('idle');
240
+ });
140
241
  // ── dwell timer ────────────────────────────────────────────────────────────
141
242
  it('does NOT transition before actionDwellMs elapses', () => {
142
243
  const slowFsm = new GestureStateMachine({ ...FAST_TUNING, actionDwellMs: 200 });
@@ -145,6 +145,7 @@ export class WebcamOverlay {
145
145
  idle: 'Idle',
146
146
  panning: 'Pan',
147
147
  zooming: 'Zoom',
148
+ rotating: 'Rotate',
148
149
  };
149
150
  this.badge.textContent = labels[mode];
150
151
  this.badge.className = `ol-gesture-badge ol-gesture-badge--${mode}`;
@@ -13,12 +13,15 @@ export declare const LANDMARKS: {
13
13
  readonly PINKY_TIP: 20;
14
14
  readonly PINKY_MCP: 17;
15
15
  };
16
+ export declare const PINCH_THRESHOLD = 0.25;
17
+ export declare const PINCH_RELEASE_THRESHOLD = 0.35;
16
18
  export declare const FINGERTIP_INDICES: readonly [8, 12, 16, 20];
17
19
  export declare const FINGER_BASE_INDICES: readonly [5, 9, 13, 17];
18
20
  export declare const COLORS: {
19
21
  readonly idle: "#888888";
20
22
  readonly panning: "#00ccff";
21
23
  readonly zooming: "#00ffcc";
24
+ readonly rotating: "#ff9900";
22
25
  readonly landmark: "rgba(255,255,255,0.6)";
23
26
  readonly connection: "rgba(255,255,255,0.3)";
24
27
  readonly fingertipGlow: "#4488ff";
package/dist/constants.js CHANGED
@@ -29,6 +29,12 @@ export const LANDMARKS = {
29
29
  PINKY_TIP: 20,
30
30
  PINKY_MCP: 17,
31
31
  };
32
+ // Pinch detection thresholds as a fraction of hand size.
33
+ // Hysteresis: enter pinch at PINCH_THRESHOLD, exit (release) at PINCH_RELEASE_THRESHOLD.
34
+ // The wider release threshold prevents the classifier from flickering between
35
+ // 'pinch' and 'none' when the fingers hover at the edge of the enter threshold.
36
+ export const PINCH_THRESHOLD = 0.25;
37
+ export const PINCH_RELEASE_THRESHOLD = 0.35;
32
38
  // Fingertip indices for open-palm detection
33
39
  export const FINGERTIP_INDICES = [
34
40
  LANDMARKS.INDEX_TIP,
@@ -47,6 +53,7 @@ export const COLORS = {
47
53
  idle: '#888888',
48
54
  panning: '#00ccff',
49
55
  zooming: '#00ffcc',
56
+ rotating: '#ff9900',
50
57
  landmark: 'rgba(255,255,255,0.6)',
51
58
  connection: 'rgba(255,255,255,0.3)',
52
59
  fingertipGlow: '#4488ff',
@@ -14,9 +14,24 @@ export declare function getTwoHandDistance(landmarksA: HandLandmark[], landmarks
14
14
  /**
15
15
  * Classify the gesture for a single hand from its landmarks.
16
16
  *
17
- * Priority: fist > openPalm > none
17
+ * Priority: fist > pinch > openPalm > none
18
18
  *
19
- * Fist: most fingers curled
20
- * Open palm: all fingers extended
19
+ * Fist: most fingers curled
20
+ * Pinch: thumb tip and index tip close together
21
+ * Open palm: all fingers extended and spread
22
+ *
23
+ * NOTE: This stateless version has no pinch hysteresis. Use `createHandClassifier`
24
+ * for a stateful per-hand classifier that prevents pinch flickering.
21
25
  */
22
26
  export declare function classifyGesture(landmarks: HandLandmark[]): GestureType;
27
+ /**
28
+ * Returns a stateful classify function for a single hand.
29
+ *
30
+ * Pinch uses hysteresis: the hand enters 'pinch' when tips are within
31
+ * PINCH_THRESHOLD of hand size, and stays in 'pinch' until tips separate
32
+ * beyond PINCH_RELEASE_THRESHOLD. This prevents rapid toggling when fingers
33
+ * hover at the entry threshold during a held pinch gesture.
34
+ *
35
+ * Use one instance per hand (left / right) and reset between hand sessions.
36
+ */
37
+ export declare function createHandClassifier(): (landmarks: HandLandmark[]) => GestureType;
@@ -1,4 +1,4 @@
1
- import { LANDMARKS, FINGERTIP_INDICES, FINGER_BASE_INDICES } from './constants.js';
1
+ import { LANDMARKS, FINGERTIP_INDICES, FINGER_BASE_INDICES, PINCH_THRESHOLD, PINCH_RELEASE_THRESHOLD } from './constants.js';
2
2
  /**
3
3
  * Euclidean distance between two landmarks (ignoring Z).
4
4
  */
@@ -89,13 +89,42 @@ export function getTwoHandDistance(landmarksA, landmarksB) {
89
89
  return 0;
90
90
  return dist(tipA, tipB);
91
91
  }
92
+ /**
93
+ * Stateless pinch check used only as the entry condition.
94
+ * Returns true when thumb tip and index tip are within PINCH_THRESHOLD of hand size.
95
+ */
96
+ function isPinchEnter(landmarks) {
97
+ const handSize = getHandSize(landmarks);
98
+ if (handSize === 0)
99
+ return false;
100
+ const thumbTip = landmarks[LANDMARKS.THUMB_TIP];
101
+ const indexTip = landmarks[LANDMARKS.INDEX_TIP];
102
+ return dist(thumbTip, indexTip) < handSize * PINCH_THRESHOLD;
103
+ }
104
+ /**
105
+ * Returns true when thumb tip and index tip are still within PINCH_RELEASE_THRESHOLD.
106
+ * Used as the exit condition to implement hysteresis: once pinching, we stay in
107
+ * 'pinch' until the fingers open wider than the release threshold.
108
+ */
109
+ function isPinchHeld(landmarks) {
110
+ const handSize = getHandSize(landmarks);
111
+ if (handSize === 0)
112
+ return false;
113
+ const thumbTip = landmarks[LANDMARKS.THUMB_TIP];
114
+ const indexTip = landmarks[LANDMARKS.INDEX_TIP];
115
+ return dist(thumbTip, indexTip) < handSize * PINCH_RELEASE_THRESHOLD;
116
+ }
92
117
  /**
93
118
  * Classify the gesture for a single hand from its landmarks.
94
119
  *
95
- * Priority: fist > openPalm > none
120
+ * Priority: fist > pinch > openPalm > none
121
+ *
122
+ * Fist: most fingers curled
123
+ * Pinch: thumb tip and index tip close together
124
+ * Open palm: all fingers extended and spread
96
125
  *
97
- * Fist: most fingers curled
98
- * Open palm: all fingers extended
126
+ * NOTE: This stateless version has no pinch hysteresis. Use `createHandClassifier`
127
+ * for a stateful per-hand classifier that prevents pinch flickering.
99
128
  */
100
129
  export function classifyGesture(landmarks) {
101
130
  if (landmarks.length < 21)
@@ -103,8 +132,48 @@ export function classifyGesture(landmarks) {
103
132
  if (areAllFingersCurled(landmarks)) {
104
133
  return 'fist';
105
134
  }
135
+ if (isPinchEnter(landmarks)) {
136
+ return 'pinch';
137
+ }
106
138
  if (areAllFingersExtended(landmarks)) {
107
139
  return 'openPalm';
108
140
  }
109
141
  return 'none';
110
142
  }
143
+ /**
144
+ * Returns a stateful classify function for a single hand.
145
+ *
146
+ * Pinch uses hysteresis: the hand enters 'pinch' when tips are within
147
+ * PINCH_THRESHOLD of hand size, and stays in 'pinch' until tips separate
148
+ * beyond PINCH_RELEASE_THRESHOLD. This prevents rapid toggling when fingers
149
+ * hover at the entry threshold during a held pinch gesture.
150
+ *
151
+ * Use one instance per hand (left / right) and reset between hand sessions.
152
+ */
153
+ export function createHandClassifier() {
154
+ let pinching = false;
155
+ return function classify(landmarks) {
156
+ if (landmarks.length < 21) {
157
+ pinching = false;
158
+ return 'none';
159
+ }
160
+ if (areAllFingersCurled(landmarks)) {
161
+ pinching = false;
162
+ return 'fist';
163
+ }
164
+ // Hysteresis: enter pinch on PINCH_THRESHOLD, exit on PINCH_RELEASE_THRESHOLD.
165
+ if (pinching) {
166
+ pinching = isPinchHeld(landmarks);
167
+ }
168
+ else {
169
+ pinching = isPinchEnter(landmarks);
170
+ }
171
+ if (pinching) {
172
+ return 'pinch';
173
+ }
174
+ if (areAllFingersExtended(landmarks)) {
175
+ return 'openPalm';
176
+ }
177
+ return 'none';
178
+ };
179
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,6 @@ export { GestureController } from './GestureController.js';
2
2
  export { GestureStateMachine } from './GestureStateMachine.js';
3
3
  export type { StateMachineOutput } from './GestureStateMachine.js';
4
4
  export { WebcamOverlay } from './WebcamOverlay.js';
5
- export { classifyGesture, getHandSize, getTwoHandDistance } from './gestureClassifier.js';
5
+ export { classifyGesture, createHandClassifier, getHandSize, getTwoHandDistance } from './gestureClassifier.js';
6
6
  export type { GestureMode, GestureType, HandednessLabel, GestureFrame, DetectedHand, WebcamConfig, TuningConfig, SmoothedPoint, Point2D, HandLandmark, } from './types.js';
7
7
  export { DEFAULT_WEBCAM_CONFIG, DEFAULT_TUNING_CONFIG, LANDMARKS, COLORS, } from './constants.js';