Cube engine#

app/cube/ is a pure-Python 3x3 engine: notation parsing, cube state, scrambles, the solve-track format and SVG rendering. It imports nothing from Flask, touches no database, has no module-level mutable state beyond the move tables built at import time, and is deterministic given a seed.

app/static/js/cube/ mirrors it in ES modules for the browser. The two implementations agree because a shared fixture file makes them agree — see Parity.

from app.cube import (
    parse_moves, format_moves, invert_moves, expand_moves, Move,   # notation
    Cube,                                                          # state
    scramble, scramble_for_case,                                   # scramble
    Track, parse_track, dump_track, encode_track, decode_track,    # track
    net_svg, thumbnail_svg,                                        # render
)

Notation#

Singmaster notation, space separated.

Class Tokens Meaning
Faces U D L R F B Turn that face
Wide turns u d l r f b or Uw Dw Lw Rw Fw Bw Face plus the adjacent slice. Both spellings parse; the canonical output is lowercase
Slices M E S The middle layer between two opposite faces
Rotations x y z Turn the whole cube
Modifiers ' 2 2' Counter-clockwise, half turn, half turn (accepted, normalised to 2)

Direction is the standard: a plain move turns that face clockwise as seen looking directly at it. The derived layers follow their reference face:

  • M follows L
  • E follows D
  • S follows F
  • x follows R, y follows U, z follows F

Input may contain ( ) [ ] and any amount of whitespace; the parser strips them. Round brackets appear in the source PDFs as memorisation aids ((R U R' U') (R' F R F')) and carry no meaning.

>>> from app.cube import parse_moves, format_moves, invert_moves
>>> format_moves(parse_moves("(R U R') (U' R' F R F')"))
"R U R' U' R' F R F'"
>>> invert_moves("R U R' U'")
"U R U' R'"

parse_moves raises NotationError (a ValueError) on anything it cannot read. Callers that accept user input should catch it; callers working with library data should not, because bad library data is a bug.

expand_moves#

Rewrites wide turns and slices into face turns plus rotations, preserving the visual result. A renderer that can only animate the six faces plus whole-cube rotations can therefore animate anything:

>>> from app.cube import expand_moves, format_moves
>>> format_moves(expand_moves("M"))
"R L' x'"

The expansion is chosen so the cube looks the same at every step, not merely at the end — which matters because the 3D viewer interpolates between them.

The facelet string#

The canonical state is a 54-character string in face order U R F D L B, each face read row-major from its own top-left as seen with that face toward you in the standard orientation (U on top, F toward you).

index  0..8   U   (index 0 is the UBL corner sticker)
       9..17  R
      18..26  F
      27..35  D
      36..44  L
      45..53  B
U · 0–8 012 345 678 L · 36–44 363738 394041 424344 F · 18–26 181920 212223 242526 R · 9–17 91011 121314 151617 B · 45–53 454647 484950 515253 D · 27–35 272829 303132 333435 each face read row-major from its own top-left
The folded-out net with the facelet index of every sticker.

Characters are the face letters U R F D L B, not colours. The state is colour-agnostic; the viewer maps letters to colours through viewer.colors in the config, which is why changing to a Japanese colour scheme is a config edit and not a data migration.

A solved cube is therefore:

SOLVED_FACELETS = "U"*9 + "R"*9 + "F"*9 + "D"*9 + "L"*9 + "B"*9

This is the same convention as the widely used kociemba facelet string, so a state from this app can be pasted into external solvers and back.

Cube#

Cube.solved()                    # -> Cube
Cube.from_facelets(s)            # -> Cube, raises CubeError on a bad string
Cube.from_moves("R U R'")        # -> Cube
cube.apply("R U R' U'")          # -> a NEW Cube; the receiver is untouched
cube.sequence("R U R'")          # -> iterator of the state after each move
cube.facelets                    # -> the 54-char string
cube.face("U"); cube.rows("U")   # -> list[str] / list[list[str]]
cube.is_solved()
cube.is_solved_ignoring(mask)    # "solved apart from these stickers"
cube.copy()

apply returns a new object rather than mutating, so a Cube can be shared, hashed and compared. sequence is what the viewer and the tests use to walk a solve one state at a time without re-applying from the start each time.

is_solved_ignoring is how F2L cases are validated: "the target slot and the first two layers are solved, and we do not care what the last layer looks like."

The solve-track format#

A track is one document saying start here, then play these moves. It is produced by the library, consumed by the 3D viewer and the flat net, and it is the only shape in which solves travel between Python and JavaScript.

{
  "version": 1,
  "title": "OLL 21 — Double Cross",
  "initial": "UUUUUUUUU...",
  "scramble": "R U2 R' U' R U R' U' R U' R'",
  "orientation": {"x": -25, "y": 32},
  "steps": [
    {"moves": "R U R' U'", "label": "insert pair", "phase": "f2l"},
    {"moves": "R' F R F'", "label": "orient", "phase": "oll"}
  ],
  "savepoints": [{"index": 4, "label": "pair inserted"}],
  "meta": {"caseKey": "oll-21", "source": "rubiks-trainer"}
}
Field Required Meaning
version yes TRACK_VERSION, currently 1. Parsing refuses anything newer
title no Shown above the viewer
initial no 54-char facelet string to start from
scramble no Notation applied to the starting state
orientation no Camera hint, degrees, keys x y z
steps no Ordered labelled chunks; phase colours the timeline segment
savepoints no Bookmarks, index counted in flattened moves
meta no Free-form. caseKey links back to a library case

Rules:

  • initial and scramble are both optional. With neither, the cube starts solved. With both, initial is the starting state and scramble is applied on top of it.
  • steps[].moves is a notation string. The viewer flattens every step into one move list but keeps the boundaries so the timeline can draw segments.
  • savepoints[].index counts flattened moves: 0 is the start state, len(moves) is the end.
  • A track does not have to end solved. A solved cube plus a scramble is a perfectly valid track.
  • Every document round-trips through parse_track/dump_track and through encode_track/decode_track without loss. That is a tested property, not an aspiration.
from app.cube import parse_track, dump_track, encode_track, decode_track

track = parse_track(document)     # -> Track, raises TrackError
document = dump_track(track)      # -> dict
blob = encode_track(track)        # -> base64url string for a URL
track = decode_track(blob)        # -> Track
Form When
/viewer/?scramble=R+U+R%27&alg=... Short, human-typable, hand-editable
/viewer/?t=<base64url of the compact JSON> Anything with steps, labels or savepoints
/viewer/?id=<id> The track was too long for a URL; POST /api/v1/tracks stored it

The first two are portable between instances; ?id= only works against the instance that stored the track.

Scrambles#

scramble(seed=None, base=None, extra_max=None) -> str
scramble_for_case(case, seed=None) -> str

scramble emits base random face turns (default scrambler.base_twists, 18) plus 0..extra_max more (default 6), honouring both config rules: no same-face repeat, no A B A parallel sandwich. Only the six face turns appear, so the output pastes into csTimer. Passing a seed makes it reproducible.

scramble_for_case starts from the case's setup_moves and prefixes a random AUF and, where recognition survives, a random y rotation. See Scrambling for the reasoning.

Both are deterministic given a seed, which is what makes the test suite meaningful.

Thumbnails and the flat net#

net_svg(cube, **opts) -> str
thumbnail_svg(case_or_cube, **opts) -> str

Both return self-contained SVG — no external references, no <image>, no fonts — so they can be inlined in a page, saved, or printed. palette() gives the face-letter to colour map, honouring viewer.colors.

thumbnail_svg accepts either a Cube or a case mask. A mask describes only the stickers that matter for recognition:

{"kind": "oll", "u": [1,0,1,0,1,0,1,0,1], "sides": {"F": [0,1,0]}}

kind selects the diagram style — an OLL thumbnail shows the U face plus the top row of each side; a PLL thumbnail adds permutation arrows; an F2L thumbnail highlights the slot and the pair. Stickers not in the mask are drawn muted, which is what makes a case diagram readable at 48 pixels.

Parity between Python and JavaScript#

app/static/js/cube/ implements the same API in ES modules:

Python JavaScript
notation.py notation.jsparseMoves, formatMoves, invertMoves, expandMoves
state.py state.jsCube, same facelet string
track.py track.jsparseTrack, dumpTrack, encodeTrack, decodeTrack
render.py net.js — the flat net, same layout
viewer3d.js, timeline.js, player.js, geometry.js — the 3D view and transport

Parity is enforced by fixtures, not by discipline. tests/fixtures/cube_vectors.json is generated by the Python engine — every move applied to a solved cube, every case setup, a set of scrambles with their seeds — and the node test runner re-checks all of it against the JavaScript implementation.

docker compose run --rm -e RT_DATABASE_URL=sqlite:////tmp/t.db web pytest tests/test_cube_moves.py
docker compose run --rm jstest

If you change a move table on either side, the fixtures will tell you. If you add a move type, regenerate the fixtures and implement it on both sides in the same change — a green Python suite with a red JS suite is the failure mode this arrangement exists to prevent.

Rules for changing the engine#

  1. No Flask imports, ever. app/cube/ reads config through a guarded get_settings() call that degrades to defaults; it must remain importable with no application context.
  2. Determinism given a seed. No random without a seeded Random instance, no datetime.now().
  3. Purity. No I/O, no globals mutated after import.
  4. The facelet convention is frozen. External tools depend on it.
  5. The track format is versioned. Additive changes keep version: 1; anything that would break an existing permalink needs a bump and a reader for both.

Source: docs/developer/cube-engine.md