2 ### -*- coding: utf-8 -*-
4 ### Rubik cube model and rendering
6 ### (c) 2018 Mark Wooding
9 ###----- Licensing notice ---------------------------------------------------
11 ### This program is free software; you can redistribute it and/or modify
12 ### it under the terms of the GNU General Public License as published by
13 ### the Free Software Foundation; either version 2 of the License, or
14 ### (at your option) any later version.
16 ### This program is distributed in the hope that it will be useful,
17 ### but WITHOUT ANY WARRANTY; without even the implied warranty of
18 ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 ### GNU General Public License for more details.
21 ### You should have received a copy of the GNU General Public License
22 ### along with this program; if not, write to the Free Software Foundation,
23 ### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
25 ###--------------------------------------------------------------------------
26 ### External dependencies.
28 import math as M; TAU = 2*M.pi
35 ###--------------------------------------------------------------------------
36 ### The main cube model.
38 ## Names for the faces and corresponding colours.
39 U, D, L, R, F, B, UNK = 0, 1, 2, 3, 4, 5, 6
40 FACE = ['U', 'D', 'L', 'R', 'F', 'B']
45 ## Colours of the faces.
46 COLOUR = ['#', '+', '%', '/', '=', '-', 'ยท']
47 RGB = [(1, 0, 0), (1, 0.75, 0),
51 CMAP = { 'r': U, 'o': D, 'y': L, 'w': R, 'b': F, 'g': B, '?': UNK }
55 ## For 0 <= k < 6 and 0 <= d < 4, GEOM[k][d] holds a triple (k', ix, iy) with
56 ## the following meaning. If you start at face k, move in the direction d
57 ## (up, down, left, or right), and treat the edge that you crossed as the x
58 ## axis of a right-handed coordinate system, increasing clockwise, then you
59 ## are on face k', and (ix, iy) is a unit vector in the coordinate system of
60 ## the state array pointing in the positive x direction.
61 GEOM = [[(B, -1, 0), (F, -1, 0), (L, -1, 0), (R, -1, 0)],
62 [(F, +1, 0), (B, +1, 0), (L, +1, 0), (R, +1, 0)],
63 [(U, 0, -1), (D, 0, -1), (B, 0, +1), (F, 0, -1)],
64 [(U, 0, +1), (D, 0, +1), (F, 0, +1), (B, 0, -1)],
65 [(U, +1, 0), (D, -1, 0), (L, 0, +1), (R, 0, -1)],
66 [(U, -1, 0), (D, +1, 0), (R, 0, +1), (L, 0, -1)]]
68 def convert_side_coords(w, orig, face, x, y):
70 Convert relative coordinates (X, Y) on a side face into raw coordinates.
72 Suppose we start on face ORIG and move to the adjacent FACE (one of `U',
73 `D', `L', `R'), treating the edge we crossed as the x-axis of a right-hand
74 coordinate system, increasing clockwise; return a triple (K, U, V), such
75 that m[K][U][V] identifies the slot in the model corresponding to the given
76 coordinates. The argument W is the side length of the cube.
78 k, ix, iy = GEOM[orig][face]
80 if ix < 0 or jx < 0: ox = w - 1
82 if iy < 0 or jy < 0: oy = w - 1
84 return k, ox + x*ix + y*jx, oy + x*iy + y*jy
86 def slice_orbits(w, face, dp):
88 Return the effect of a slice turn on the side stickers.
90 Returns a list of lists of (K, X, Y) model coordinates describing the cycle
91 decomposition of the permutation induced on the side stickers of a
92 clockwise turn of the DPth slice of a size-W cube as viewed from FACE.
96 oo.append([convert_side_coords(w, face, k, x, dp)
97 for k in [U, R, D, L]])
100 def face_orbits(w, face, inv):
102 Return the effect of a face turn on the face's stickers.
104 Returns a list of lists of (K, X, Y) model coordinates describing the cycle
105 decomposition of the permutation induced on the face stickers of a
106 clockwise (or anticlockwise, if INV is true) turn of a FACE of a size-W
109 if inv: ta, tb, tc, td, dx, dy = 0, -1, 1, 0, w - 1, 0
110 else: ta, tb, tc, td, dx, dy = 0, 1, -1, 0, 0, w - 1
112 for x in xrange((w + 1)//2, w):
113 for y in xrange(w//2, x + 1):
117 o.append((face, u, v))
118 u, v = ta*u + tb*v + dx, tc*u + td*v + dy
122 def orbits(w, face, dp):
124 Return the effect of a slice turn on the cube's stickers.
126 Returns a list of lists of (K, X, Y) model coordinates describing the cycle
127 decompositon of the permutation induced on the cube's stickers of a
128 clockwise turn of the DPth slice of a size-W cube, as viewed from FACE.
130 oo = slice_orbits(w, face, dp)
131 if dp == 0: oo += face_orbits(w, face, False)
132 elif dp == w - 1: oo += face_orbits(w, face ^ 1, True)
135 def maybe_parens(parens, s):
136 """If S, then return the string `(S)'; otherwise just return S."""
137 if parens: return '(' + s + ')'
140 class BaseMove (object):
142 Base class for move objects.
144 Subclasses must implement the following methods.
146 * apply(PUZ): apply the move to a puzzle PUZ
147 * invert(): return the inverse of the move
148 * _str(lv): return a string representation, in a priority-LV context
151 """Return the move repeated M times."""
152 if m == 0: return NULLMOVE
153 else: return RepeatedMove(me, m)
155 """Return a list of constituent moves."""
158 """Return a string representation of the move."""
161 class NullMove (BaseMove):
162 """The null move, which does nothing at all."""
164 """Apply the move to the puzzle PUZ."""
167 """Return the inverse of the move."""
170 """Return the move repeated M times."""
173 """Return a list of constituent moves."""
176 """Return a string representation, in a priority-LV context."""
178 NULLMOVE = NullMove()
180 class AtomicMove (BaseMove):
181 """A turn of a single slice."""
184 for k in xrange(NFACE):
185 MAP[FACE[k]] = (k, 0)
186 MAP[FACE[k].lower()] = (k, 1)
188 def __init__(me, face, dp = 0, n = 1):
190 Construct an atomic move.
192 Returns a move representing N clockwise turns of the DPth slice, as
199 """Apply the move to the puzzle PUZ."""
200 if me.dp >= 0: puz.turn(me.face, me.dp, me.n)
201 else: puz.turn_multi(me.face, -me.dp, me.n)
203 """Return the inverse of the move."""
204 return AtomicMove(me.face, me.dp, -me.n)
206 """Return the move repeated M times."""
208 if not mm: return NULLMOVE
209 else: return AtomicMove(me.face, me.dp, mm)
211 """Return a string representation, in a priority-LV context."""
213 if me.n%4 == 0: d = '0'
214 elif me.n%4 == 1: d = ''
215 elif me.n%4 == 2: d = '2'
216 elif me.n%4 == 3: d = "'"
217 if me.dp == 0: return f + d
218 elif me.dp == 1: return f.lower() + d
219 elif me.dp < 0: return '%s%s_%d' % (f, d, -me.dp)
221 class SpinMove (BaseMove):
222 """A spin of the entire cube."""
223 MAP = { 'X': R, 'Y': U, 'Z': F }
225 for k, v in MAP.iteritems(): UNMAP[v] = k
227 def __init__(me, face, n = 1):
228 """Construct a spin move.
230 Returns a move representing N clockwise spins the entire cube DPth slice,
236 """Apply the move to the puzzle PUZ."""
237 puz.turn_multi(me.face, puz.w, me.n)
239 """Return the inverse of the move."""
240 return SpinMove(me.face, -me.n)
242 """Return the move repeated M times."""
244 if not mm: return NULLMOVE
245 else: return SpinMove(me.face, mm)
247 """Return a string representation, in a priority-LV context."""
249 if me.n%4 == 0: d = '0'
250 elif me.n%4 == 1: d = ''
251 elif me.n%4 == 2: d = '2'
252 elif me.n%4 == 3: d = "'"
255 class MoveSequence (BaseMove):
256 """A sequence of moves, applied in left-to-right order."""
257 def __init__(me, moves):
258 """Construct a sequence of moves from the list MOVES."""
259 me.moves = reduce(lambda x, y: x + y, [mv.flatten() for mv in moves])
261 """Apply the move to the puzzle PUZ."""
262 for mv in me.moves: mv.apply(puz)
264 """Return the inverse of the move."""
265 return MoveSequence(list(reversed([mv.invert() for mv in me.moves])))
267 """Return a string representation, in a priority-LV context."""
268 return maybe_parens(lv < 5, ' '.join([mv._str(5) for mv in me.moves]))
270 class RepeatedMove (BaseMove):
271 """A move applied multiple times."""
272 def __init__(me, move, m):
273 """Construct the move which consists of MOVE repeated M times."""
277 """Apply the move to the puzzle PUZ."""
278 for i in xrange(me.m): me.move.apply(puz)
280 """Return the inverse of the move."""
281 return RepeatedMove(me.move.invert(), me.m)
283 """Return the move repeated M times."""
284 if m == 0: return NULLMOVE
285 else: return RepeatedMove(me.move, m*me.m)
287 """Return a string representation, in a priority-LV context."""
288 return maybe_parens(lv < 3, '%s%d' % (me.move._str(2), me.m))
290 def skip_spaces(s, i):
291 """Return the smallest index J >= I such that S[J] is not whitespace."""
292 while i < len(s) and s[i].isspace(): i += 1
295 def parse_number(s, i):
297 Parse an integer from S, starting at index I.
299 The substring S[I:] consists of zero or more digits, optionally followed by
300 a nondigit character and any other characters. Convert the digit sequence
301 into an integer N and return the pair (J, N), where J is the index of the
302 first non-whitespace character following the digit sequence, or the length
303 of S if there is no such character.
306 while i < len(s) and s[i].isdigit():
307 n = 10*n + ord(s[i]) - ord('0')
309 i = skip_spaces(s, i)
312 def parse_parens(s, i, delim):
313 i = skip_spaces(s, i)
314 i, mv = parse_conjugate(s, i)
315 if s[i] != delim: raise SyntaxError('missing %s' % delim)
316 i = skip_spaces(s, i + 1)
319 def parse_primary(s, i):
320 if i >= len(s): return i, None
321 elif s[i] == '(': return parse_parens(s, i + 1, ')')
322 elif s[i] == '[': return parse_parens(s, i + 1, ']')
323 elif s[i] in SpinMove.MAP:
324 k = SpinMove.MAP[s[i]]
325 i = skip_spaces(s, i + 1)
326 return i, SpinMove(k, 1)
327 elif s[i] in AtomicMove.MAP:
328 k, dp = AtomicMove.MAP[s[i]]
329 if dp == 0 and i + 2 < len(s) and s[i + 1] == '_' and s[i + 2].isdigit():
330 i, n = parse_number(s, i + 2)
334 i = skip_spaces(s, i + 1)
335 mv = AtomicMove(k, dp, 1)
339 def parse_move(s, i):
340 i, mv = parse_primary(s, i)
341 if mv is None: return i, None
343 if i < len(s) and s[i] == "'":
344 i = skip_spaces(s, i + 1)
346 elif i < len(s) and s[i].isdigit():
347 i, m = parse_number(s, i)
349 elif i + 1 < len(s) and s[i] == '^' and s[i + 1].isdigit():
350 i, m = parse_number(s, i + 1)
356 def parse_sequence(s, i):
359 i, mv = parse_move(s, i)
362 if len(seq) == 0: raise SyntaxError('unrecognized move')
363 elif len(seq) == 1: return i, seq[0]
364 else: return i, MoveSequence(seq)
366 def parse_commutator(s, i):
367 i, mv = parse_sequence(s, i)
368 if i < len(s) and s[i] == ';':
369 i = skip_spaces(s, i + 1)
370 i, mv1 = parse_sequence(s, i)
371 mv = MoveSequence([mv, mv1, mv.invert(), mv1.invert()])
374 def parse_conjugate(s, i):
375 i, mv = parse_commutator(s, i)
376 if i < len(s) and (s[i] == '.' or s[i] == '*'):
377 i = skip_spaces(s, i + 1)
378 i, mv1 = parse_commutator(s, i)
379 mv = MoveSequence([mv, mv1, mv.invert()])
382 def parse_moves(s, i = 0):
383 i = skip_spaces(s, i)
384 i, mv = parse_conjugate(s, i)
385 i = skip_spaces(s, i)
386 if i < len(s): raise SyntaxError('junk at eol')
389 def xltmatrix(dx, dy, dz):
390 return NP.matrix([[1, 0, 0, dx],
393 [0, 0, 0, 1]], NP.float64)
395 def rotmatrix(theta, axis):
396 R = NP.matrix(NP.identity(4, NP.float64))
400 c, s = M.cos(theta), M.sin(theta)
401 R[i, i] = R[j, j] = c
402 R[i, j] = s; R[j, i] = -s
405 def scalematrix(sx, sy, sz):
406 return NP.matrix([[sx, 0, 0, 0],
409 [0, 0, 0, 1]], NP.float64)
411 class Transform (object):
412 def __init__(me, theta_x, theta_y, delta_z):
416 me._T = xltmatrix(0, 0, delta_z)*\
417 rotmatrix(theta_x, 0)*rotmatrix(theta_y, 1)
418 def project(me, x, y, z):
419 u, v, w, _ = (me._T*NP.array([x, y, z, 1])[:, NP.newaxis]).A1
420 return me.delta_z*u/w, me.delta_z*v/w
423 return NP.array([x, y, z], NP.float64)
426 mag = M.sqrt(NP.dot(v, v))
429 def shorten(p, q, delta):
430 u = unit_vector(q - p)
431 return p + delta*u, q - delta*u
436 class BasePainter (object):
437 def __init__(me, xf, cr):
440 dx, _ = me._cr.device_to_user_distance(0.4, 0)
441 me._cr.set_line_width(dx)
442 def set_colour(me, r, g, b):
443 me._cr.set_source_rgb(r, g, b)
448 u, v = me._xf.project(x, y, z)
449 if firstp: me._cr.move_to(u, v)
450 else: me._cr.line_to(u, v)
453 def _rounded_path_corner(me, r, q1, p1, p2):
454 q2, q3 = shorten(p1, p2, r)
455 c1, c2 = along(0.7, q1, p1), along(0.7, q2, p1)
456 x0, y0 = me._xf.project(*c1)
457 x1, y1 = me._xf.project(*c2)
458 x2, y2 = me._xf.project(*q2)
459 me._cr.curve_to(x0, y0, x1, y1, x2, y2)
461 def _rounded_path_step(me, r, q1, p1, p2):
462 p2, q3 = me._rounded_path_corner(r, q1, p1, p2)
463 x3, y3 = me._xf.project(*q3)
464 me._cr.line_to(x3, y3)
466 def rounded_polygon(me, r, pts):
470 if o0 is None: o0 = p2
473 q0, q1 = shorten(o0, p1, r)
474 x0, y0 = me._xf.project(*q1)
475 me._cr.move_to(x0, y0)
476 else: p1, q1 = me._rounded_path_step(r, q1, p1, p2)
477 p1, q1 = me._rounded_path_step(r, q1, p1, o0)
478 p1, q1 = me._rounded_path_corner(r, q1, p1, o1)
481 def polygon(me, pts):
486 class Painter (BasePainter):
494 class MeasuringPainter (BasePainter):
495 def __init__(me, *args, **kw):
496 super(MeasuringPainter, me).__init__(*args, **kw)
497 me.x0 = me.y0 = me.x1 = me.y1 = None
498 def _update_bbox(me, x0, y0, x1, y1):
499 if me.x0 is None or me.x0 > x0: me.x0 = x0
500 if me.y0 is None or me.y0 > y0: me.y0 = y0
501 if me.x1 is None or me.x1 < x1: me.x1 = x1
502 if me.y1 is None or me.y1 < y1: me.y1 = y1
504 x0, y0, x1, y1 = me._cr.stroke_extents()
506 me._update_bbox(x0, y0, x1, y1)
509 x0, y0, x1, y1 = me._cr.fill_extents()
511 me._update_bbox(x0, y0, x1, y1)
514 class Puzzle (object):
518 me.m = NP.ndarray((NFACE, w, w), NP.uint8)
520 for k in xrange(NFACE):
521 for y in reversed(xrange(w)):
525 me.orbits = [[orbits(w, k, dp) for dp in xrange(w)]
526 for k in xrange(NFACE)]
528 def turn(me, face, dp, n):
529 for o in me.orbits[face][dp]:
530 c = [me.m[k, x, y] for k, x, y in o]
532 k, x, y = o[(i + n)%4]
536 def turn_multi(me, face, dp, n):
537 for i in xrange(dp): me.turn(face, i, n)
539 def fill_face(me, s, i, k, ox, oy, ix, iy, jx, jy):
540 i = skip_spaces(s, i)
541 if i >= len(s): return i
543 for y in xrange(me.w):
545 elif i >= len(s) or s[i] != ',': raise SyntaxError('missing ,')
547 for x in xrange(me.w):
548 if i >= len(s): raise SyntaxError('unexpected eof')
550 me.m[k, ox + x*ix + y*jx, oy + x*iy + y*jy] = c
553 if i < len(s) and not s[i].isspace():
554 raise SyntaxError('face spec too long')
560 i = me.fill_face(s, i, U, 0, 2, +1, 0, 0, -1)
561 i = me.fill_face(s, i, F, 0, 2, +1, 0, 0, -1)
562 i = me.fill_face(s, i, R, 0, 2, +1, 0, 0, -1)
563 i = me.fill_face(s, i, B, 2, 2, -1, 0, 0, -1)
564 i = me.fill_face(s, i, L, 2, 2, -1, 0, 0, -1)
565 i = me.fill_face(s, i, D, 0, 0, +1, 0, 0, +1)
566 i = skip_spaces(s, i)
567 if i < len(s): raise SyntaxError('trailing junk')
571 for y in reversed(xrange(me.w)):
572 print (2*me.w + 2)*' ' + \
573 ' '.join([COLOUR[me.m[U, x, y]]
574 for x in xrange(me.w)])
576 for y in reversed(xrange(me.w)):
577 print ' '.join([' '.join([COLOUR[me.m[k, x, y]]
578 for x in xrange(me.w)])
579 for k in [L, F, R, B]])
581 for y in reversed(xrange(me.w)):
582 print (2*me.w + 2)*' ' + \
583 ' '.join([COLOUR[me.m[D, x, y]]
584 for x in xrange(me.w)])
587 def render_face(me, paint, k, ox, oy, oz, ix, iy, iz, jx, jy, jz):
590 for x in xrange(me.w):
591 for y in xrange(me.w):
592 paint.set_colour(*RGB[me.m[k, x, y]])
593 paint.rounded_polygon(0.04,
594 [(ox + (x*w + g)*ix + (y*w + g)*jx,
595 oy + (x*w + g)*iy + (y*w + g)*jy,
596 oz + (x*w + g)*iz + (y*w + g)*jz),
597 (ox + ((x + 1)*w - g)*ix + (y*w + g)*jx,
598 oy + ((x + 1)*w - g)*iy + (y*w + g)*jy,
599 oz + ((x + 1)*w - g)*iz + (y*w + g)*jz),
600 (ox + ((x + 1)*w - g)*ix + ((y + 1)*w - g)*jx,
601 oy + ((x + 1)*w - g)*iy + ((y + 1)*w - g)*jy,
602 oz + ((x + 1)*w - g)*iz + ((y + 1)*w - g)*jz),
603 (ox + (x*w + g)*ix + ((y + 1)*w - g)*jx,
604 oy + (x*w + g)*iy + ((y + 1)*w - g)*jy,
605 oz + (x*w + g)*iz + ((y + 1)*w - g)*jz)]).fill()
607 def render(me, paint, reflectp):
610 paint.set_colour(0, 0, 0)
611 paint.rounded_polygon(0.04, [(-1, +1, +4), (+1, +1, +4),
612 (+1, -1, +4), (-1, -1, +4)]).fill()
613 paint.rounded_polygon(0.04, [(-1, -4, +1), (+1, -4, +1),
614 (+1, -4, -1), (-1, -4, -1)]).fill()
615 paint.rounded_polygon(0.04, [(-4, -1, +1), (-4, +1, +1),
616 (-4, +1, -1), (-4, -1, -1)]).fill()
617 me.render_face(paint, D, -1, -4, +1, +1, 0, 0, 0, 0, -1)
618 me.render_face(paint, B, +1, -1, +4, -1, 0, 0, 0, +1, 0)
619 me.render_face(paint, L, -4, -1, +1, 0, 0, -1, 0, +1, 0)
621 paint.set_colour(0, 0, 0)
622 paint.rounded_polygon(0.04, [(-1, +1, -1), (-1, +1, +1),
623 (+1, +1, +1), (+1, -1, +1),
624 (+1, -1, -1), (-1, -1, -1)]).fill()
626 ##paint.set_colour(0.3, 0.3, 0.3)
627 ##paint.path([(-1, +1, -1), (+1, +1, -1), (+1, +1, +1)]).stroke()
628 ##paint.path([(+1, +1, -1), (+1, -1, -1)]).stroke()
630 me.render_face(paint, U, -1, +1, -1, +1, 0, 0, 0, 0, +1)
631 me.render_face(paint, F, -1, -1, -1, +1, 0, 0, 0, +1, 0)
632 me.render_face(paint, R, +1, -1, -1, 0, 0, +1, 0, +1, 0)
638 for a in SYS.argv[1:]:
640 elif a[0] == '-' or a[0] == '+':
645 if sense: st = Puzzle(n)
647 elif w == 'invert': invertp = sense
648 elif w == 'reflect': reflectp = sense
649 else: raise SyntaxError('unknown option')
650 elif a[0] == '=': st.fill(a[1:])
651 elif a[0] == ':': parse_moves(a[1:]).apply(st)
652 elif a == '?': st.show()
655 _, e = OS.path.splitext(p)
656 surf = CR.ImageSurface(CR.FORMAT_ARGB32, 100, 100)
657 xf = Transform(TAU/10, TAU/10, 8)
658 cr = CR.Context(surf)
659 if invertp: cr.scale(scale, scale)
660 else: cr.scale(scale, -scale)
661 m = MeasuringPainter(xf, cr)
662 st.render(m, reflectp)
663 x0, y0 = cr.user_to_device(m.x0, m.y0)
664 x1, y1 = cr.user_to_device(m.x1, m.y1)
665 if x0 > x1: x0, x1 = x1, x0
666 if y0 > y1: y0, y1 = y1, y0
667 w, h = map(lambda x: int(M.ceil(x)), [x1 - x0, y1 - y0])
668 if e == '.pdf': surf = CR.PDFSurface(p, w + 2, h + 2)
669 elif e == '.png': surf = CR.ImageSurface(CR.FORMAT_ARGB32, w + 2, h + 2)
671 surf = CR.PSSurface(p, w + 2, h + 2)
673 else: raise SyntaxError('unrecognized extension')
674 cr = CR.Context(surf)
675 cr.translate(1 - x0, 1 - y0)
676 if invertp: cr.scale(scale, scale)
677 else: cr.scale(scale, -scale)
678 paint = Painter(xf, cr)
679 st.render(paint, reflectp)
681 if e == '.png': surf.write_to_png(p)