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.
@@ -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())