chiark / gitweb /
Initial commit.
[rubik] / rubik
1 #! /usr/bin/python
2 ### -*- coding: utf-8 -*-
3 ###
4 ### Rubik cube model and rendering
5 ###
6 ### (c) 2018 Mark Wooding
7 ###
8
9 ###----- Licensing notice ---------------------------------------------------
10 ###
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.
15 ###
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.
20 ###
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.
24
25 ###--------------------------------------------------------------------------
26 ### External dependencies.
27
28 import math as M; TAU = 2*M.pi
29 import os as OS
30 import sys as SYS
31
32 import cairo as CR
33 import numpy as NP
34
35 ###--------------------------------------------------------------------------
36 ### The main cube model.
37
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']
41
42 ## Number of faces.
43 NFACE = 6
44
45 ## Colours of the faces.
46 COLOUR = ['#', '+', '%', '/', '=', '-', 'ยท']
47 RGB = [(1, 0, 0), (1, 0.75, 0),
48        (1, 1, 0), (1, 1, 1),
49        (0, 0, 1), (0, 1, 0),
50        (0.4, 0.4, 0.4)]
51 CMAP = { 'r': U, 'o': D, 'y': L, 'w': R, 'b': F, 'g': B, '?': UNK }
52
53 ## Geometry table.
54 ##
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)]]
67
68 def convert_side_coords(w, orig, face, x, y):
69   """
70   Convert relative coordinates (X, Y) on a side face into raw coordinates.
71
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.
77   """
78   k, ix, iy = GEOM[orig][face]
79   jx, jy = -iy, ix
80   if ix < 0 or jx < 0: ox = w - 1
81   else: ox = 0
82   if iy < 0 or jy < 0: oy = w - 1
83   else: oy = 0
84   return k, ox + x*ix + y*jx, oy + x*iy + y*jy
85
86 def slice_orbits(w, face, dp):
87   """
88   Return the effect of a slice turn on the side stickers.
89
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.
93   """
94   oo = []
95   for x in xrange(w):
96     oo.append([convert_side_coords(w, face, k, x, dp)
97                for k in [U, R, D, L]])
98   return oo
99
100 def face_orbits(w, face, inv):
101   """
102   Return the effect of a face turn on the face's stickers.
103
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
107   cube.
108   """
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
111   oo = []
112   for x in xrange((w + 1)//2, w):
113     for y in xrange(w//2, x + 1):
114       o = []
115       u, v = x, y
116       for i in xrange(4):
117         o.append((face, u, v))
118         u, v = ta*u + tb*v + dx, tc*u + td*v + dy
119       oo.append(o)
120   return oo
121
122 def orbits(w, face, dp):
123   """
124   Return the effect of a slice turn on the cube's stickers.
125
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.
129   """
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)
133   return oo
134
135 def maybe_parens(parens, s):
136   """If S, then return the string `(S)'; otherwise just return S."""
137   if parens: return '(' + s + ')'
138   else: return s
139
140 class BaseMove (object):
141   """
142   Base class for move objects.
143
144   Subclasses must implement the following methods.
145
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
149   """
150   def repeat(me, m):
151     """Return the move repeated M times."""
152     if m == 0: return NULLMOVE
153     else: return RepeatedMove(me, m)
154   def flatten(me):
155     """Return a list of constituent moves."""
156     return [me]
157   def __str__(me):
158     """Return a string representation of the move."""
159     return me._str(9)
160
161 class NullMove (BaseMove):
162   """The null move, which does nothing at all."""
163   def apply(me, puz):
164     """Apply the move to the puzzle PUZ."""
165     pass
166   def invert(me):
167     """Return the inverse of the move."""
168     return me
169   def repeat(me, m):
170     """Return the move repeated M times."""
171     return me
172   def flatten(me):
173     """Return a list of constituent moves."""
174     return []
175   def _str(me, lv):
176     """Return a string representation, in a priority-LV context."""
177     return '#<null>'
178 NULLMOVE = NullMove()
179
180 class AtomicMove (BaseMove):
181   """A turn of a single slice."""
182
183   MAP = { }
184   for k in xrange(NFACE):
185     MAP[FACE[k]] = (k, 0)
186     MAP[FACE[k].lower()] = (k, 1)
187
188   def __init__(me, face, dp = 0, n = 1):
189     """
190     Construct an atomic move.
191
192     Returns a move representing N clockwise turns of the DPth slice, as
193     viewed from FACE.
194     """
195     me.face = face
196     me.dp = dp
197     me.n = n
198   def apply(me, puz):
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)
202   def invert(me):
203     """Return the inverse of the move."""
204     return AtomicMove(me.face, me.dp, -me.n)
205   def repeat(me, m):
206     """Return the move repeated M times."""
207     mm = (me.n*m)%4
208     if not mm: return NULLMOVE
209     else: return AtomicMove(me.face, me.dp, mm)
210   def _str(me, lv):
211     """Return a string representation, in a priority-LV context."""
212     f = FACE[me.face]
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)
220
221 class SpinMove (BaseMove):
222   """A spin of the entire cube."""
223   MAP = { 'X': R, 'Y': U, 'Z': F }
224   UNMAP = { }
225   for k, v in MAP.iteritems(): UNMAP[v] = k
226
227   def __init__(me, face, n = 1):
228     """Construct a spin move.
229
230     Returns a move representing N clockwise spins the entire cube DPth slice,
231     as viewed from FACE.
232     """
233     me.face = face
234     me.n = n
235   def apply(me, puz):
236     """Apply the move to the puzzle PUZ."""
237     puz.turn_multi(me.face, puz.w, me.n)
238   def invert(me):
239     """Return the inverse of the move."""
240     return SpinMove(me.face, -me.n)
241   def repeat(me, m):
242     """Return the move repeated M times."""
243     mm = (me.n*m)%4
244     if not mm: return NULLMOVE
245     else: return SpinMove(me.face, mm)
246   def _str(me, lv):
247     """Return a string representation, in a priority-LV context."""
248     f = me.MAP[me.face]
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 = "'"
253     return f + d
254
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])
260   def apply(me, puz):
261     """Apply the move to the puzzle PUZ."""
262     for mv in me.moves: mv.apply(puz)
263   def invert(me):
264     """Return the inverse of the move."""
265     return MoveSequence(list(reversed([mv.invert() for mv in me.moves])))
266   def _str(me, lv):
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]))
269
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."""
274     me.move = move
275     me.m = m
276   def apply(me, puz):
277     """Apply the move to the puzzle PUZ."""
278     for i in xrange(me.m): me.move.apply(puz)
279   def invert(me):
280     """Return the inverse of the move."""
281     return RepeatedMove(me.move.invert(), me.m)
282   def repeat(me, m):
283     """Return the move repeated M times."""
284     if m == 0: return NULLMOVE
285     else: return RepeatedMove(me.move, m*me.m)
286   def _str(me, lv):
287     """Return a string representation, in a priority-LV context."""
288     return maybe_parens(lv < 3, '%s%d' % (me.move._str(2), me.m))
289
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
293   return i
294
295 def parse_number(s, i):
296   """
297   Parse an integer from S, starting at index I.
298
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.
304   """
305   n = 0
306   while i < len(s) and s[i].isdigit():
307     n = 10*n + ord(s[i]) - ord('0')
308     i += 1
309   i = skip_spaces(s, i)
310   return i, n
311
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)
317   return i, mv
318
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)
331       if n == 1: dp = 0
332       else: dp = -n
333     else:
334       i = skip_spaces(s, i + 1)
335     mv = AtomicMove(k, dp, 1)
336     return i, mv
337   else: return i, None
338
339 def parse_move(s, i):
340   i, mv = parse_primary(s, i)
341   if mv is None: return i, None
342   while True:
343     if i < len(s) and s[i] == "'":
344       i = skip_spaces(s, i + 1)
345       mv = mv.invert()
346     elif i < len(s) and s[i].isdigit():
347       i, m = parse_number(s, i)
348       mv = mv.repeat(m)
349     elif i + 1 < len(s) and s[i] == '^' and s[i + 1].isdigit():
350       i, m = parse_number(s, i + 1)
351       mv = mv.repeat(m)
352     else:
353       break
354   return i, mv
355
356 def parse_sequence(s, i):
357   seq = []
358   while True:
359     i, mv = parse_move(s, i)
360     if mv is None: break
361     seq.append(mv)
362   if len(seq) == 0: raise SyntaxError('unrecognized move')
363   elif len(seq) == 1: return i, seq[0]
364   else: return i, MoveSequence(seq)
365
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()])
372   return i, mv
373
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()])
380   return i, mv
381
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')
387   return mv
388
389 def xltmatrix(dx, dy, dz):
390   return NP.matrix([[1, 0, 0, dx],
391                     [0, 1, 0, dy],
392                     [0, 0, 1, dz],
393                     [0, 0, 0, 1]], NP.float64)
394
395 def rotmatrix(theta, axis):
396   R = NP.matrix(NP.identity(4, NP.float64))
397   i, j = 0, 1
398   if i >= axis: i += 1
399   if j >= axis: j += 1
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
403   return R
404
405 def scalematrix(sx, sy, sz):
406   return NP.matrix([[sx, 0, 0, 0],
407                     [0, sy, 0, 0],
408                     [0, 0, sz, 0],
409                     [0, 0, 0, 1]], NP.float64)
410
411 class Transform (object):
412   def __init__(me, theta_x, theta_y, delta_z):
413     me.theta_x = theta_x
414     me.theta_y = theta_y
415     me.delta_z = 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
421
422 def vector(x, y, z):
423   return NP.array([x, y, z], NP.float64)
424
425 def unit_vector(v):
426   mag = M.sqrt(NP.dot(v, v))
427   return v/mag
428
429 def shorten(p, q, delta):
430   u = unit_vector(q - p)
431   return p + delta*u, q - delta*u
432
433 def along(f, p, q):
434   return p + f*(q - p)
435
436 class BasePainter (object):
437   def __init__(me, xf, cr):
438     me._cr = cr
439     me._xf = xf
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)
444     return me
445   def path(me, pts):
446     firstp = True
447     for x, y, z in pts:
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)
451       firstp = False
452     return me
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)
460     return p2, q3
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)
465     return p2, q3
466   def rounded_polygon(me, r, pts):
467     o0 = o1 = None
468     for x, y, z in pts:
469       p2 = vector(x, y, z)
470       if o0 is None: o0 = p2
471       elif o1 is None:
472         o1 = p1 = 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)
479     me._cr.close_path()
480     return me
481   def polygon(me, pts):
482     me.path(pts)
483     me._cr.close_path()
484     return me
485
486 class Painter (BasePainter):
487   def stroke(me):
488     me._cr.stroke()
489     return me
490   def fill(me):
491     me._cr.fill()
492     return me
493
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
503   def stroke(me):
504     x0, y0, x1, y1 = me._cr.stroke_extents()
505     me._cr.new_path()
506     me._update_bbox(x0, y0, x1, y1)
507     return me
508   def fill(me):
509     x0, y0, x1, y1 = me._cr.fill_extents()
510     me._cr.new_path()
511     me._update_bbox(x0, y0, x1, y1)
512     return me
513
514 class Puzzle (object):
515
516   def __init__(me, w):
517     me.w = w
518     me.m = NP.ndarray((NFACE, w, w), NP.uint8)
519
520     for k in xrange(NFACE):
521       for y in reversed(xrange(w)):
522         for x in xrange(w):
523           me.m[k, x, y] = k
524
525     me.orbits = [[orbits(w, k, dp) for dp in xrange(w)]
526                  for k in xrange(NFACE)]
527
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]
531       for i in xrange(4):
532         k, x, y = o[(i + n)%4]
533         me.m[k, x, y] = c[i]
534     return me
535
536   def turn_multi(me, face, dp, n):
537     for i in xrange(dp): me.turn(face, i, n)
538
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
542     firstp = True
543     for y in xrange(me.w):
544       if firstp: pass
545       elif i >= len(s) or s[i] != ',': raise SyntaxError('missing ,')
546       else: i += 1
547       for x in xrange(me.w):
548         if i >= len(s): raise SyntaxError('unexpected eof')
549         c = CMAP[s[i]]
550         me.m[k, ox + x*ix + y*jx, oy + x*iy + y*jy] = c
551         i += 1
552       firstp = False
553     if i < len(s) and not s[i].isspace():
554       raise SyntaxError('face spec too long')
555     return i
556
557   def fill(me, s):
558     i = 0
559     me.m[:, :, :] = UNK
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')
568     return me
569
570   def show(me):
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)])
575     print
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]])
580     print
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)])
585     print
586
587   def render_face(me, paint, k, ox, oy, oz, ix, iy, iz, jx, jy, jz):
588     w = 2.0/me.w
589     g = w/16.0
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()
606
607   def render(me, paint, reflectp):
608
609     if 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)
620
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()
625
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()
629
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)
633
634 st = Puzzle(3)
635 invertp = False
636 reflectp = True
637 scale = 32
638 for a in SYS.argv[1:]:
639   if not a: pass
640   elif a[0] == '-' or a[0] == '+':
641     w = a[1:]
642     sense = a[0] == '-'
643     if w.isdigit():
644       n = int(w)
645       if sense: st = Puzzle(n)
646       else: scale = 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()
653   else:
654     p = a
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)
670     elif e == '.ps':
671       surf = CR.PSSurface(p, w + 2, h + 2)
672       surf.set_eps(True)
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)
680     surf.show_page()
681     if e == '.png': surf.write_to_png(p)
682     surf.finish()
683     del cr, surf