Initial commit.
[rubik] / rubik
CommitLineData
295b2492
MW
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
28import math as M; TAU = 2*M.pi
29import os as OS
30import sys as SYS
31
32import cairo as CR
33import numpy as NP
34
35###--------------------------------------------------------------------------
36### The main cube model.
37
38## Names for the faces and corresponding colours.
39U, D, L, R, F, B, UNK = 0, 1, 2, 3, 4, 5, 6
40FACE = ['U', 'D', 'L', 'R', 'F', 'B']
41
42## Number of faces.
43NFACE = 6
44
45## Colours of the faces.
46COLOUR = ['#', '+', '%', '/', '=', '-', 'ยท']
47RGB = [(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)]
51CMAP = { '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.
61GEOM = [[(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
68def 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
86def 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
100def 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
122def 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
135def 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
140class 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
161class 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>'
178NULLMOVE = NullMove()
179
180class 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
221class 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
255class 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
270class 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
290def 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
295def 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
312def 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
319def 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
339def 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
356def 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
366def 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
374def 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
382def 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
389def 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
395def 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
405def 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
411class 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
422def vector(x, y, z):
423 return NP.array([x, y, z], NP.float64)
424
425def unit_vector(v):
426 mag = M.sqrt(NP.dot(v, v))
427 return v/mag
428
429def shorten(p, q, delta):
430 u = unit_vector(q - p)
431 return p + delta*u, q - delta*u
432
433def along(f, p, q):
434 return p + f*(q - p)
435
436class 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
486class 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
494class 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
514class 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
634st = Puzzle(3)
635invertp = False
636reflectp = True
637scale = 32
638for 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