twistysvg
v0.1.0
Published
Render, animate, and interact with twisty puzzles as SVG — React components for NxN cubes, pyraminx, skewb, megaminx, square-1, clock, and FTO with alg playback, masks, arrows, PNG/GIF export
Maintainers
Readme
twistysvg
Render, animate, and interact with twisty puzzles as SVG — in React or anywhere. NxN cubes, Pyraminx, Skewb, Megaminx, Square-1, Clock, and FTO (face-turning octahedron).
A modern successor to puzzle-gen / visualcube: the same case-diagram vocabulary (masks, sticker colors, arrows, plan view), plus things they don't do — smooth turn animation with play/pause/step/seek, drag-to-turn interaction, SSR-safe React components, and PNG/GIF export.
- Zero-dependency core (
twistysvg): cube state, move notation, geometry, scene computation, playback engine. Runs in Node and the browser. - React bindings (
twistysvg/react):<Cube>,<CubePlayer>,<InteractiveCube>,useAlgPlayer,useCubeState. React is an optional peer dependency. - Export (
twistysvg/export): SVG / PNG / animated GIF.
All WCA puzzles are supported with full parity: static images, scramble/alg application in each puzzle's official notation, smooth turn animation with play/pause/step/seek, drag interaction, and PNG/GIF export.
| puzzle | notation | extras |
| --- | --- | --- |
| NxN cubes ('cube', size) | R U' Rw 3Rw M x … | plan view, mask presets, hint facelets, transparent |
| Pyraminx | U L R B + tips u l r b | |
| Skewb | WCA fixed-corner U L R B | |
| Megaminx | 12 face names + R++ D-- U' (verified against TNoodle) | LL plan view |
| Square-1 | (3,-2) / with slice-legality checking | shape-shifting render, layer-drag + tap-to-slice |
| Clock | UR3+ DL2- y2 + trailing pins | front+back 2D view |
| FTO | U F R L D B BR BL | face-turning octahedron with corner-tip layers |
Install
npm install twistysvgStatic images
import { Cube } from 'twistysvg/react';
// a scrambled cube
<Cube setup="R U R' U' F2 D L B'" />
// an OLL case diagram: `case` shows the state the alg solves
<Cube view="plan" mask="oll" case="r U R' U' r' F R F'" />
// PLL diagram with arrows
<Cube
view="plan"
mask="pll"
case="R U R' U' R' F R2 U' R' U' R U R' F'"
arrows={[{ from: 'U2', to: 'U8', headStart: true }]}
/>Components are SSR-safe: the output is plain <svg> with no ids, no defs,
and no effects required, so it renders identically on the server.
Props (shared visual vocabulary)
| prop | meaning |
| --- | --- |
| size | cube size N (default 3) |
| setup | alg applied to the solved cube |
| case | inverse-of-alg applied (visualcube semantics for case diagrams) |
| view | '3d' (default) or 'plan' (top-down case diagram) |
| camera | { yaw, pitch, projection, distance } |
| scheme | color scheme overrides (U R F D L B, body, dim, arrow, background) |
| mask | preset name ('oll', 'pll', 'f2l', 'cll', 'ell', 'cross', …) or per-sticker { U4: 'hidden' } |
| stickerColors | raw color per sticker, e.g. { U0: '#e91e63' } |
| arrows | { from, to, via?, color?, width?, headStart?, headEnd? }[] |
| hintFacelets | show hidden faces floating behind the cube (true or a gap distance); they animate with turns and are draggable in <InteractiveCube> |
| transparent | see-through cube: no plastic, translucent stickers, back faces visible |
Stickers are addressed as U0…U8, F4, etc. — row-major per face in the
standard Kociemba/visualcube reading order, so existing visualcube
configurations translate directly.
Hidden faces
// twizzle-style hint facelets: hidden faces float behind the cube,
// animate with turns, accept arrows, and stay draggable
<Cube setup="R U R' U'" hintFacelets />
<Cube setup="R U R' U'" hintFacelets={4} /> // wider gap
// see-through cube: no plastic, translucent stickers
<Cube setup="R U R' U'" transparent />Any WCA puzzle
import { TwistyPuzzle, TwistyPlayer, usePuzzlePlayer, InteractivePuzzle, InteractiveSquare1 } from 'twistysvg/react';
// static images in each puzzle's notation
<TwistyPuzzle puzzle="pyraminx" setup="U L' R B u'" />
<TwistyPuzzle puzzle="megaminx" view="plan" case="R U R' U R U2' R'" />
<TwistyPuzzle puzzle="clock" setup="UR3+ DL2- ALL4+ y2 UR" />
// animated player for any puzzle
const handle = usePuzzlePlayer({ puzzle: 'square1', alg: '(1,0) / (3,-3) /' });
<TwistyPlayer player={handle} controls />
// drag-to-turn for the rotation puzzles; square-1 gets layer dragging
// with 30° snapping and tap-to-slice (with legality checking)
<InteractivePuzzle puzzle="skewb" onMove={(turn, notation) => console.log(notation)} />
<InteractiveSquare1 onIllegalSlice={() => flash()} />Animated player
import { CubePlayer, useAlgPlayer } from 'twistysvg/react';
function TPerm() {
const handle = useAlgPlayer({
alg: "R U R' U' R' F R2 U' R' U' R U R' F'",
tps: 2.5, // quarter turns per second
loop: true,
});
return <CubePlayer player={handle} controls />;
}handle.player exposes the full imperative API: play() pause() stop()
seekTo(ms) seekToMove(i) stepForward() stepBackward() setSpeed(tps)
setAlg(alg). The handle's status, currentMoveIndex, moves, and
totalMs are reactive.
Animation never re-renders your React tree: per-frame updates patch SVG attributes directly through a keyed differ, while React only re-renders on status/move transitions.
Interactive cube
import { InteractiveCube } from 'twistysvg/react';
<InteractiveCube
defaultState="R U R' U' F2 D"
onMove={(move, notation, next) => console.log(notation)}
/>Drag a sticker to turn its layer (the drag direction picks the move); drag the background to orbit. Pointer Events only — works with touch.
Headless core
import { applyAlg, createSolved, computeScene, resolveSceneInput, sceneToSvgString } from 'twistysvg';
const state = applyAlg(createSolved(3), "R U R' U'");
const svg = sceneToSvgString(computeScene(resolveSceneInput({ state, mask: 'll' })));computeScene is a pure function from state + camera (+ partial turn) to
ordered, culled 2D polygons; the SVG string renderer, the imperative SVG DOM
patcher, and the canvas rasterizer all consume the same scenes. Mid-turn
rendering splits the cube into convex slabs ordered by the cut planes, so
there is no depth sorting to glitch.
Export
import { exportSvg, exportPng, exportGif, downloadBlob } from 'twistysvg/export';
const png = await exportPng({ setup: 'R U F' }, { width: 512 });
const gif = await exportGif(
{ alg: "R U R' U'", setup: '' },
{ tps: 2.5, fps: 25, width: 320, loop: true, onProgress: (done, total) => {} },
);
downloadBlob(gif, 'sexy-move.gif');GIF encoding (gifenc) runs in the
browser, renders frames straight to canvas (no SVG rasterization round
trip), supports AbortSignal, and yields to the event loop while encoding.
Notation
U D L R F B with ', 2, 2'; wide moves Rw / r / 3Rw; slices
M E S; rotations x y z; parentheses for grouping. Parse errors carry the
offending token's offset.
Known limitations
- Square-1 slice strobing: the 180° slice flip can shimmer — seam lines
on the flipping half move many times their width per frame (temporal
aliasing inherent to fast flat-shaded flips; measured against a 3x3
cube baseline with the Playwright harness in
scripts/flicker-hunt.mjs). Mitigations shipped: exact painter ordering, solid seam geometry, a mid-flip deceleration warp, and slower slice timing. Slowing slices further reduces it; a shaded (WebGL) renderer would eliminate it. - Mask presets are cube-only; plan view is cube + megaminx; clock is render-only (no drag interaction).
Development
npm install
npm run dev # Vite playground
npm test # Vitest
npm run build # tsup → dist (ESM + CJS + d.ts)License
MIT
