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:
MfollowsLEfollowsDSfollowsFxfollowsR,yfollowsU,zfollowsF
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
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:
initialandscrambleare both optional. With neither, the cube starts solved. With both,initialis the starting state andscrambleis applied on top of it.steps[].movesis a notation string. The viewer flattens every step into one move list but keeps the boundaries so the timeline can draw segments.savepoints[].indexcounts flattened moves:0is 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_trackand throughencode_track/decode_trackwithout 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
Permalinks#
| 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.js — parseMoves, formatMoves, invertMoves, expandMoves |
state.py |
state.js — Cube, same facelet string |
track.py |
track.js — parseTrack, 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#
- No Flask imports, ever.
app/cube/reads config through a guardedget_settings()call that degrades to defaults; it must remain importable with no application context. - Determinism given a seed. No
randomwithout a seededRandominstance, nodatetime.now(). - Purity. No I/O, no globals mutated after import.
- The facelet convention is frozen. External tools depend on it.
- 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.