erno.js
v0.2.14
Published
An engine for twisty puzzles and the permutations underneath them, rendered to SVG. Zero dependencies.
Maintainers
Readme
erno.js
A tiny engine for twisty puzzles rendered to SVG, twenty-nine of them.
N×N cubes, Skewb & Master Skewb, Pyraminx & Master Pyraminx, Mirror, Void,
Rubik's Tetris, cuboids (Domino, Tower, Floppy, any nx×ny×nz), shape mods
(Fisher, Windmill, Axis, Ghost, Twist, Penrose, Pyramorphix, Mastermorphix)
and corner/edge turners (Compy, Dino, Helicopter). Apply moves in standard
notation, scramble, animate layer turns, and style every sticker. No WebGL,
no canvas, just <svg>.
Sibling of heerich.js (voxels → SVG), sharing its philosophy: a small dependency-free core, declarative API, and crisp vector output with per-sticker data attributes.
The twenty-nine are not all equal citizens. Cubes, cuboids, and cubes fused into one (Siamese, Fused) are the priority: they are what the algebra, the permutation tools and the pattern game are aimed at, what a new feature is proven on first, and what a design decision favours when it has to choose. The minxes, the pyramids, the corner turners and the shape mods are real, tested, and kept working. They are just not who this library is for first.
Named after Ernő Rubik.
Install
npm install erno.jsimport { Erno } from 'erno.js'Or use the UMD build via a <script> tag, and the global Erno is available.
Quick Start
import { Erno } from 'erno.js'
const cube = new Erno({
size: 3,
tile: 40,
camera: { type: 'isometric', angle: 30 },
})
cube.move("R U R' U'")
document.body.innerHTML = cube.toSVG()Moves
move() accepts standard WCA notation, whitespace-separated:
- Face turns:
R L U D F B, with'(counterclockwise) and2(half turn):R,R',R2,R2' - Wide turns:
Rwor lowercaser(outer two layers); on big cubes, prefix the layer count:3Rw/3rturns the outer three layers - Slices:
M E S(odd cubes only: M follows L, E follows D, S follows F) - Rotations:
x y z(whole cube: x follows R, y follows U, z follows F)
cube.move("R U R' U'") // chainable: cube.move("R").move("U")
cube.scramble() // random scramble, returns the sequence
cube.scramble(30) // explicit length
cube.reset() // back to solved
cube.isSolved() // boolean
cube.history // tokens applied since the last reset
Erno.inverse("R U2 f'") // → "f U2' R'"Internally every move is a permutation of facelet indices, derived once from exact integer 3D rotations and cached, so applying a move is a single array shuffle.
The algebra
move() takes plain notation, and it takes the algebra cubers already write:
cube.move("[R, U]") // a commutator: R U R' U'
cube.move("[R: U]") // a conjugate: R U R'
cube.move("(R U)105") // 210 moves, and it comes back solved
cube.move("[R: [U, D]]") // they nest(A)n repeats, ' inverts a move or a whole group, [A, B] is a
commutator and [A: B] a conjugate. Nothing else. No puzzle's notation uses
brackets or parentheses, so the two can never be confused, and every puzzle
takes it, the quick start's Erno included: a Megaminx [A, C]2, a
Siamese [AD, AL]2.
It compresses because understanding compresses. (R U)105 is eight
characters and two hundred and ten moves, and it is eight characters only
if you know that R U has order 105.
Reading a sequence
Running a sequence tells you where the puzzle ended up. effectOf tells you
what the sequence is. Cycles are read off pieces, so this one belongs to
the piece engine: Cube and every puzzle in the catalogue below, but not
the facelet Erno above, which has stickers and nothing behind them:
import { Cube } from 'erno.js'
new Cube().effectOf("[R U' R', D]")
// {
// sequence: "R U' R' D R U R' D'",
// moves: 8,
// cycles: [ [ [-1,-1,1], [-1,1,1], [1,-1,1] ] ], // one three-cycle
// turnedInPlace: [],
// moved: 3,
// order: 3,
// }The cycles are over positions, the way a solver reads a puzzle: whatever is in this slot goes to that one. A piece that comes home but turned is not in a cycle, so it is listed apart; that is a twisted corner or a flipped edge.
It is the quickest way to see why the two brackets are worth teaching:
| sequence | what it is |
|---|---|
| R | two four-cycles, one centre turned, order 4 |
| [R, U] | two swaps and a three-cycle, order 6 |
| [R U' R', D] | one three-cycle, three pieces, order 3 |
| [R: [U, D]] | nothing at all, because U and D commute |
| U2 D2 F2 B2 L2 R2 | six swaps, six centres turned, order 2 |
That last-but-one is the point. Six moves that do nothing is not something a list of moves will ever tell you, and it is obvious the moment you know a commutator only keeps what its two halves disagree about.
The puzzle is left exactly as it was found.
State
State is a facelet string in URFDLB face order, row-major per face, one letter per sticker naming its home face (54 chars for a 3×3, 6N² in general):
cube.getState()
// "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB"
cube.setState('UUU…') // throws on malformed inputThis is the same layout used by common solvers (e.g. Kociemba's), so wiring a solver up is a string away.
Position: saving and restoring
getState() reads the puzzle from outside, and two different placements can
wear the same face. getPosition() reads it from inside, so it round-trips
exactly on every puzzle on the piece engine, Megaminx and shape-shifters
included. (Erno is the one puzzle without an inside: facelets are all it
has, so its getState() already says everything there is to save.)
import { Cube } from 'erno.js'
const cube = new Cube()
const saved = cube.getPosition() // a string; put it in a URL
cube.scramble()
cube.setPosition(saved) // exactly where it was, down to the cubiesetPosition throws rather than half-load, and leaves the puzzle untouched if
it does.
Patterns
A pattern is a position worth reaching that is not the solved one. Capture it
with getPattern() and the puzzle can score itself against it:
const checkerboard = new Cube().move('U2 D2 F2 B2 L2 R2').getPattern()
const cube = new Cube()
cube.distanceTo(checkerboard) // 24 stickers out of place
cube.move('U2 D2 F2 B2 L2 R2')
cube.matches(checkerboard) // true
cube.move('y x') // held another way up
cube.matches(checkerboard) // still true
cube.matches(checkerboard, { anyOrientation: false }) // falsegetPattern() is the facelet string on a plain puzzle and the sticker colours
on a painted one, because two cubies of the same colour are interchangeable and
nobody looking at it could tell them apart. orientations() lists the 24 ways
of holding a cube; a puzzle that cannot be held every way up, like a Domino,
reports the one it is in rather than guessing.
Not every pattern you can imagine is one a puzzle can reach. A turn permutes
pieces but never changes what kind of piece one is, so a pattern that only
asks about the kind is the same however you turn it, and there is nothing to
solve. See site/examples/pattern for a game built on all of this.
The laws of the possible
Every position a sequence can reach obeys three laws, and every cuber has met them the hard way: a cube that came back from a borrower with one corner turned is not scrambled, it is unsolvable. Corner twists sum to a multiple of three, edges flip in pairs, and the corner and edge permutations agree in parity, so two pieces alone can never swap.
move() cannot break them, by theorem. Three verbs do what a thumb does:
import { Cube } from 'erno.js'
const cube = new Cube({ size: 3 })
cube.twistCorner("URF") // 120° about its own axis, like a prankster
cube.lawful()
// { lawful: false, breaks: ["the corner twists add up to 1 mod 3: ..."] }
cube.twistCorner("ULB", "counterclockwise") // a second, the other way
console.assert(cube.lawful().lawful) // the sum is whole again
cube.flipEdge("UF") // alone, unlawful; in pairs, fine
cube.swapPieces("UF", "UB") // rides a real cube symmetry
console.assert(!cube.lawful().lawful)Unlawfulness is an invariant: scramble a damaged cube all you like, and
lawful() still names the law that was broken. That is the whole tragedy
of the borrowed cube, stated as a theorem.
The judge rules welded boards too, and its verdict there is computed rather than quoted: every turn is a permutation of sticker places, the group those turns generate is built inside the engine (Schreier-Sims), and membership decides. On a weld a slab either always comes back to itself or never does, so lawful means reachable, the verdict is complete, and it names the body it convicts. Pieces and slots on a weld spell body-first, the way its moves already do:
import { Siamese } from "erno.js";
const weld = new Siamese().twistCorner("ADLB"); // body A's DLB corner
console.assert(!weld.lawful().lawful);
// "body A stands outside the reach of its own turns: no sequence of
// this weld's moves brings it back"
console.assert(new Siamese().lawful().complete); // exact, not cautiousRenderers that are not this one
The SVG is one consumer of the engine, not the engine. getPieces() hands
any renderer the same truth the SVG draws from: each piece's geometry in its
own space, plus a column-major matrix saying where that space is — ready for
THREE.Matrix4().fromArray(...). Build the geometry once, then move it by
matrix every frame; mid-turn included.
import { Cube } from 'erno.js'
const cube = new Cube({ size: 3 })
const pieces = cube.getPieces({ turn: { move: "R", progress: 0.5 } })
// [{ faces, matrix, moving, warped, ... }, ...] one entry per piece
cube.getViewMatrix() // how the whole puzzle is held, same contract
cube.getFrame() // the frame toSVG would draw in, in world units
cube.getRadius() // the sphere every reachable state fits insidegetFrame() exists because two renderers guessing at margins agree only by
accident: it returns the exact frame toSVG uses, so a WebGL view and the
SVG are the same picture. A puzzle bent by a deform function says
warped: true and hands its geometry back already placed and bent, since a
twist is not a rigid motion and has no matrix.
And the adapter itself ships in the package, built on nothing but these
calls. three is an optional peer, imported lazily on first use, so a
consumer who never asks for WebGL never loads it:
import { createThreeView } from 'erno.js/three'
// in the browser, with `three` installed:
// const view = await createThreeView(container)
// await view.show(cube, { move: "R", progress: 0.5 })
// one shared WebGL context serves every view on the page.
console.assert(typeof createThreeView === "function")Stateless rendering
Render a facelet string without keeping an instance (size is inferred):
import { Cube, Erno } from 'erno.js'
const stateString = new Cube({ size: 3 }).move("R U R' U'").getState()
Erno.renderState(stateString, { camera: { type: 'isometric', angle: 30 } })
// → '<svg …>'Useful for static site generators: render hundreds of algorithm diagrams at build time.
Cubes of any size
new Erno({ size: 2 }) // pocket cube
new Erno({ size: 5 }) // professor's cube, wide turns like 3RwClassic variants
Beyond the N×N cube, erno ships the classic variants, all with the same API
(move / scramble / toSVG / style / setCamera / turn animation):
import { Skewb, Pyraminx, Mirror, Void, Tetris, Megaminx } from 'erno.js'
new Skewb().move("R U' L B")
new Pyraminx().move("U L' R b")
new Mirror().move("R U2 F' D") // shape-shifts like the real thing
new Void().move("M E' S2")
new Tetris().scramble() // solid-colored Tetrimino cubies
new Megaminx().move("A C' F") // a dodecahedron: faces are lettered A–L
new Megaminx().move("R++ D-- U'") // and it reads WCA scramble notationThe bigger siblings come along too: MasterSkewb, MasterPyraminx,
Mastermorphix and Kilominx.
Twenty-nine puzzles in all. Cubes and cuboids of any size, the shape mods
(Fisher, Windmill, Axis, Ghost, Mirror, Twist, Penrose), the turners (Skewb,
Dino, Compy, Master Skewb, Helicopter), the tetrahedra (Pyraminx, Master
Pyraminx, Pyramorphix, Mastermorphix), and three solids that are neither
cube nor tetrahedron: Megaminx and Kilominx on a dodecahedron, and
SkewbDiamond on an octahedron, the Skewb's dual, which is the same four
cuts and moves applied to a different solid.
They run on a generic piece engine (Twisty, also exported): each puzzle is
defined as a base solid plus cut planes, the engine slices it into pieces,
and every piece carries an exact integer rotation, so scramble + inverse
restores the solved state bit for bit, with zero float drift.
- Skewb: WCA Fixed Corner Notation:
R U L Bturn 120° clockwise around a corner ('for counterclockwise). State is 30 facelets in URFDLB order, 5 per face (corners in reading order, center third). - Pyraminx:
U L R Bturn a vertex's two layers, lowercaseu l r bturn just the tips. State is 36 facelets in FLRD face order, reading order per face. Scrambles include random tip moves, WCA style. - Mirror: a 3×3 mechanism sitting off-center inside the cube: the cut
planes sit at the mechanism pivot ± ½ and turns rotate about that pivot,
so blocks stay flush at the cuts while the outer surfaces protrude and
recess, and it shape-shifts exactly like the real puzzle. Full cube notation;
getState()returns virtual URFDLB facelets identical to theErno3×3 (it is isomorphic), so solvers work unchanged. - Void: a 3×3 without centers; the engine renders interior plastic walls, so the holes go all the way through. 48 facelets (8 per face).
- Tetris: the official Rubik's × Tetris cube: 26 solid-colored cubies;
solved, each face reveals one classic Tetrimino in its Tetris color around
that face's center (all but the I, which the real product ships as a
display stand), with exactly two white filler cubies.
isSolved()compares the visible pattern, so same-colored cubies are interchangeable, like the physical puzzle. - Cuboids: any nx×ny×nz box via
new Cuboid({ size: [3, 4, 3] }), with presetsDomino(3×2×3, Ernő Rubik's 1978 pre-cube puzzle),Tower(2×3×2) andFloppy(3×1×3). The engine enforces the physics: quarter turns only about axes with a square cross section (Rthrows on a Domino,R2works), so the box never shape-shifts. Standard cube notation, including slices on odd axes and legal whole-puzzle rotations. - Fisher, Windmill, Axis & Ghost: a 3×3 mechanism rotated inside the
cube shell: Fisher yawed 45°, Windmill 30°, Axis 60° about a corner
diagonal, Ghost by a compound odd angle with uniform pale stickers.
U D R L F Bname the mechanism faces. Turns push pieces out at odd angles and the puzzle shape-shifts; stickers facing off-grid directions report?ingetState()until they realign. - Pyramorphix & Mastermorphix: a 2×2 / 3×3 mechanism inside a tetrahedron: quarter turns are legal but the shell isn't symmetric under them, so both shape-shift wildly. Full cube notation (Mastermorphix includes slices).
- Master Pyraminx: the 4-layer Pyraminx:
utips,Utwo layers,Uwthree. Master Skewb and Compy Cube join the corner-turning family: the cut depth decides the puzzle (shallow Compy caps-and-wings, Dino's edges-only diagonals, Master Skewb's 50 pieces). - Twist: a 3×3 molded with a continuous 90° twist: top and bottom
squares sit axis-aligned with all the twisting in the body, side stickers
are kite-shaped tiles. Full cube notation;
U D Eturns keep the silhouette coherent, side turns shape-shift it, as they do on the real puzzle: a twist cube is a 3×3 mechanism in a moulded shell, and the shell is what changes. - Penrose: the classic three-color shape mod: pairs of adjacent faces share a color and their shared edge (UB, FL, DR, mutually skew and 3-fold symmetric) is rounded off with a big fillet. Every face keeps its sticker grid; curved tiles wrap the rounded edges. Scrambling makes the surface jagged. Full cube notation.
- Dino: corner-turning: twelve edge pieces, cuts along the face
diagonals; moves are corner names (
URF,DBL'…, any letter order). - Helicopter: edge-turning: 180° flips about twelve edge axes (
UF,FR…); eight corners plus twenty-four single-sticker petals.
Color schemes ship as presets for any cube-faced puzzle:
import { SCHEMES, Mirror, Void } from 'erno.js'
new Void({ colors: SCHEMES.japanese }) // blue down, yellow back
new Mirror({ colors: SCHEMES.gold })
// SCHEMES.classic, SCHEMES.japanese, SCHEMES.silver, SCHEMES.goldOr generate them. A seeded hue walk with eased saturation and lightness gives an endless supply of harmonious schemes (concepts borrowed from rampensau, poline and dittoTones):
import { Erno, Void, generateScheme, schemeFrom, generateRamp, nameScheme } from 'erno.js'
const scheme = generateScheme([..."URFDLB"], { seed: 42 })
scheme.name // "Vivid Cyan", every scheme is named
new Erno({ colors: scheme })
schemeFrom('#e63946', [..."URFDLB"]) // whole scheme from one brand color
const ramp = generateRamp(20) // paint pieces along a gradient…
new Void().style(({ piece }) => ({ fill: ramp[piece] }))
// …and scrambling turns it into a mosaic
nameScheme(scheme) // name any scheme after the factSchemes are composed in OKLCH and gamut-mapped by reducing chroma rather
than clipping channels, so a colour that sRGB cannot show is pulled toward
the boundary with its lightness and hue intact instead of drifting. Pass
character: 'pale' | 'muted' | 'deep' | 'vivid' to pick the mood: chroma
and lightness predict how a palette feels far better than hue does.
The conversions are exported for use on their own:
import { oklchToHex, hexToOklch, TETRIS_PALETTE } from 'erno.js'
oklchToHex(0.55, 0.20, 28) // '#cc2823', gamut-mapped
hexToOklch('#cc2823') // [L, C, H]Piece-based puzzles emit data-part, data-face, data-index,
data-color and data-piece on every polygon, and style callbacks receive
{ face, index, letter, piece }. Open gallery.html on the dev server for
a visual test sheet of every puzzle, scrambled and mid-turn.
Cuboids that change shape
A quarter turn about an axis whose cross-section is not square leaves a box misshapen. Both answers to that are real puzzles. A Domino's mechanism cannot make the move, while a 3×3×5 is sold precisely because it can, so it is a policy rather than a law:
import { Cuboid } from 'erno.js'
new Cuboid({ size: [3, 2, 3] }).move("R") // throws
new Cuboid({ size: [3, 3, 5], shapeShift: true }).move("R") // deformsOff (the default) the puzzle refuses the move and tells you to use R2. On,
it shifts shape, reports ? for the stickers that have left the facelet
grid, and still inverts exactly.
Painting
colors sets a fill per FACE. paint sets one per STICKER, at build time,
and unlike a style callback it becomes part of the puzzle's state:
import { Cube, Skewb, Cuboid, Mirror, Tetris, tetrisPaint } from 'erno.js'
// the Tetris cube is a plain 3×3 wearing a paint; this renders
// identically to `new Tetris()`
new Cube({ paint: tetrisPaint })
// tint one face of any mechanism
new Skewb({ paint: ({ letter }) => letter === 'U' ? '#cc2823' : undefined })
// return nothing and the sticker keeps its face colour, so a paint can
// decorate a few stickers without restating the rest
new Cuboid({ size: [4, 4, 4], paint: ({ index }) => index % 2 && '#00489f' })
// and any other mechanism can wear it too
new Mirror({ paint: tetrisPaint })Cube is the plain cube on the piece engine. Use it when a 3×3 has to
carry a paint, since Erno is the facelet representation and has no pieces
to paint. It takes a single number for size; Cuboid takes the triple.
The callback receives { face, index, row, col, letter, piece, pieceIndex,
slot, normal }. A sticker is addressed the way getState() addresses it:
by its face and its place within that face, in the same reading order, so
you can paint one and leave the rest:
// the centre of U, and nothing else
new Cube({ paint: ({ face, row, col }) =>
face === 'U' && row === 1 && col === 1 && '#cc2823' })
// the top row of F
new Cube({ paint: ({ face, row }) => face === 'F' && row === 0 && '#f6ba00' })row and col are given only where a face is square. A Skewb face holds
five stickers and a Megaminx eleven, so a grid there would be a lie. Use
index instead.
To paint by hand, pass a map instead of a callback: face letter to colours in that same reading order, with a hole wherever a sticker should keep its face colour. A bare colour paints the whole face.
new Cube({ paint: {
U: ['#c00', null, '#00f', null, '#fc0', null, null, null, '#0a0'],
D: '#111',
} })A painted puzzle is solved by its pattern, not by its facelets: with
solid-coloured cubies two pieces of the same colour are interchangeable and
orientation stops mattering, exactly like the real Tetris cube. isSolved()
switches to comparing getTints() on its own, so a puzzle painted a single
colour can never be unsolved, however hard you scramble it.
Note tetrisPaint's layout is a hand-found exact-cover solution for the 3×3
(one tetromino per face around its centre, no I piece, two white fillers).
It is not a formula, so it does not generalise to other sizes; anything off
that grid is left untinted.
Subtraction
The sibling of heerich's removeGeometry: drop whole pieces and the engine
draws the interior walls they leave behind, so the holes go all the way
through.
import { Cube, Megaminx, Void } from 'erno.js'
new Cube({ remove: 'centers' }) // this IS the Void cube
new Cube({ size: 5, remove: 'centers' }) // and there is no 5×5 Void on sale
new Megaminx({ remove: 'centers' })
new Cube({ remove: ({ slot }) => slot.every(v => v > 0) }) // one corner gone
new Cube({ remove: { box: [[0, 0, 0], [2, 2, 2]] } }) // a whole octantremove takes a predicate ({ slot, stickers, piece, centroid }) => boolean,
the name 'centers' or 'core', or a { box } region in slot space. What
is left is a real puzzle: it turns, scrambles and inverts exactly, and
Cube({ remove: 'centers' }) renders byte for byte the same as new Void().
Deformation
A deformation bends the picture rather than the puzzle. deform applies a linear map as
the SVG is drawn, so it can squash a Megaminx or stretch a weld without
touching a mechanism.
import { Cube, Megaminx, squash, twist } from 'erno.js'
new Cube({ deform: squash(0.6) })
new Megaminx({ deform: squash(0.6) })
new Cube({ deform: squash(1.7) }) // above 1 stretches
new Cube({ deform: twist(90) }) // a function, not a matrixsquash(k, axis) compresses by k along an axis that defaults to the body
diagonal: I + (k − 1)·nnᵀ. The viewBox widens by the map's largest stretch
so nothing is clipped. Any invertible 3×3 works.
deform also takes a function, for bends no matrix can express. A matrix
turns every point by the same amount; twist(degrees) turns each one by an
amount that grows with its height, which is why it needs to be a function.
The viewBox is then measured by running it over the puzzle's corners rather
than read off the map. A warped puzzle's getPieces() hands back geometry
already placed and bent, flagged warped: true, since there is no matrix for
a renderer to apply.
Because it is only a way of looking, the state is untouched: a deformed puzzle's facelets are its undeformed one's, exactly.
One thing to know, because it is the first thing that goes wrong: a parallel projection looking straight down the axis of a compression cannot see it. On erno's default isometric camera, which looks along the body diagonal, a squashed cube and a plain one are the same picture. Give it a camera off that axis.
Decals
A paint sets a sticker's colour; a decal puts a mark on it. A dice cube, a Sudokube and the spots on Ernő's own Domino are the same mechanisms underneath, printed differently, so they ship as decoration, not as classes.
import { Cube, Domino, DICE_CUBE, SUDOKU_CUBE, DOMINO_PRINT } from 'erno.js'
new Cube(DICE_CUBE) // every cubie is a die; opposite faces sum to 7
new Cube(SUDOKU_CUBE) // 1–9 on every face
new Domino(DOMINO_PRINT) // the 1978 puzzle, spots and allEach of those is a bundle of colors, plastic and decal, because on a
printed cube the colour is not a scheme choice. It is what the puzzle looks
like: a dice cube is black with white pips, a Sudokube white with black
numerals, the Domino cream tiles on black. The marks alone are
dicePips, sudokuDigits and dominoPips, to put on any mechanism you
like.
The callback is addressed exactly like paint, as
({ face, index, row, col, size, letter, piece, slot, normal, fill }), and
returns SVG drawn in a unit square, which the engine lays onto the
sticker:
new Cube({
decal: ({ row, col, fill }) =>
row === col ? `<circle cx="0.5" cy="0.5" r="0.25" fill="${fill}"/>` : null,
})Two things follow from how it works. Marks are printed at build time, so a mark belongs to its cubie and travels with it: scramble the puzzle and the pips go along, which is what a printed cube does. And the mark is laid on the face's own reading directions, so it sits upright when solved and turns with its piece afterwards. A sticker that is not a quadrilateral, a Skewb's triangle or a Megaminx's kite, has no unit square to map and is left bare.
Fusion
The union, and the other half of subtraction: weld two or more boxes into one puzzle. Where they overlap, the wall between them stops being a wall: the buried stickers go, the shared cubies become single pieces, and what is left is the shape you would get by gluing two cubes together.
import { Siamese, Fused } from 'erno.js'
new Siamese() // the classic: two 3×3s sharing a 1×1×3 bar
new Siamese({ offset: [1, 2, 0] }) // sharing a 2×1×3 block instead
new Siamese({ size: 4, offset: [3, 3, 0] })
new Fused({ bodies: [ // a 2×2 grown on the corner of a 3×3
{ size: [3, 3, 3], at: [0, 0, 0] },
{ size: [2, 2, 2], at: [1.5, 1.5, 0.5] },
]})Notation prefixes each face with its body's letter: AU, BR', AF2.
The first body is A and anchors the lattice; each welded body takes the
next letter, and legend() says who is who in those same letters:
new Siamese().legend() is "A: 3 at the origin; B: 3 at 2,2,0".
Bodies must line up cubie to cubie on one lattice; anything else would slice
its neighbour in half, and the constructor says so instead.
The board spec
One string names any board the engine can build: a number is a cube, a
triple is a box, + size @ x,y,z welds bodies on one lattice, and
- name bakes a carve in. boardOf builds it, the instance carries
spec, the canonical printing, and parse and print round-trip exactly,
which is what makes the string a serialization worth sharing.
import { boardOf, Void } from 'erno.js'
boardOf("3") // a 3×3 cube
boardOf("2x2x3") // a cuboid
boardOf("3 + 3 @ 2,2,0") // the classic siamese pair, spelled out
boardOf("3 + 3x2x3 @ 2,0.5,0") // a cube welded to a cuboid
boardOf("3 - centers") // renders byte for byte as new Void()
boardOf("3 - FU").spec // "3 - UF": carve names come back canonical
boardOf("3 + 3 @ 2,2,0 - centers") // a carved weld, judged in fullMisaligned lattices are refused in the constructor's words. A welded spec
carves too: on a weld "centers" means the single-sticker pieces of both
bodies, a named carve speaks body-first (- ADLB), and the carved weld
keeps the exact judge - its blocking holds the holes still, so lawful
still means reachable.
Seeded scrambles
scramble(length) walks random legal moves. Give it a seed and the walk
is deterministic, the same for everyone on every machine, which is what
lets a scramble be part of a challenge's identity:
import { Cube } from 'erno.js'
const a = new Cube({ size: 3 })
const b = new Cube({ size: 3 })
a.scramble(15, 42) // same seed,
b.scramble(15, 42) // same walk, same board after itThe alg value
Declare a sequence against a board without turning it. algOf parses
now, refuses now if it is not real notation, reads the sequence off the
board's own effectOf, and hands back a frozen value with the
notation's constructors: every method returns a new frozen alg, nothing
is ever simplified, and emission preserves compression. The transforms
consult the board's published symmetries, and the board is always left
exactly as found:
import { Cube, algOf } from 'erno.js'
const cube = new Cube({ size: 3 })
const sexy = algOf(cube, "[R, U]")
sexy.order // 6
sexy.cycleNames // cycles in cubers' spelling
sexy.times(6).alg // "([R, U])6": compression kept
sexy.reflect("RL").alg // the other hand, from the mirror table
algOf(cube, "R R'").sameEffect("") // true; .equals("") is falseSymmetries, published
Each mechanism can publish the symmetries of its own solid, derived from
the geometry the way legalMoves() is derived from the one law. A box
publishes its three mirrors; the classic siamese pair publishes five
names; a weld of unequal bodies never publishes exchange, because its
bodies are nobody's image, but a mirror the compound truly has still
appears. Every entry maps tokens to tokens, and every map closes over
the vocabulary, checked at publication:
import { Cube, Siamese } from 'erno.js'
const rl = new Cube({ size: 3 }).symmetries()[0]
rl.map("R") // "L'"
rl.map("M") // "M": the slice rides its pair's double flip
rl.map("Rw") // "Lw'"
const s = new Siamese()
s.symmetries().map(t => t.name) // exchange, swap, flat, diag, anti
const ex = s.symmetries().find(t => t.name === "exchange")
ex.map("AD") // "BU", prime-free: a 180° rotation, not a mirrorCarving a live board
remove exists at construction; carve does it to a board mid-life:
it rebuilds the mechanism with the removal while every surviving piece
keeps its current placement, so the absence is a piece-shaped hole
standing exactly where the piece stood, and it travels under turns the
way the piece would have:
import { Cube } from 'erno.js'
const cube = new Cube({ size: 3 })
cube.scramble(12, 42)
const holed = cube.carve(({ slot }) => slot.join(",") === "1,1,1")
// one corner gone, every other piece exactly where it wasA token whose layer would grab nothing is not a move: canMove and
legalMoves shed it, because turning nothing is not a turn.
Blocking
Fusion supplies the shape. What makes a Siamese cube a puzzle is which turns it refuses, and that comes from one rule:
A turn is possible only if the layer it grabs comes back to itself.
This is the shell symmetry law narrowed from the whole puzzle to a single layer. Whatever the layer leaves behind is still in the way, so a layer that would land somewhere else cannot go. Ask a puzzle what it can do:
const s = new Siamese()
s.legalMoves() // AD AD' AD2 AL AL' AL2 BU BU' BU2 BR BR' BR2
s.canMove('AF') // false
s.move('AF') // throws: the layer does not come back to itself
s.scramble(20) // walks the legal moves rather than replaying a fixed stringNothing lists those twelve moves anywhere. The shared bar runs up one corner of each cube, so each cube keeps exactly the two faces furthest from the weld, which is what the real puzzle does in your hands.
The same law reproduces rules the library used to state by hand. A Domino refuses its quarter turns because a 3×1×3 layer spun about x comes back 3×3×1; switch blocking on over a cuboid that is allowed to deform and you get the identical move list, size for size:
new Cuboid({ size: [3, 2, 3], shapeShift: true, blocking: true }).legalMoves()
// same as new Cuboid({ size: [3, 2, 3] }).legalMoves()legalMoves() and canMove() answer on every puzzle, not just blocking ones:
a move the notation refuses outright counts as unavailable too.
One consequence worth knowing: because the law is about the shape of the material, subtraction takes part in it. A Void cube keeps all eighteen turns (its holes are symmetric), but hollowing out one corner and switching blocking on will close the turns that no longer come back to themselves.
Bandaging
Fusion welds bodies; bandaging welds cubies inside one. It is the other way a twisty puzzle gets its blocked turns, and it runs through the same law.
import { Cube, Fused } from 'erno.js'
// the Fused Cube: a 2×2×2 block set into a 3×3
new Cube({ bandage: ({ slot }) => slot.every(v => v >= 0) ? 'block' : null })
// → 20 pieces, and only D, L and B still turn
// or name the slots outright: here the U centre glued to the UF edge
new Cube({ bandage: [[[0, 1, 1], [0, 1, 0]]] })
// → F is the only turn it costsbandage takes a grouping function ({ slot, piece, centroid }) => key,
where pieces answering with the same key are welded, or a plain list of slot
groups. It switches blocking on by default, since a glued pair with nothing
forbidden would simply tear.
Add stickerGroup: true to make the glue visible: the welded cubies then wear
one tile per face instead of a grid pretending they come apart.
new Cube({
bandage: ({ slot }) => slot.every(v => v >= 0) ? 'block' : null,
stickerGroup: true,
})Camera
Four projection types, sharing semantics with heerich:
- isometric (default):
{ angle }: pitch locked to 35.264°. The classic cube-diagram look. - orthographic:
{ angle, pitch }: true parallel projection, free tilt. - oblique:
{ angle, depth }: front face undistorted, depth recedes atangle;depthis the fraction of a tile per cubie of depth (default 0.5). - perspective:
{ position: [x, y], distance }in cubie units, single vanishing point.
cube.setCamera({ type: 'perspective', position: [5, -2], distance: 12 })Styling
Sticker fills come from the color scheme (colors option, keyed by home face
letter). Override per sticker with a static object or a callback:
// static: merged into every sticker
cube.style({ stroke: '#111', strokeWidth: 2 })
// per sticker: ({face, row, col, letter}) → style | null
// face/row/col = where the sticker sits now; letter = its home face (color)
cube.style(({ letter }) => letter === 'U' ? null : { fill: '#d4d4d4' })Style objects use camelCase SVG attributes (fill, stroke, strokeWidth,
opacity, …). Returning null keeps the default.
Cube body options:
new Erno({
plastic: '#0d0d0d', // gap/core color
stickerInset: 0.12, // sticker inset per cell; 0 = stickerless look
colors: { U: '#fff', R: '#b71234', F: '#009b48',
D: '#ffd500', L: '#ff5800', B: '#0046ad' },
})SVG output & interactivity
Every sticker is a <polygon> with data attributes:
<polygon points="…" fill="#009b48"
data-part="sticker" data-face="F" data-row="0" data-col="2" data-color="F" />data-part:sticker,plastic(cell backing) orcore(internals exposed mid-turn)data-face/data-row/data-col: the position on the cubedata-color: the sticker's home face (its color identity)
So highlighting, hovering and click-handling are plain CSS/JS:
svg [data-face="U"]:hover { opacity: 0.7; }toSVG(options):
padding: viewBox padding in px (default 20)viewBox: explicit[x, y, w, h]overridefitSphere: size the viewBox to the cube's circumsphere so it stays stable across moves and animation framesturn: mid-turn snapshot, see belowprepend/append: raw SVG inserted before/after the faces
Animating turns
toSVG can render a layer mid-turn, and the internal core is drawn where the
cube opens up:
cube.toSVG({ turn: { move: 'R', progress: 0.5 }, fitSphere: true })The state is not changed by rendering; animate with requestAnimationFrame
and apply the move when the turn completes:
function play(token, duration = 180) {
const start = performance.now()
function frame(now) {
const p = Math.min(1, (now - start) / duration)
el.innerHTML = cube.toSVG({ turn: { move: token, progress: p }, fitSphere: true })
if (p < 1) requestAnimationFrame(frame)
else { cube.move(token); el.innerHTML = cube.toSVG({ fitSphere: true }) }
}
requestAnimationFrame(frame)
}Use fitSphere: true (or a fixed viewBox) while animating so the image
doesn't jump between frames.
Advanced: getFaces()
getFaces(turn?) returns the projected, depth-sorted face list (points,
face, row, col, letter, part, depth) if you want to build your own
renderer on top.
Development
npm run dev: Vite dev server with the demo page (/) and the visual test gallery (/gallery.html)npm test: run both test suites (plain Node, no framework)npm run build: build the library todist/(UMD + ESM)npm run build:site: build the demo page todist-site/
Source layout mirrors heerich: src/erno.js (N×N facelet cube, package
entry), src/twisty.js (generic piece engine + notation), src/puzzles.js
(Skewb, Pyraminx, Mirror, Void, color schemes), src/render.js (shared
cameras, culling, depth sort, SVG emission).
License
MIT
