6
\$\begingroup\$

Intro

You've mastered the Rubik's Cube and got bored solving it, so now you are looking for a new challenge. One puzzle similar to the Rubik's Cube caught your attention. It's called a Pyraminx puzzle, and is a triangular pyramid-shaped puzzle. The parts are arranged in a pyramidal pattern on each side, while the layers can be rotated with respect to each vertex, and the individual tips can be rotated as well. There are 4 faces on the Pyraminx. The puzzle should be held so that one face faces you and one face faces down, as in the image below. The four corners are then labeled U (for up), R (for right), L (for left), and B (for back). The front face thus contains the U, R, and L corners.

Let's write down all possible moves for vertex U in the following notation:

  • U - 120° counterclockwise turn of topmost tip (assuming that we're looking at the pyraminx from the top, and vertex U is the topmost);
  • U' - clockwise turn for the same tip;
  • u - 120° counterclockwise turn of two upper layer;
  • u' - clockwise turn for the same layers.

enter image description here

For other vertices the moves can be described similarly

enter image description here

The first puzzle you bought wasn't assembled, so you get your professional pyraminx solver friend to assemble it. He does, and you wrote down all his moves notated as described above. Now the puzzle's faces have colors faceColors[0] (front face), faceColors[1] (bottom face), faceColors[2] (left face), faceColors[3] (right face). You want to know the initial state of the puzzle to repeat your friend's moves and see how he solved it.

Example

For face_colors = ['R', 'G', 'Y', 'O'] and moves = ["B", "b'", "u'", "R"], the output should be

pyraminxPuzzle(faceColors, moves) = [['Y', 'Y', 'Y', 'Y', 'R', 'R', 'R', 'R', 'G'],
                                     ['G', 'R', 'O', 'O', 'O', 'G', 'G', 'G', 'G'],
                                     ['Y', 'O', 'Y', 'G', 'O', 'O', 'G', 'G', 'Y'],
                                     ['R', 'O', 'O', 'R', 'O', 'Y', 'Y', 'R', 'R']]

Visual representation

enter image description here enter image description here

Edit

As requested bij @Jan Kuijken a map for each face to the corresponding index

"""
L[2] :  [     4         | U[0] : [       0,         | R[3] : [      8,
           6, 5, 1      |              1, 2, 3,     |            3, 7, 6,
        8, 7, 3, 2, 0 ] |           4, 5, 6, 7, 8 ] |         0, 2, 1, 5, 4 ]
-----------------------------------------------------------------------------
                          B[1] : [  8, 7, 6, 5, 4,
                                       3, 2, 1,
                                          0        ]
"""

Tests

import unittest

class TestPyraminx(unittest.TestCase): 
    def test1(self):
        exp_1 = [["Y","Y","Y","Y","R","R","R","R","G"], 
                 ["G","R","O","O","O","G","G","G","G"], 
                 ["Y","O","Y","G","O","O","G","G","Y"], 
                 ["R","O","O","R","O","Y","Y","R","R"]]
        colours = ["R",  "G",  "Y",  "O"]
        moves = ["B",  "b'",  "u'",  "R"]
        self.assertEqual(pyraminx_puzzle(colours, moves), exp_1)

    def test2(self):
        exp_2 = [["R","R","R","R","R","R","R","R","R"], 
                 ["G","G","G","G","G","G","G","G","G"], 
                 ["Y","Y","Y","Y","Y","Y","Y","Y","Y"], 
                 ["O","O","O","O","O","O","O","O","O"]]
        colours = ["R",  "G",  "Y",  "O"]
        moves = ["l",  "l'"]
        self.assertEqual(pyraminx_puzzle(colours, moves), exp_2)

    def test3(self):
        exp_3 = [["Y","O","R","G","G","G","G","G","G"], 
                 ["G","O","G","Y","O","O","Y","Y","Y"], 
                 ["R","G","R","R","O","Y","Y","Y","Y"], 
                 ["R","R","R","R","O","O","O","O","R"]]
        colours = ["R",  "G",  "Y",  "O"]
        moves = ["l",  "l'",  "u",  "R",  "U'",  "L",  "R'",  "u'",  "l'",  "L'",  "r"]
        self.assertEqual(pyraminx_puzzle(colours, moves), exp_3)

    def test4(self):
        exp_4 = [["R","R","R","G","R","R","G","G","G"], 
                 ["G","O","G","G","O","O","O","G","G"], 
                 ["Y","Y","Y","Y","Y","Y","Y","Y","Y"], 
                 ["R","R","R","R","O","O","O","O","O"]]
        colours = ["R",  "G",  "Y",  "O"]
        moves = ["r"]
        self.assertEqual(pyraminx_puzzle(colours, moves), exp_4)

    def test5(self):
        exp_5 = [["A","A","A","A","A","A","A","A","A"], 
                 ["B","B","B","B","B","B","B","B","B"], 
                 ["C","C","C","C","C","C","C","C","C"], 
                 ["D","D","D","D","D","D","D","D","D"]]
        colours = ["A",  "B",  "C",  "D"]
        moves = ["l",  "l'",  "r'",  "r",  "u",  "U",  "u'",  "R'",  "L",  "R",  "L'",  "B'",  "U'",  "b",  "B",  "b'"]
        self.assertEqual(pyraminx_puzzle(colours, moves), exp_5)

    def test6(self):
        exp_6 = [["E","Y","E","G","Y","Y","R","G","G"], 
                 ["Y","E","Y","R","E","E","E","R","R"], 
                 ["G","G","G","Y","R","R","E","E","E"], 
                 ["R","G","R","R","G","G","Y","Y","Y"]]
        colours = ["R", "G", "Y", "E"]
        moves = ["b", "l", "r", "u"]
        self.assertEqual(pyraminx_puzzle(colours, moves), exp_6)

    def test7(self):
        exp_7 = [["E","R","R","R","L","R","R","R","U"], 
                 ["L","U","U","U","E","U","U","U","R"], 
                 ["U","L","L","L","R","L","L","L","E"], 
                 ["R","E","E","E","U","E","E","E","L"]]
        colours = ["R", "U", "L", "E"]
        moves = ["U", "B", "R", "L"]
        self.assertEqual(pyraminx_puzzle(colours, moves), exp_7)

    def test8(self):
        exp_8 = [["W","W","D","A","W","W","S","A","A"], 
                 ["A","D","D","A","D","D","D","A","A"], 
                 ["S","S","S","S","S","W","A","A","S"], 
                 ["W","W","W","D","D","S","W","S","D"]]
        colours = ["W", "A", "S", "D"]
        moves = ["l", "r'", "U'", "u", "r'", "B", "l'", "b'"]
        self.assertEqual(pyraminx_puzzle(colours, moves), exp_8)

    def test9(self):
        exp_9 = [["W","S","D","S","W","S","S","W","A"], 
                 ["D","W","A","A","D","A","D","W","A"], 
                 ["S","A","A","D","S","W","W","S","A"], 
                 ["W","D","D","W","S","D","A","S","D"]]
        colours = ["W", "A", "S", "D"]
        moves = ["B'", "R'", "L", "U'", "B", "r'", "l", "B'", "L'", "r'", "L", "U", "u'",
                 "U", "B'", "r", "L'", "R", "B", "r", "R'", "R", "U'", "U", "L", "r", "L",
                 "B'", "U", "B", "R", "R'", "R", "u'", "l", "R'", "R", "B", "R'", "U", "u",
                 "U", "u'", "B'", "r", "L'", "B'", "R'", "B'", "r", "R'", "r", "L", "R'",
                 "B", "u", "B'", "B", "L", "U", "B", "B", "L", "R", "B", "R", "u'", "R'",
                 "B", "u", "u'", "L'", "B", "R'", "l'", "U", "U'", "B", "r", "L'", "B", "r'",
                 "U", "R", "R'", "u'", "r", "R'", "u'", "r'", "L'", "R'", "r'", "U", "u'",
                 "B'", "U", "L'", "L'", "B"]
        self.assertEqual(pyraminx_puzzle(colours, moves), exp_9)

    def test10(self):
        exp_10 = [["W","W","W","W","W","W","W","W","W"], 
                  ["A","A","A","A","A","A","A","A","A"], 
                  ["S","S","S","S","S","S","S","S","S"], 
                  ["D","D","D","D","D","D","D","D","D"]]
        colours = ["W", "A", "S", "D"]
        moves = ["L", "L", "L", "r'", "r'", "r'", "U", "U'", "U", "U'", "b", "b'", "b'", "b"]
        self.assertEqual(pyraminx_puzzle(colours, moves), exp_10)

if __name__ == '__main__':
    unittest.main()

Code

def pyraminx_puzzle(face_colours, moves):
    move_position = { "U": [[0, 0], [3, 8], [2, 4]],
                      "u": [[0, 0], [0, 1], [0, 2], [0, 3], [3, 8], [3, 3], [3, 7], [3, 6], [2, 4], [2, 6], [2, 5], [2, 1]],
                      "L": [[2, 0], [1, 8], [0, 4]],
                      "l": [[2, 0], [2, 1], [2, 2], [2, 3], [1, 8], [1, 3], [1, 7], [1, 6], [0, 4], [0, 6], [0, 5], [0, 1]],
                      "R": [[3, 0], [0, 8], [1, 4]],
                      "r": [[3, 0], [3, 1], [3, 2], [3, 3], [0, 3], [0, 8], [0, 7], [0, 6], [1, 4], [1, 6], [1, 5], [1, 1]],
                      "B": [[1, 0], [2, 8], [3, 4]],
                      "b": [[1, 0], [1, 1], [1, 2], [1, 3], [2, 8], [2, 3], [2, 7], [2, 6], [3, 4], [3, 6], [3, 5], [3, 1]] }

    puzzle = [[c for _ in range(9)] for c in face_colours]
    for move in reversed(moves):
        left = "'" not in move
        move_colors = { move: [puzzle[r][c] for r, c in move_position[move]] for move in move_position }
        current_color = shift_color(move_colors[move[0]], left)        
        for idx, pos in enumerate(move_position[move[0]]):
            puzzle[pos[0]][pos[1]] = current_color[idx]

    return puzzle

def shift_color(current_color, left):
    i = len(current_color) // 3
    if left:
        return current_color[i:] + current_color[0:i]
    return current_color[-i:] + current_color[:-i] 
\$\endgroup\$
  • \$\begingroup\$ Nice problem, could you also provide a drawing which maps the x index of faceColours[x] to the actual positions on the faces? (for an easier understanding) \$\endgroup\$ – Jan Kuiken Jun 26 '18 at 17:00
  • \$\begingroup\$ @JanKuiken I've edited that part in, I hope it is more clear now. \$\endgroup\$ – Ludisposed Jun 27 '18 at 11:24
  • \$\begingroup\$ Why is the right face numbered so weirdly? \$\endgroup\$ – 200_success Jul 10 '18 at 20:58
  • \$\begingroup\$ You mean import unittest and not import unittests, right? \$\endgroup\$ – 200_success Jul 10 '18 at 21:11
  • \$\begingroup\$ @200_succes Thnx, was a typo edited it. The right face is weird indeed, but if you see the R as the top, and go topdown left to right it makes sense. \$\endgroup\$ – Ludisposed Jul 10 '18 at 22:25
2
\$\begingroup\$

Trivial observations

You have a parameter named face_colours, and another parameter named current_color, as well as a function named shift_color. Pick one spelling convention and stick with it. American English generally prevails in the programming world, so I recommend that.


In move_position, the values are lists of lists. Since each coordinate is immutable and always has length 2, you should prefer to write them as tuples.


puzzle = [[c for _ in range(9)] for c in face_colours]

would be better written as

puzzle = [[c] * 9 for c in face_colours]

for idx, pos in enumerate(move_position[move[0]]):
    puzzle[pos[0]][pos[1]] = current_color[idx]

would be better written as

for (r, c), color in zip(move_position[move[0]], current_color):
    puzzle[r][c] = color

Bug

You have an error that causes test6, test8, and test9 to fail. The cause is that in move_position["r"], coordinates [0, 8] and [0, 3] are swapped.

A contributing factor to this bug is the lack of regularity and organization in the lookup table. If the table had been written like this…

move_position = {
    "U": [(0, 0), (3, 8), (2, 4)],
    "u": [(0, 0), (0, 1), (0, 2), (0, 3),
          (3, 8), (3, 3), (3, 7), (3, 6),
          (2, 4), (2, 6), (2, 5), (2, 1)],
    "L": [(2, 0), (1, 8), (0, 4)],
    "l": [(2, 0), (2, 1), (2, 2), (2, 3),
          (1, 8), (1, 3), (1, 7), (1, 6),
          (0, 4), (0, 6), (0, 5), (0, 1)],
    "R": [(3, 0), (0, 8), (1, 4)],
    "r": [(3, 0), (3, 1), (3, 2), (3, 3),
          (0, 3), (0, 8), (0, 7), (0, 6),
          (1, 4), (1, 6), (1, 5), (1, 1)],
    "B": [(1, 0), (2, 8), (3, 4)],
    "b": [(1, 0), (1, 1), (1, 2), (1, 3),
          (2, 8), (2, 3), (2, 7), (2, 6),
          (3, 4), (3, 6), (3, 5), (3, 1)],
}

… then two patterns would have been more apparent:

  • Each lowercase move consists of 12 tiles being permuted: 4 tiles on each of 3 faces.
  • Each uppercase move consists of 3 tiles being permuted. Furthermore, the three tiles involved in the uppercase move are a subset of the corresponding lowercase move: namely, the first coordinates of each row of the corresponding lowercase move. The pattern was broken between move_positions["R"] and move_positions["r"].

Therefore, I suggest that the hard-coded data in the lookup table should be minimized. Rather, the code can automatically produce the uppercase moves, given the lowercase moves.

Then, if we are going to programmatically extend the lookup table, we might as well go a step further and programmatically include both clockwise and counterclockwise moves in the lookup table.

Division of labour

It's not obvious what the shift_color function does. I would prefer to see the helpers designed such that pyraminx_puzzle is entirely straightforward:

def pyraminx_puzzle(face_colors, moves):
    puzzle = [[c] * 9 for c in face_colors]
    for move in reversed(moves):
        MOVES[move](puzzle)
    return puzzle

Suggested solution

Most of the work goes into building the table of moves. Applying each move, then, becomes a simple matter of performing a lookup and doing some unconditional swaps.

def make_moves(moves):
    """
    Extrapolate from a table of moves to build a table that includes
    clockwise moves, counterclockwise moves, and corner-only moves.
    Each resulting key will be the name of a move (e.g. "U'"), and each
    resulting value will be a function that mutates a puzzle.
    """
    def make_move(new_positions, old_positions):
        def move(puzzle):
            old_colors = [puzzle[r][c] for r, c in old_positions]
            for (r, c), color in zip(new_positions, old_colors):
                puzzle[r][c] = color
        return move
    for turn, pos in moves.items():
        # Clockwise variant
        yield turn, make_move(pos[-4:] + pos[:-4], pos)
        # Counterclockwise variant
        yield turn + "'", make_move(pos[4:] + pos[:4], pos)
        # Corner-only clockwise variant
        yield turn.upper(), make_move([pos[i] for i in range(-4, 8, 4)], pos[::4])
        # Corner-only counterclockwise variant
        yield turn.upper() + "'", make_move([pos[i] for i in range(-8, 4, 4)], pos[::4])

MOVES = dict(make_moves({
    "u": [(0, 0), (0, 1), (0, 2), (0, 3),
          (3, 8), (3, 3), (3, 7), (3, 6),
          (2, 4), (2, 6), (2, 5), (2, 1)],
    "l": [(2, 0), (2, 1), (2, 2), (2, 3),
          (1, 8), (1, 3), (1, 7), (1, 6),
          (0, 4), (0, 6), (0, 5), (0, 1)],
    "r": [(3, 0), (3, 1), (3, 2), (3, 3),
          (0, 8), (0, 3), (0, 7), (0, 6),
          (1, 4), (1, 6), (1, 5), (1, 1)],
    "b": [(1, 0), (1, 1), (1, 2), (1, 3),
          (2, 8), (2, 3), (2, 7), (2, 6),
          (3, 4), (3, 6), (3, 5), (3, 1)],
}))

def pyraminx_puzzle(face_colors, moves):
    puzzle = [[c] * 9 for c in face_colors]
    for move in reversed(moves):
        MOVES[move](puzzle)
    return puzzle
\$\endgroup\$
  • \$\begingroup\$ Yes that bug is pretty bad, i fixed it... but somehow ended up in here anyway, sorry for that. I like the way you move, it's much clearer now. \$\endgroup\$ – Ludisposed Jul 11 '18 at 6:33

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.