tperm-visor 1.0.2 → 1.0.4

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.
@@ -101,6 +101,8 @@ _BOUND_CORNERS = [(x, y, z) for x in (-2.8, 2.8)
101
101
  for y in (-2.8, 2.8)
102
102
  for z in (-2.8, 2.8)]
103
103
 
104
+ _PROBE_DEG = 0.5 # probe angle for measuring slice screen direction
105
+
104
106
  _POINTERS_3D = []
105
107
  for _x in [-1.6, 0.0, 1.6]:
106
108
  for _y in [-1.6, 0.0, 1.6]:
@@ -156,6 +158,7 @@ class CubeRenderer:
156
158
  self._ready = False
157
159
  self._body_list = None # display list: one cubie body
158
160
  self._sticker_base = None # 54 consecutive display lists, one per sticker
161
+ self._slice_dirs = {} # face_key -> unit screen dir of the grabbed pointer
159
162
 
160
163
  def init_gl(self):
161
164
  self._fbo = glGenFramebuffers(1)
@@ -329,9 +332,9 @@ class CubeRenderer:
329
332
 
330
333
  for p3d in _POINTERS_3D:
331
334
  try:
332
- # Need to manually project with slice rotation applied to get accurate 2D pointers
333
- # But it's easier to just use the un-rotated pointer for the interaction hit-test
334
- # Since when you drag, the slice rotates, but your finger stays near the pointer.
335
+ # Project the UN-rotated pointer: while you drag, the slice turns
336
+ # but your finger stays near where you grabbed, so the un-rotated
337
+ # position is the right hit-test target.
335
338
  win_x, win_y, win_z = gluProject(p3d[0], p3d[1], p3d[2], modelview, projection, viewport)
336
339
  if 0 <= win_z <= 1:
337
340
  cv_y = h - win_y
@@ -339,6 +342,34 @@ class CubeRenderer:
339
342
  except Exception:
340
343
  pass
341
344
 
345
+ # Screen direction each pointer travels for a small POSITIVE turn of each
346
+ # layer it belongs to. Measured through the very matrices used to draw
347
+ # this frame, rather than re-deriving the transform by hand - an analytic
348
+ # version disagreed with the renderer on 88% of random orientations.
349
+ self._slice_dirs = {}
350
+ if highlighted_pointer is not None:
351
+ try:
352
+ base = gluProject(highlighted_pointer[0], highlighted_pointer[1],
353
+ highlighted_pointer[2], modelview, projection, viewport)
354
+ p = np.asarray(highlighted_pointer, dtype=float)
355
+ for face_key in ('U', 'D', 'E', 'R', 'L', 'M'):
356
+ if not _is_in_layer(face_key, *highlighted_pointer):
357
+ continue
358
+ axis = np.asarray(_get_rotation_axis(face_key), dtype=float)
359
+ # render() applies glRotatef(-angle, axis), so probe with -eps
360
+ theta = np.radians(-_PROBE_DEG)
361
+ c, s_ = np.cos(theta), np.sin(theta)
362
+ moved = (p * c + np.cross(axis, p) * s_
363
+ + axis * np.dot(axis, p) * (1.0 - c))
364
+ wx, wy, _wz = gluProject(moved[0], moved[1], moved[2],
365
+ modelview, projection, viewport)
366
+ d = np.array([wx - base[0], (h - wy) - (h - base[1])])
367
+ n = np.linalg.norm(d)
368
+ if n > 1e-9:
369
+ self._slice_dirs[face_key] = d / n
370
+ except Exception:
371
+ self._slice_dirs = {}
372
+
342
373
  # Read back only the cube's bounding box, not the whole frame. A full 720p
343
374
  # RGBA readback is 3.7 MB across the bus every tick and dominates the
344
375
  # frame; the cube usually covers a few percent of the screen.
@@ -379,6 +410,14 @@ class CubeRenderer:
379
410
 
380
411
  return cv_frame, pointers_2d
381
412
 
413
+ def slice_directions(self):
414
+ """Screen direction each candidate layer pushes the grabbed pointer.
415
+
416
+ Populated during render() for whichever pointer is highlighted, i.e. the
417
+ one currently grabbed. Empty when nothing is grabbed.
418
+ """
419
+ return self._slice_dirs
420
+
382
421
  def cleanup(self):
383
422
  if self._body_list:
384
423
  glDeleteLists(self._body_list, 1)
@@ -62,10 +62,27 @@ def is_open_palm(hand: HandData) -> bool:
62
62
  return extended >= 4 and thumb_idx_dist > 0.08
63
63
 
64
64
 
65
- def is_pinch(hand: HandData) -> bool:
66
- """Thumb tip index tip closer than 0.06 normalised units."""
67
- d = _norm_dist(hand.landmarks, 4, 8)
68
- return d < 0.06
65
+ # Hysteresis band for the pinch. A single threshold makes the gesture drop out
66
+ # whenever landmark noise nudges the distance across it, which ends a drag
67
+ # mid-swipe. Requiring a firm pinch to START but a clearly-open hand to RELEASE
68
+ # means jitter around the boundary cannot break contact.
69
+ PINCH_CLOSE = 0.055 # must get at least this close to begin pinching
70
+ PINCH_OPEN = 0.085 # must open at least this wide to stop
71
+
72
+
73
+ def pinch_distance(hand: HandData) -> float:
74
+ """Thumb tip to index tip, in normalised units."""
75
+ return _norm_dist(hand.landmarks, 4, 8)
76
+
77
+
78
+ def is_pinch(hand: HandData, was_pinching: bool = False) -> bool:
79
+ """True while the thumb and index finger are pinched together.
80
+
81
+ Pass the previous frame's state to get hysteresis; without it this is a
82
+ plain threshold at PINCH_CLOSE.
83
+ """
84
+ d = pinch_distance(hand)
85
+ return d < (PINCH_OPEN if was_pinching else PINCH_CLOSE)
69
86
 
70
87
 
71
88
  # ── Palm orientation ──────────────────────────────────────────────────────────
package/backend/server.py CHANGED
@@ -28,8 +28,8 @@ from gesture_engine import (
28
28
  from cube.rubiks import solved_state, scramble, is_solved, apply_move
29
29
  from cube.renderer import CubeRenderer, LAYER_TURN_MOVE
30
30
  from utils.smoothing import EMA, QuatEMA
31
- from utils.transforms import (quat_multiply, quat_conjugate, cube_axes_on_screen,
32
- snap_to_nearest_90, hand_orientation_quat)
31
+ from utils.transforms import (quat_multiply, quat_conjugate, snap_to_nearest_90,
32
+ hand_orientation_quat)
33
33
  import hud
34
34
 
35
35
 
@@ -224,7 +224,7 @@ class AREngine:
224
224
  snapping = False
225
225
  active_pointer_3d = None
226
226
  drag_start_pos = None
227
- drag_direction = None
227
+ drag_sign = 1.0
228
228
  DRAG_LOCK_THRESHOLD = 15
229
229
  pinch_released = True
230
230
 
@@ -493,43 +493,45 @@ class AREngine:
493
493
  prev_hands = {h.label: h for h in hands}
494
494
 
495
495
  elif state == State.DRAGGING_SLICE:
496
- pinching_hand = next((h for h in hands if is_pinch(h)), None)
496
+ # Already pinching: use the wide release threshold so a
497
+ # noisy frame cannot break the drag half-way through.
498
+ pinching_hand = next(
499
+ (h for h in hands if is_pinch(h, was_pinching=True)), None)
497
500
  if pinching_hand and not snapping:
498
501
  px = pinching_hand.landmarks[8].x * frame_w
499
502
  py = pinching_hand.landmarks[8].y * frame_h
500
503
  dx = px - drag_start_pos[0]
501
504
  dy = py - drag_start_pos[1]
502
505
 
503
- if drag_direction is None:
504
- smooth_q = rot_ema.update(cube_rotation)
505
- if abs(dx) > DRAG_LOCK_THRESHOLD or abs(dy) > DRAG_LOCK_THRESHOLD:
506
- screen_x, screen_y = cube_axes_on_screen(smooth_q)
507
- swipe = np.array([dx, dy])
508
- proj_x = abs(np.dot(swipe, screen_x))
509
- proj_y = abs(np.dot(swipe, screen_y))
510
- if proj_x > proj_y:
511
- drag_direction = 'ROW'
512
- if active_pointer_3d[1] > 0.1: face_rot_face = 'U'
513
- elif active_pointer_3d[1] < -0.1: face_rot_face = 'D'
514
- else: face_rot_face = 'E'
515
- else:
516
- drag_direction = 'COL'
517
- if active_pointer_3d[0] > 0.1: face_rot_face = 'R'
518
- elif active_pointer_3d[0] < -0.1: face_rot_face = 'L'
519
- else: face_rot_face = 'M'
520
-
521
- if drag_direction is not None and face_rot_face is not None:
522
- smooth_q = rot_ema.update(cube_rotation)
523
- screen_x, screen_y = cube_axes_on_screen(smooth_q)
524
- swipe = np.array([dx, dy])
525
- if drag_direction == 'ROW':
526
- proj = np.dot(swipe, screen_x)
527
- sign = -1.0 if face_rot_face in ('U', 'E') else 1.0
528
- face_rot_angle = sign * proj / 2.0
529
- elif drag_direction == 'COL':
530
- proj = np.dot(swipe, screen_y)
531
- sign = -1.0 if face_rot_face in ('R', 'M') else 1.0
532
- face_rot_angle = sign * proj / 2.0
506
+ # Both the layer and the direction come from
507
+ # renderer._slice_dirs: the screen direction each
508
+ # candidate layer would push the grabbed cubie,
509
+ # measured through the same matrices that drew the
510
+ # frame. Deriving this analytically did not match the
511
+ # renderer on 88% of random orientations, so it is
512
+ # measured rather than computed.
513
+ swipe = np.array([dx, dy], dtype=float)
514
+ swipe_len = float(np.linalg.norm(swipe))
515
+
516
+ if face_rot_face is None and swipe_len > DRAG_LOCK_THRESHOLD:
517
+ best_align = 0.0
518
+ for key, direction in renderer.slice_directions().items():
519
+ align = float(np.dot(direction, swipe / swipe_len))
520
+ if abs(align) > abs(best_align):
521
+ best_align = align
522
+ face_rot_face = key
523
+ drag_sign = 1.0 if align > 0 else -1.0
524
+ if face_rot_face is not None and abs(best_align) < 0.1:
525
+ face_rot_face = None # too ambiguous, keep waiting
526
+
527
+ if face_rot_face is not None:
528
+ direction = renderer.slice_directions().get(face_rot_face)
529
+ if direction is not None:
530
+ # Project the drag onto the direction that layer
531
+ # actually travels, so the turn keeps following
532
+ # the finger for the whole gesture.
533
+ face_rot_angle = drag_sign * float(np.dot(swipe, direction)) / 2.0
534
+
533
535
  elif not snapping:
534
536
  snap_target_angle = snap_to_nearest_90(face_rot_angle)
535
537
  snap_start_angle = face_rot_angle
@@ -580,7 +582,6 @@ class AREngine:
580
582
  face_rot_angle = 0.0
581
583
  snapping = False
582
584
  active_pointer_3d = None
583
- drag_direction = None
584
585
  state = State.HOLDING
585
586
 
586
587
  # ── Always render the cube at current state ──
@@ -109,40 +109,3 @@ def quat_multiply(q1: np.ndarray, q2: np.ndarray) -> np.ndarray:
109
109
  def snap_to_nearest_90(angle_degrees: float) -> float:
110
110
  """Round to nearest multiple of 90°."""
111
111
  return round(angle_degrees / 90.0) * 90.0
112
-
113
-
114
- def cube_axes_on_screen(q: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
115
- """
116
- Given cube rotation quaternion, return the cube's local X and Y
117
- axes projected to 2D screen space (as unit vectors in pixel-direction).
118
-
119
- Uses the SAME quaternion convention as the renderer: [x, y, z, w].
120
-
121
- Returns (screen_x, screen_y) — each is a 2D numpy array.
122
- """
123
- x, y, z, w = q
124
-
125
- # Rotation matrix columns (local axes in world space)
126
- # Same formula as renderer.py's quat_to_matrix
127
- local_x = np.array([
128
- 1 - 2*y*y - 2*z*z,
129
- 2*x*y + 2*z*w,
130
- 2*x*z - 2*y*w
131
- ])
132
- local_y = np.array([
133
- 2*x*y - 2*z*w,
134
- 1 - 2*x*x - 2*z*z,
135
- 2*y*z + 2*x*w
136
- ])
137
-
138
- # Project to screen: take x,y components, flip y for pixel coords
139
- sx = np.array([local_x[0], -local_x[1]])
140
- sy = np.array([local_y[0], -local_y[1]])
141
-
142
- # Normalize
143
- nx = np.linalg.norm(sx)
144
- ny = np.linalg.norm(sy)
145
- if nx > 1e-6: sx /= nx
146
- if ny > 1e-6: sy /= ny
147
-
148
- return sx, sy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tperm-visor",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "T-PERM - gesture-driven AR Rubik's Cube in your browser. Webcam hand tracking via MediaPipe, 3D cube rendered with OpenGL. Requires Python 3.9+.",
5
5
  "bin": {
6
6
  "tperm-visor": "bin/t-perm.js"