tperm-visor 1.0.1
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/LICENSE +21 -0
- package/README.md +322 -0
- package/backend/cube/__init__.py +0 -0
- package/backend/cube/renderer.py +397 -0
- package/backend/cube/rubiks.py +260 -0
- package/backend/gesture_engine.py +106 -0
- package/backend/hand_landmarker.task +0 -0
- package/backend/hand_tracker.py +199 -0
- package/backend/hud.py +136 -0
- package/backend/requirements.txt +9 -0
- package/backend/server.py +864 -0
- package/backend/utils/__init__.py +0 -0
- package/backend/utils/smoothing.py +40 -0
- package/backend/utils/transforms.py +148 -0
- package/bin/t-perm.js +148 -0
- package/frontend/css/style.css +330 -0
- package/frontend/index.html +96 -0
- package/frontend/js/app.js +105 -0
- package/package.json +49 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cube/renderer.py
|
|
3
|
+
Renders the Rubik's cube via PyOpenGL into an offscreen framebuffer, then
|
|
4
|
+
composites the cube's bounding box onto the OpenCV camera frame.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from OpenGL.GL import *
|
|
9
|
+
from OpenGL.GLU import *
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from cube.rubiks import FACE_COLORS_GL
|
|
13
|
+
|
|
14
|
+
# ── Geometry Generation ───────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
# Generate 26 physical sub-cubes
|
|
17
|
+
_BLOCK_CENTERS = []
|
|
18
|
+
for x in [-1.0, 0.0, 1.0]:
|
|
19
|
+
for y in [-1.0, 0.0, 1.0]:
|
|
20
|
+
for z in [-1.0, 0.0, 1.0]:
|
|
21
|
+
if x == 0 and y == 0 and z == 0: continue
|
|
22
|
+
_BLOCK_CENTERS.append((x, y, z))
|
|
23
|
+
|
|
24
|
+
# Sticker grid geometry, one entry per face: outward normal, and the grid's
|
|
25
|
+
# "right" and "down" directions as seen from OUTSIDE that face.
|
|
26
|
+
#
|
|
27
|
+
# These must match the sticker index convention in rubiks.py exactly: index 0 is
|
|
28
|
+
# the top-left sticker viewed from outside, running row-major. U is oriented with
|
|
29
|
+
# its row 0 against the B face and row 2 against F; D is the mirror, row 0 against
|
|
30
|
+
# F. Getting U, R or B mirrored here does not show up on a solved cube - every
|
|
31
|
+
# sticker on a face is the same colour - but it silently corrupts every turn,
|
|
32
|
+
# because the renderer then moves stickers somewhere the permutation in rubiks.py
|
|
33
|
+
# did not put them. test_slice_moves.py pins all six faces down.
|
|
34
|
+
_STICKER_SIZE = 0.85
|
|
35
|
+
_FACE_PLANE = 1.51 # distance from cube centre to the sticker plane
|
|
36
|
+
|
|
37
|
+
_V = lambda *a: np.array(a, dtype=float)
|
|
38
|
+
_FACE_AXES = {
|
|
39
|
+
'F': (_V(0, 0, 1), _V(1, 0, 0), _V(0, -1, 0)),
|
|
40
|
+
'B': (_V(0, 0, -1), _V(-1, 0, 0), _V(0, -1, 0)),
|
|
41
|
+
'R': (_V(1, 0, 0), _V(0, 0, -1), _V(0, -1, 0)),
|
|
42
|
+
'L': (_V(-1, 0, 0), _V(0, 0, 1), _V(0, -1, 0)),
|
|
43
|
+
'U': (_V(0, 1, 0), _V(1, 0, 0), _V(0, 0, 1)),
|
|
44
|
+
'D': (_V(0, -1, 0), _V(1, 0, 0), _V(0, 0, -1)),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sticker_quads_for_face(face: str):
|
|
49
|
+
normal, right, down = _FACE_AXES[face]
|
|
50
|
+
half = _STICKER_SIZE / 2.0
|
|
51
|
+
quads = []
|
|
52
|
+
for row in range(3):
|
|
53
|
+
for col in range(3):
|
|
54
|
+
centre = normal * _FACE_PLANE + right * (col - 1) + down * (row - 1)
|
|
55
|
+
quads.append([
|
|
56
|
+
tuple(centre - right * half - down * half),
|
|
57
|
+
tuple(centre + right * half - down * half),
|
|
58
|
+
tuple(centre + right * half + down * half),
|
|
59
|
+
tuple(centre - right * half + down * half),
|
|
60
|
+
])
|
|
61
|
+
return quads
|
|
62
|
+
|
|
63
|
+
_STICKER_QUADS = {face: _sticker_quads_for_face(face) for face in 'UDFBLR'}
|
|
64
|
+
|
|
65
|
+
# Map each of the 54 stickers to its parent block (x,y,z)
|
|
66
|
+
_STICKER_TO_BLOCK = {}
|
|
67
|
+
for face, quads in _STICKER_QUADS.items():
|
|
68
|
+
_STICKER_TO_BLOCK[face] = []
|
|
69
|
+
for q in quads:
|
|
70
|
+
# Calculate center of quad
|
|
71
|
+
cx = sum(v[0] for v in q) / 4.0
|
|
72
|
+
cy = sum(v[1] for v in q) / 4.0
|
|
73
|
+
cz = sum(v[2] for v in q) / 4.0
|
|
74
|
+
bx = round(cx)
|
|
75
|
+
by = round(cy)
|
|
76
|
+
bz = round(cz)
|
|
77
|
+
_STICKER_TO_BLOCK[face].append((bx, by, bz))
|
|
78
|
+
|
|
79
|
+
def _draw_subcube_body(size=0.96):
|
|
80
|
+
"""Draws a black box centered at origin."""
|
|
81
|
+
hs = size / 2.0
|
|
82
|
+
glBegin(GL_QUADS)
|
|
83
|
+
# F
|
|
84
|
+
glNormal3f(0, 0, 1); glVertex3f(-hs, -hs, hs); glVertex3f(hs, -hs, hs); glVertex3f(hs, hs, hs); glVertex3f(-hs, hs, hs)
|
|
85
|
+
# B
|
|
86
|
+
glNormal3f(0, 0, -1); glVertex3f(hs, -hs, -hs); glVertex3f(-hs, -hs, -hs); glVertex3f(-hs, hs, -hs); glVertex3f(hs, hs, -hs)
|
|
87
|
+
# U
|
|
88
|
+
glNormal3f(0, 1, 0); glVertex3f(-hs, hs, hs); glVertex3f(hs, hs, hs); glVertex3f(hs, hs, -hs); glVertex3f(-hs, hs, -hs)
|
|
89
|
+
# D
|
|
90
|
+
glNormal3f(0, -1, 0); glVertex3f(-hs, -hs, -hs); glVertex3f(hs, -hs, -hs); glVertex3f(hs, -hs, hs); glVertex3f(-hs, -hs, hs)
|
|
91
|
+
# R
|
|
92
|
+
glNormal3f(1, 0, 0); glVertex3f(hs, -hs, hs); glVertex3f(hs, -hs, -hs); glVertex3f(hs, hs, -hs); glVertex3f(hs, hs, hs)
|
|
93
|
+
# L
|
|
94
|
+
glNormal3f(-1, 0, 0); glVertex3f(-hs, -hs, -hs); glVertex3f(-hs, -hs, hs); glVertex3f(-hs, hs, hs); glVertex3f(-hs, hs, -hs)
|
|
95
|
+
glEnd()
|
|
96
|
+
|
|
97
|
+
# Corners of a model-space box containing the cube in ANY slice rotation. The
|
|
98
|
+
# furthest geometry is a pointer at (1.6, 1.6, 1.6), norm 2.77; rotating a layer
|
|
99
|
+
# about an axis preserves distance from the origin, so half-extent 2.8 bounds it.
|
|
100
|
+
_BOUND_CORNERS = [(x, y, z) for x in (-2.8, 2.8)
|
|
101
|
+
for y in (-2.8, 2.8)
|
|
102
|
+
for z in (-2.8, 2.8)]
|
|
103
|
+
|
|
104
|
+
_POINTERS_3D = []
|
|
105
|
+
for _x in [-1.6, 0.0, 1.6]:
|
|
106
|
+
for _y in [-1.6, 0.0, 1.6]:
|
|
107
|
+
for _z in [-1.6, 0.0, 1.6]:
|
|
108
|
+
if _x == 0 and _y == 0 and _z == 0: continue
|
|
109
|
+
_POINTERS_3D.append((_x, _y, _z))
|
|
110
|
+
|
|
111
|
+
def _is_in_layer(face_key, bx, by, bz):
|
|
112
|
+
"""Check if a block/pointer belongs to the rotating layer."""
|
|
113
|
+
return (
|
|
114
|
+
(face_key == 'U' and by > 0.5) or (face_key == 'D' and by < -0.5) or
|
|
115
|
+
(face_key == 'R' and bx > 0.5) or (face_key == 'L' and bx < -0.5) or
|
|
116
|
+
(face_key == 'F' and bz > 0.5) or (face_key == 'B' and bz < -0.5) or
|
|
117
|
+
(face_key == 'M' and abs(bx) < 0.5) or # middle column
|
|
118
|
+
(face_key == 'E' and abs(by) < 0.5) # middle row
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
_FACE_NORMALS = {
|
|
122
|
+
'U': (0, 1, 0), 'D': (0, -1, 0), 'F': (0, 0, 1),
|
|
123
|
+
'B': (0, 0, -1), 'R': (1, 0, 0), 'L': (-1, 0, 0),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# The cube move equal to ONE +90 step of this renderer's layer rotation.
|
|
127
|
+
#
|
|
128
|
+
# A layer spins about a world axis, but move names are face-local: the same
|
|
129
|
+
# world rotation about +Y is U for the top layer and D' for the bottom, because
|
|
130
|
+
# D is defined clockwise viewed from below. So +90 means clockwise only for the
|
|
131
|
+
# faces on the positive side of their axis (U, R, F); the rest take the prime.
|
|
132
|
+
# Verified exhaustively in test_slice_moves.py.
|
|
133
|
+
LAYER_TURN_MOVE = {
|
|
134
|
+
'U': 'U', 'R': 'R', 'F': 'F',
|
|
135
|
+
'D': "D'", 'L': "L'", 'B': "B'", 'M': "M'", 'E': "E'",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _get_rotation_axis(face_key):
|
|
140
|
+
"""Return the GL rotation axis for a face."""
|
|
141
|
+
if face_key in ('R', 'L', 'M'):
|
|
142
|
+
return (1, 0, 0)
|
|
143
|
+
elif face_key in ('U', 'D', 'E'):
|
|
144
|
+
return (0, 1, 0)
|
|
145
|
+
elif face_key in ('F', 'B'):
|
|
146
|
+
return (0, 0, 1)
|
|
147
|
+
return (0, 1, 0)
|
|
148
|
+
|
|
149
|
+
class CubeRenderer:
|
|
150
|
+
def __init__(self, frame_w: int, frame_h: int):
|
|
151
|
+
self.w = frame_w
|
|
152
|
+
self.h = frame_h
|
|
153
|
+
self._fbo = None
|
|
154
|
+
self._tex = None
|
|
155
|
+
self._depth_rb = None
|
|
156
|
+
self._ready = False
|
|
157
|
+
self._body_list = None # display list: one cubie body
|
|
158
|
+
self._sticker_base = None # 54 consecutive display lists, one per sticker
|
|
159
|
+
|
|
160
|
+
def init_gl(self):
|
|
161
|
+
self._fbo = glGenFramebuffers(1)
|
|
162
|
+
glBindFramebuffer(GL_FRAMEBUFFER, self._fbo)
|
|
163
|
+
|
|
164
|
+
self._tex = glGenTextures(1)
|
|
165
|
+
glBindTexture(GL_TEXTURE_2D, self._tex)
|
|
166
|
+
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, self.w, self.h, 0, GL_RGBA, GL_UNSIGNED_BYTE, None)
|
|
167
|
+
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
|
|
168
|
+
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
|
|
169
|
+
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, self._tex, 0)
|
|
170
|
+
|
|
171
|
+
self._depth_rb = glGenRenderbuffers(1)
|
|
172
|
+
glBindRenderbuffer(GL_RENDERBUFFER, self._depth_rb)
|
|
173
|
+
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, self.w, self.h)
|
|
174
|
+
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, self._depth_rb)
|
|
175
|
+
|
|
176
|
+
status = glCheckFramebufferStatus(GL_FRAMEBUFFER)
|
|
177
|
+
glBindFramebuffer(GL_FRAMEBUFFER, 0)
|
|
178
|
+
if status == GL_FRAMEBUFFER_COMPLETE:
|
|
179
|
+
self._ready = True
|
|
180
|
+
|
|
181
|
+
glEnable(GL_LIGHTING)
|
|
182
|
+
glEnable(GL_LIGHT0)
|
|
183
|
+
glLightfv(GL_LIGHT0, GL_POSITION, [2.0, 4.0, 3.0, 1.0])
|
|
184
|
+
glLightfv(GL_LIGHT0, GL_DIFFUSE, [0.9, 0.9, 0.9, 1.0])
|
|
185
|
+
glLightfv(GL_LIGHT0, GL_AMBIENT, [0.35, 0.35, 0.35, 1.0])
|
|
186
|
+
glLightfv(GL_LIGHT0, GL_SPECULAR, [0.5, 0.5, 0.5, 1.0])
|
|
187
|
+
glEnable(GL_COLOR_MATERIAL)
|
|
188
|
+
glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
|
|
189
|
+
glEnable(GL_DEPTH_TEST)
|
|
190
|
+
glDepthFunc(GL_LEQUAL)
|
|
191
|
+
glEnable(GL_BLEND)
|
|
192
|
+
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
|
193
|
+
|
|
194
|
+
self._build_display_lists()
|
|
195
|
+
|
|
196
|
+
def _build_display_lists(self):
|
|
197
|
+
"""Bake the static geometry into display lists.
|
|
198
|
+
|
|
199
|
+
Drawn immediately, one frame is ~1210 individual PyOpenGL calls (26 cubie
|
|
200
|
+
bodies at ~32 each, 54 stickers at ~7). Every one acquires the GIL that
|
|
201
|
+
the MediaPipe result callback also needs. The geometry never changes -
|
|
202
|
+
only its colour and the slice transform do - so compile it once and
|
|
203
|
+
replay it with ~294 calls.
|
|
204
|
+
"""
|
|
205
|
+
self._body_list = glGenLists(1)
|
|
206
|
+
glNewList(self._body_list, GL_COMPILE)
|
|
207
|
+
_draw_subcube_body(0.96)
|
|
208
|
+
glEndList()
|
|
209
|
+
|
|
210
|
+
self._sticker_base = glGenLists(54)
|
|
211
|
+
for i, (face, quad) in enumerate(
|
|
212
|
+
(f, q) for f in 'UDFBLR' for q in _STICKER_QUADS[f]
|
|
213
|
+
):
|
|
214
|
+
glNewList(self._sticker_base + i, GL_COMPILE)
|
|
215
|
+
glBegin(GL_QUADS)
|
|
216
|
+
glNormal3f(*_FACE_NORMALS[face])
|
|
217
|
+
for v in quad:
|
|
218
|
+
glVertex3f(*v)
|
|
219
|
+
glEnd()
|
|
220
|
+
glEndList()
|
|
221
|
+
|
|
222
|
+
# Sticker i of face F lives at _sticker_base + _STICKER_LIST_OFFSET[F] + i
|
|
223
|
+
_STICKER_LIST_OFFSET = {face: n * 9 for n, face in enumerate('UDFBLR')}
|
|
224
|
+
|
|
225
|
+
def render(self, cv_frame: np.ndarray, cube_state: dict, cube_pos_px: np.ndarray,
|
|
226
|
+
cube_rotation_q: np.ndarray, cube_scale: float = 1.0,
|
|
227
|
+
face_rotating: Optional[tuple] = None,
|
|
228
|
+
highlighted_pointer: Optional[tuple] = None) -> tuple[np.ndarray, dict]:
|
|
229
|
+
if not self._ready:
|
|
230
|
+
return cv_frame, {}
|
|
231
|
+
|
|
232
|
+
h, w = cv_frame.shape[:2]
|
|
233
|
+
glBindFramebuffer(GL_FRAMEBUFFER, self._fbo)
|
|
234
|
+
glViewport(0, 0, w, h)
|
|
235
|
+
glClearColor(0, 0, 0, 0)
|
|
236
|
+
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
|
|
237
|
+
|
|
238
|
+
glMatrixMode(GL_PROJECTION)
|
|
239
|
+
glLoadIdentity()
|
|
240
|
+
gluPerspective(45, w / h, 0.1, 100.0)
|
|
241
|
+
|
|
242
|
+
glMatrixMode(GL_MODELVIEW)
|
|
243
|
+
glLoadIdentity()
|
|
244
|
+
|
|
245
|
+
cx_px, cy_px = cube_pos_px
|
|
246
|
+
z_dist = 5.0
|
|
247
|
+
fov_y_rad = np.radians(45.0)
|
|
248
|
+
plane_h = 2.0 * z_dist * np.tan(fov_y_rad / 2.0)
|
|
249
|
+
plane_w = plane_h * (w / h)
|
|
250
|
+
cx_ndc = (cx_px / w) * 2.0 - 1.0
|
|
251
|
+
cy_ndc = 1.0 - (cy_px / h) * 2.0
|
|
252
|
+
tx = cx_ndc * (plane_w / 2.0)
|
|
253
|
+
ty = cy_ndc * (plane_h / 2.0)
|
|
254
|
+
tz = -z_dist
|
|
255
|
+
|
|
256
|
+
glTranslatef(tx, ty, tz)
|
|
257
|
+
|
|
258
|
+
base_scale = 0.35 * cube_scale
|
|
259
|
+
glScalef(base_scale, base_scale, base_scale)
|
|
260
|
+
|
|
261
|
+
def quat_to_matrix(q):
|
|
262
|
+
w, x, y, z = q
|
|
263
|
+
return np.array([
|
|
264
|
+
[1 - 2*y*y - 2*z*z, 2*x*y - 2*z*w, 2*x*z + 2*y*w, 0],
|
|
265
|
+
[2*x*y + 2*z*w, 1 - 2*x*x - 2*z*z, 2*y*z - 2*x*w, 0],
|
|
266
|
+
[2*x*z - 2*y*w, 2*y*z + 2*x*w, 1 - 2*x*x - 2*y*y, 0],
|
|
267
|
+
[0, 0, 0, 1]
|
|
268
|
+
], dtype=np.float32).T
|
|
269
|
+
|
|
270
|
+
mat = quat_to_matrix(cube_rotation_q)
|
|
271
|
+
glMultMatrixf(mat)
|
|
272
|
+
|
|
273
|
+
rot_face, rot_angle = face_rotating if face_rotating else (None, 0.0)
|
|
274
|
+
rot_axis = _get_rotation_axis(rot_face) if rot_face else None
|
|
275
|
+
|
|
276
|
+
# Draw the 26 blocks
|
|
277
|
+
glColor4f(0.05, 0.05, 0.05, 1.0)
|
|
278
|
+
for block in _BLOCK_CENTERS:
|
|
279
|
+
glPushMatrix()
|
|
280
|
+
if rot_face and _is_in_layer(rot_face, *block):
|
|
281
|
+
glRotatef(-rot_angle, *rot_axis)
|
|
282
|
+
glTranslatef(*block)
|
|
283
|
+
glCallList(self._body_list)
|
|
284
|
+
glPopMatrix()
|
|
285
|
+
|
|
286
|
+
# Draw Stickers
|
|
287
|
+
for face_key, quads in _STICKER_QUADS.items():
|
|
288
|
+
colors = cube_state[face_key]
|
|
289
|
+
block_map = _STICKER_TO_BLOCK[face_key]
|
|
290
|
+
list_base = self._sticker_base + self._STICKER_LIST_OFFSET[face_key]
|
|
291
|
+
for i in range(len(quads)):
|
|
292
|
+
glPushMatrix()
|
|
293
|
+
if rot_face and _is_in_layer(rot_face, *block_map[i]):
|
|
294
|
+
glRotatef(-rot_angle, *rot_axis)
|
|
295
|
+
glColor4f(*FACE_COLORS_GL[colors[i]], 1.0)
|
|
296
|
+
glCallList(list_base + i)
|
|
297
|
+
glPopMatrix()
|
|
298
|
+
|
|
299
|
+
# Draw and project pointers
|
|
300
|
+
pointers_2d = {}
|
|
301
|
+
modelview = glGetDoublev(GL_MODELVIEW_MATRIX)
|
|
302
|
+
projection = glGetDoublev(GL_PROJECTION_MATRIX)
|
|
303
|
+
viewport = glGetIntegerv(GL_VIEWPORT)
|
|
304
|
+
|
|
305
|
+
glDisable(GL_LIGHTING)
|
|
306
|
+
glDisable(GL_DEPTH_TEST)
|
|
307
|
+
for p3d in _POINTERS_3D:
|
|
308
|
+
# Highlight the grabbed pointer in red, others white
|
|
309
|
+
if highlighted_pointer and p3d == highlighted_pointer:
|
|
310
|
+
glColor4f(1.0, 0.15, 0.15, 1.0) # bright red
|
|
311
|
+
glPointSize(12.0)
|
|
312
|
+
else:
|
|
313
|
+
glColor4f(1.0, 1.0, 1.0, 0.9)
|
|
314
|
+
glPointSize(6.0)
|
|
315
|
+
# We must apply slice rotation to pointers too!
|
|
316
|
+
glPushMatrix()
|
|
317
|
+
if face_rotating:
|
|
318
|
+
rot_face, angle_deg = face_rotating
|
|
319
|
+
bx, by, bz = p3d
|
|
320
|
+
if _is_in_layer(rot_face, bx, by, bz):
|
|
321
|
+
ax = _get_rotation_axis(rot_face)
|
|
322
|
+
glRotatef(-angle_deg, *ax)
|
|
323
|
+
glBegin(GL_POINTS)
|
|
324
|
+
glVertex3f(*p3d)
|
|
325
|
+
glEnd()
|
|
326
|
+
glPopMatrix()
|
|
327
|
+
glEnable(GL_DEPTH_TEST)
|
|
328
|
+
glEnable(GL_LIGHTING)
|
|
329
|
+
|
|
330
|
+
for p3d in _POINTERS_3D:
|
|
331
|
+
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
|
+
win_x, win_y, win_z = gluProject(p3d[0], p3d[1], p3d[2], modelview, projection, viewport)
|
|
336
|
+
if 0 <= win_z <= 1:
|
|
337
|
+
cv_y = h - win_y
|
|
338
|
+
pointers_2d[p3d] = (win_x, cv_y)
|
|
339
|
+
except Exception:
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
# Read back only the cube's bounding box, not the whole frame. A full 720p
|
|
343
|
+
# RGBA readback is 3.7 MB across the bus every tick and dominates the
|
|
344
|
+
# frame; the cube usually covers a few percent of the screen.
|
|
345
|
+
xs, ys = [], []
|
|
346
|
+
for c in _BOUND_CORNERS:
|
|
347
|
+
try:
|
|
348
|
+
wx, wy, _ = gluProject(c[0], c[1], c[2], modelview, projection, viewport)
|
|
349
|
+
except Exception:
|
|
350
|
+
xs = []
|
|
351
|
+
break
|
|
352
|
+
xs.append(wx)
|
|
353
|
+
ys.append(wy)
|
|
354
|
+
|
|
355
|
+
if xs:
|
|
356
|
+
x0 = max(0, int(np.floor(min(xs))))
|
|
357
|
+
x1 = min(w, int(np.ceil(max(xs))) + 1)
|
|
358
|
+
y0 = max(0, int(np.floor(min(ys)))) # GL coords, origin bottom-left
|
|
359
|
+
y1 = min(h, int(np.ceil(max(ys))) + 1)
|
|
360
|
+
else: # projection failed - be safe
|
|
361
|
+
x0, y0, x1, y1 = 0, 0, w, h
|
|
362
|
+
|
|
363
|
+
if x1 <= x0 or y1 <= y0:
|
|
364
|
+
glBindFramebuffer(GL_FRAMEBUFFER, 0)
|
|
365
|
+
return cv_frame, pointers_2d # cube entirely off-screen
|
|
366
|
+
|
|
367
|
+
bw, bh = x1 - x0, y1 - y0
|
|
368
|
+
pixels = glReadPixels(x0, y0, bw, bh, GL_BGRA, GL_UNSIGNED_BYTE)
|
|
369
|
+
glBindFramebuffer(GL_FRAMEBUFFER, 0)
|
|
370
|
+
|
|
371
|
+
# GL's origin is bottom-left, OpenCV's top-left. ::-1 is a reverse-strided
|
|
372
|
+
# view, so it flips without copying like cv2.flip did.
|
|
373
|
+
gl_img = np.frombuffer(pixels, dtype=np.uint8).reshape(bh, bw, 4)[::-1]
|
|
374
|
+
roi = cv_frame[h - y1:h - y0, x0:x1]
|
|
375
|
+
|
|
376
|
+
# `where=` broadcasts the mask over the channel axis, so there is no
|
|
377
|
+
# 3-channel temporary, and no np.any() pre-scan of the whole frame.
|
|
378
|
+
np.copyto(roi, gl_img[:, :, :3], where=gl_img[:, :, 3:4] > 0)
|
|
379
|
+
|
|
380
|
+
return cv_frame, pointers_2d
|
|
381
|
+
|
|
382
|
+
def cleanup(self):
|
|
383
|
+
if self._body_list:
|
|
384
|
+
glDeleteLists(self._body_list, 1)
|
|
385
|
+
self._body_list = None
|
|
386
|
+
if self._sticker_base:
|
|
387
|
+
glDeleteLists(self._sticker_base, 54)
|
|
388
|
+
self._sticker_base = None
|
|
389
|
+
if self._fbo:
|
|
390
|
+
glDeleteFramebuffers(1, [self._fbo])
|
|
391
|
+
self._fbo = None
|
|
392
|
+
if self._tex:
|
|
393
|
+
glDeleteTextures(1, [self._tex])
|
|
394
|
+
self._tex = None
|
|
395
|
+
if self._depth_rb:
|
|
396
|
+
glDeleteRenderbuffers(1, [self._depth_rb])
|
|
397
|
+
self._depth_rb = None
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cube/rubiks.py
|
|
3
|
+
3×3 Rubik's cube state — 6 faces × 9 stickers, all 18 standard moves.
|
|
4
|
+
Pure functions: state in → state out. Immutable per move.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import random
|
|
9
|
+
|
|
10
|
+
# Face colour constants (OpenGL RGB floats, also used for CV drawing)
|
|
11
|
+
FACE_COLORS_GL = {
|
|
12
|
+
'U': (1.00, 1.00, 1.00), # white — top
|
|
13
|
+
'D': (1.00, 1.00, 0.00), # yellow — bottom
|
|
14
|
+
'F': (1.00, 0.50, 0.00), # orange — front
|
|
15
|
+
'B': (0.90, 0.10, 0.10), # red — back
|
|
16
|
+
'L': (0.10, 0.45, 1.00), # blue — left
|
|
17
|
+
'R': (0.10, 0.80, 0.10), # green — right
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
FACES = ['U', 'D', 'F', 'B', 'L', 'R']
|
|
21
|
+
|
|
22
|
+
# Sticker indices layout (face viewed from outside, row-major):
|
|
23
|
+
# 0 1 2
|
|
24
|
+
# 3 4 5
|
|
25
|
+
# 6 7 8
|
|
26
|
+
# [4] is always the centre (fixed colour)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def solved_state() -> dict:
|
|
30
|
+
"""Return a fully solved cube state."""
|
|
31
|
+
return {face: [face] * 9 for face in FACES}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _rotate_face_cw(state: dict, face: str) -> dict:
|
|
35
|
+
"""Rotate a single face clockwise (sticker rearrangement only, no adjacents)."""
|
|
36
|
+
s = state[face]
|
|
37
|
+
state[face] = [s[6], s[3], s[0],
|
|
38
|
+
s[7], s[4], s[1],
|
|
39
|
+
s[8], s[5], s[2]]
|
|
40
|
+
return state
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _rotate_face_ccw(state: dict, face: str) -> dict:
|
|
44
|
+
s = state[face]
|
|
45
|
+
state[face] = [s[2], s[5], s[8],
|
|
46
|
+
s[1], s[4], s[7],
|
|
47
|
+
s[0], s[3], s[6]]
|
|
48
|
+
return state
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Move implementations ──────────────────────────────────────────────────────
|
|
52
|
+
# Each move: deepcopy → transform → return
|
|
53
|
+
|
|
54
|
+
def _apply_U(s):
|
|
55
|
+
s = copy.deepcopy(s)
|
|
56
|
+
s = _rotate_face_cw(s, 'U')
|
|
57
|
+
t = s['F'][:3]
|
|
58
|
+
s['F'][:3] = s['R'][:3]
|
|
59
|
+
s['R'][:3] = s['B'][:3]
|
|
60
|
+
s['B'][:3] = s['L'][:3]
|
|
61
|
+
s['L'][:3] = t
|
|
62
|
+
return s
|
|
63
|
+
|
|
64
|
+
def _apply_U_prime(s):
|
|
65
|
+
s = copy.deepcopy(s)
|
|
66
|
+
s = _rotate_face_ccw(s, 'U')
|
|
67
|
+
t = s['F'][:3]
|
|
68
|
+
s['F'][:3] = s['L'][:3]
|
|
69
|
+
s['L'][:3] = s['B'][:3]
|
|
70
|
+
s['B'][:3] = s['R'][:3]
|
|
71
|
+
s['R'][:3] = t
|
|
72
|
+
return s
|
|
73
|
+
|
|
74
|
+
def _apply_D(s):
|
|
75
|
+
s = copy.deepcopy(s)
|
|
76
|
+
s = _rotate_face_cw(s, 'D')
|
|
77
|
+
t = s['F'][6:]
|
|
78
|
+
s['F'][6:] = s['L'][6:]
|
|
79
|
+
s['L'][6:] = s['B'][6:]
|
|
80
|
+
s['B'][6:] = s['R'][6:]
|
|
81
|
+
s['R'][6:] = t
|
|
82
|
+
return s
|
|
83
|
+
|
|
84
|
+
def _apply_D_prime(s):
|
|
85
|
+
s = copy.deepcopy(s)
|
|
86
|
+
s = _rotate_face_ccw(s, 'D')
|
|
87
|
+
t = s['F'][6:]
|
|
88
|
+
s['F'][6:] = s['R'][6:]
|
|
89
|
+
s['R'][6:] = s['B'][6:]
|
|
90
|
+
s['B'][6:] = s['L'][6:]
|
|
91
|
+
s['L'][6:] = t
|
|
92
|
+
return s
|
|
93
|
+
|
|
94
|
+
def _col(face_arr, c):
|
|
95
|
+
return [face_arr[c], face_arr[c+3], face_arr[c+6]]
|
|
96
|
+
|
|
97
|
+
def _set_col(face_arr, c, vals):
|
|
98
|
+
face_arr[c], face_arr[c+3], face_arr[c+6] = vals
|
|
99
|
+
|
|
100
|
+
def _apply_R(s):
|
|
101
|
+
s = copy.deepcopy(s)
|
|
102
|
+
s = _rotate_face_cw(s, 'R')
|
|
103
|
+
t = _col(s['F'], 2)
|
|
104
|
+
_set_col(s['F'], 2, _col(s['D'], 2))
|
|
105
|
+
_set_col(s['D'], 2, list(reversed(_col(s['B'], 0))))
|
|
106
|
+
_set_col(s['B'], 0, list(reversed(_col(s['U'], 2))))
|
|
107
|
+
_set_col(s['U'], 2, t)
|
|
108
|
+
return s
|
|
109
|
+
|
|
110
|
+
def _apply_R_prime(s):
|
|
111
|
+
s = copy.deepcopy(s)
|
|
112
|
+
s = _rotate_face_ccw(s, 'R')
|
|
113
|
+
t = _col(s['F'], 2)
|
|
114
|
+
_set_col(s['F'], 2, _col(s['U'], 2))
|
|
115
|
+
_set_col(s['U'], 2, list(reversed(_col(s['B'], 0))))
|
|
116
|
+
_set_col(s['B'], 0, list(reversed(_col(s['D'], 2))))
|
|
117
|
+
_set_col(s['D'], 2, t)
|
|
118
|
+
return s
|
|
119
|
+
|
|
120
|
+
def _apply_L(s):
|
|
121
|
+
s = copy.deepcopy(s)
|
|
122
|
+
s = _rotate_face_cw(s, 'L')
|
|
123
|
+
t = _col(s['F'], 0)
|
|
124
|
+
_set_col(s['F'], 0, _col(s['U'], 0))
|
|
125
|
+
_set_col(s['U'], 0, list(reversed(_col(s['B'], 2))))
|
|
126
|
+
_set_col(s['B'], 2, list(reversed(_col(s['D'], 0))))
|
|
127
|
+
_set_col(s['D'], 0, t)
|
|
128
|
+
return s
|
|
129
|
+
|
|
130
|
+
def _apply_L_prime(s):
|
|
131
|
+
s = copy.deepcopy(s)
|
|
132
|
+
s = _rotate_face_ccw(s, 'L')
|
|
133
|
+
t = _col(s['F'], 0)
|
|
134
|
+
_set_col(s['F'], 0, _col(s['D'], 0))
|
|
135
|
+
_set_col(s['D'], 0, list(reversed(_col(s['B'], 2))))
|
|
136
|
+
_set_col(s['B'], 2, list(reversed(_col(s['U'], 0))))
|
|
137
|
+
_set_col(s['U'], 0, t)
|
|
138
|
+
return s
|
|
139
|
+
|
|
140
|
+
def _apply_F(s):
|
|
141
|
+
s = copy.deepcopy(s)
|
|
142
|
+
s = _rotate_face_cw(s, 'F')
|
|
143
|
+
t = s['U'][6:]
|
|
144
|
+
s['U'][6:] = list(reversed(_col(s['L'], 2)))
|
|
145
|
+
_set_col(s['L'], 2, s['D'][:3])
|
|
146
|
+
s['D'][:3] = list(reversed(_col(s['R'], 0)))
|
|
147
|
+
_set_col(s['R'], 0, t)
|
|
148
|
+
return s
|
|
149
|
+
|
|
150
|
+
def _apply_F_prime(s):
|
|
151
|
+
s = copy.deepcopy(s)
|
|
152
|
+
s = _rotate_face_ccw(s, 'F')
|
|
153
|
+
t = s['U'][6:]
|
|
154
|
+
s['U'][6:] = _col(s['R'], 0)
|
|
155
|
+
_set_col(s['R'], 0, list(reversed(s['D'][:3])))
|
|
156
|
+
s['D'][:3] = _col(s['L'], 2)
|
|
157
|
+
_set_col(s['L'], 2, list(reversed(t)))
|
|
158
|
+
return s
|
|
159
|
+
|
|
160
|
+
def _apply_B(s):
|
|
161
|
+
s = copy.deepcopy(s)
|
|
162
|
+
s = _rotate_face_cw(s, 'B')
|
|
163
|
+
t = s['U'][:3]
|
|
164
|
+
s['U'][:3] = _col(s['R'], 2)
|
|
165
|
+
_set_col(s['R'], 2, list(reversed(s['D'][6:])))
|
|
166
|
+
s['D'][6:] = _col(s['L'], 0)
|
|
167
|
+
_set_col(s['L'], 0, list(reversed(t)))
|
|
168
|
+
return s
|
|
169
|
+
|
|
170
|
+
def _apply_B_prime(s):
|
|
171
|
+
s = copy.deepcopy(s)
|
|
172
|
+
s = _rotate_face_ccw(s, 'B')
|
|
173
|
+
t = s['U'][:3]
|
|
174
|
+
s['U'][:3] = list(reversed(_col(s['L'], 0)))
|
|
175
|
+
_set_col(s['L'], 0, s['D'][6:])
|
|
176
|
+
s['D'][6:] = list(reversed(_col(s['R'], 2)))
|
|
177
|
+
_set_col(s['R'], 2, t)
|
|
178
|
+
return s
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _apply_M(s):
|
|
182
|
+
"""Middle layer between L and R (follows L direction: top→front→bottom→back)."""
|
|
183
|
+
s = copy.deepcopy(s)
|
|
184
|
+
t = _col(s['F'], 1)
|
|
185
|
+
_set_col(s['F'], 1, _col(s['U'], 1))
|
|
186
|
+
_set_col(s['U'], 1, list(reversed(_col(s['B'], 1))))
|
|
187
|
+
_set_col(s['B'], 1, list(reversed(_col(s['D'], 1))))
|
|
188
|
+
_set_col(s['D'], 1, t)
|
|
189
|
+
return s
|
|
190
|
+
|
|
191
|
+
def _apply_M_prime(s):
|
|
192
|
+
s = copy.deepcopy(s)
|
|
193
|
+
t = _col(s['F'], 1)
|
|
194
|
+
_set_col(s['F'], 1, _col(s['D'], 1))
|
|
195
|
+
_set_col(s['D'], 1, list(reversed(_col(s['B'], 1))))
|
|
196
|
+
_set_col(s['B'], 1, list(reversed(_col(s['U'], 1))))
|
|
197
|
+
_set_col(s['U'], 1, t)
|
|
198
|
+
return s
|
|
199
|
+
|
|
200
|
+
def _apply_E(s):
|
|
201
|
+
"""Middle layer between U and D (follows D direction: front→left→back→right)."""
|
|
202
|
+
s = copy.deepcopy(s)
|
|
203
|
+
t = s['F'][3:6]
|
|
204
|
+
s['F'][3:6] = s['L'][3:6]
|
|
205
|
+
s['L'][3:6] = s['B'][3:6]
|
|
206
|
+
s['B'][3:6] = s['R'][3:6]
|
|
207
|
+
s['R'][3:6] = t
|
|
208
|
+
return s
|
|
209
|
+
|
|
210
|
+
def _apply_E_prime(s):
|
|
211
|
+
s = copy.deepcopy(s)
|
|
212
|
+
t = s['F'][3:6]
|
|
213
|
+
s['F'][3:6] = s['R'][3:6]
|
|
214
|
+
s['R'][3:6] = s['B'][3:6]
|
|
215
|
+
s['B'][3:6] = s['L'][3:6]
|
|
216
|
+
s['L'][3:6] = t
|
|
217
|
+
return s
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
MOVES = {
|
|
221
|
+
'U': _apply_U, "U'": _apply_U_prime,
|
|
222
|
+
'U2': lambda s: _apply_U(_apply_U(s)),
|
|
223
|
+
'D': _apply_D, "D'": _apply_D_prime,
|
|
224
|
+
'D2': lambda s: _apply_D(_apply_D(s)),
|
|
225
|
+
'R': _apply_R, "R'": _apply_R_prime,
|
|
226
|
+
'R2': lambda s: _apply_R(_apply_R(s)),
|
|
227
|
+
'L': _apply_L, "L'": _apply_L_prime,
|
|
228
|
+
'L2': lambda s: _apply_L(_apply_L(s)),
|
|
229
|
+
'F': _apply_F, "F'": _apply_F_prime,
|
|
230
|
+
'F2': lambda s: _apply_F(_apply_F(s)),
|
|
231
|
+
'B': _apply_B, "B'": _apply_B_prime,
|
|
232
|
+
'B2': lambda s: _apply_B(_apply_B(s)),
|
|
233
|
+
'M': _apply_M, "M'": _apply_M_prime,
|
|
234
|
+
'E': _apply_E, "E'": _apply_E_prime,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
ALL_MOVES = list(MOVES.keys())
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def apply_move(state: dict, move: str) -> dict:
|
|
241
|
+
if move not in MOVES:
|
|
242
|
+
raise ValueError(f"Unknown move: {move}")
|
|
243
|
+
return MOVES[move](state)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def scramble(state: dict, n: int = 20) -> tuple[dict, list[str]]:
|
|
247
|
+
"""Apply n random moves. Returns (new_state, move_sequence)."""
|
|
248
|
+
seq = []
|
|
249
|
+
prev = None
|
|
250
|
+
for _ in range(n):
|
|
251
|
+
choices = [m for m in ALL_MOVES if m[0] != (prev[0] if prev else None)]
|
|
252
|
+
move = random.choice(choices)
|
|
253
|
+
state = apply_move(state, move)
|
|
254
|
+
seq.append(move)
|
|
255
|
+
prev = move
|
|
256
|
+
return state, seq
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def is_solved(state: dict) -> bool:
|
|
260
|
+
return all(len(set(stickers)) == 1 for stickers in state.values())
|