For the complete index, see /llms.txt. A Markdown version of any documentation page is available by appending .md to its URL or by sending an Accept: text/markdown header.

Browse documentation

Cube geometry

An N×N×N twisty cube as exact integer state: cubies on a lattice, each with an orientation matrix, plus moves, notation, scrambles, sticker lookup, the drag geometry and a layer-by-layer solver.

idle
variant
order
3

Grab a face and the layer turns with your hand; let go and it snaps. Drag the background to orbit. Click the cube and type U D L R F B — shift for anticlockwise, S scrambles, H hints, enter solves it, backspace undoes, escape resets.

time
0.0s
your turns
0
state
solved
last
—
Theming

Set a role and the same CSS goes in your own app — every robot under it follows.

Install

bunx --bun shadcn@latest add https://robocn.dev/r/cube-geometry.json

Notes

  • State is exact integer arithmetic: lattice indices and 3×3 matrices of -1, 0 and 1. A cube turned ten thousand times is bit-identical to one turned none, so `isSolved` is a comparison rather than a tolerance.
  • Clockwise means clockwise **looking at that face from outside**, the way notation means it. The sign flip that costs — negative right-handed about a positive normal — lives in `moveToTurn` and nowhere else.
  • It does solve a cube, and it says how: the beginner's layer-by-layer method, written as a staged iterative-deepening search over an alphabet of *macros* — the U turns plus one standard algorithm rotated into each of the four side slots — rather than as a hundred hand-cased positions. A small search over big steps. The line is then cancelled down and **replayed**; if the replay is not solved, `solveCube` returns `null` rather than a line it has not checked.
  • The search runs on a packed encoding — where each piece sits, and which of the 24 rotations it carries — so a turn is 27 table lookups and no allocation, and a scrambled 3×3 is solved in single-digit milliseconds. It is not optimal: a beginner method spends about 120 moves where God's number is 20, and this one is honest about being the method it is.
  • 3×3 only. A 4×4 has parities the method knows nothing about, so every other order is `null`.
  • Pure functions over plain objects: no React, no three.js, no dependencies.

Usage

import { applyMoves, createCube, isSolved, scrambleMoves, solveCube } from "@/lib/robocn/cube"

const scrambled = applyMoves(createCube(3), scrambleMoves(3, 25, 7))

const line = solveCube(scrambled)          // the moves that take it home, or null
isSolved(applyMoves(scrambled, line!))     // true — and it was checked before you got it

API

exports
PropTypeDefaultDescription
createCube(order?)(order?: number) => CubeState—A solved cube, `order` cubies to a side. 2 … 7, clamped.
applyMove(state, move)(state: CubeState, move: CubeMove) => CubeState—The state after one turn. Immutable — the previous state is untouched.
applyMoves(state, moves)(state: CubeState, moves: CubeMove[]) => CubeState—A whole algorithm, in order.
isSolved(state)(state: CubeState) => boolean—Every sticker showing its own face — which is what a person means by solved, and what a spun centre does not break. Exact integer comparisons, never a tolerance.
parseMove / parseAlgorithm(text: string) => CubeMove | null—The notation people type: `R`, `U'`, `F2`, `2R'`. Anything else is dropped rather than guessed at.
formatMove / formatAlgorithm(move: CubeMove) => string—The same notation back out.
invertMove / invertMoves(moves: CubeMove[]) => CubeMove[]—The undo of a sequence: reversed, each turn the other way.
scrambleMoves(order?, count?, seed?)(order?: number, count?: number, seed?: number) => CubeMove[]—A deterministic scramble — same seed, same shuffle — never repeating a face back to back.
moveToTurn / turnToMove(move: CubeMove, order: number) => CubeTurn—A person's move (clockwise from outside) as the renderer's turn (right-handed about the positive axis), and back.
moveFromDrag(drag)(drag: CubeDrag) => CubeMove | null—The turn a drag across a face asks for: the grabbed face, the grabbed cubie and a direction in world units. Null when the drag has no direction in the plane of the face.
grabFromDrag(drag)(drag: CubeDrag) => CubeGrab | null—The same drag with the tangent as well — the direction the hand keeps pulling to keep winding that turn. What a rig follows the pointer with.
solveCube(state)(state: CubeState) => CubeMove[] | null—A line that takes this cube home, by a layer-by-layer method searched over a macro alphabet. Replayed and checked before it is returned; 3×3 only, and `null` on anything else rather than a guess.
solveStep(state)(state: CubeState) => CubeMove | null—The next move of that solve, which is what a hint is.
simplifyMoves(moves)(moves: CubeMove[]) => CubeMove[]—`R R` → `R2`, `R R'` → nothing. Same face and same layer only — a line as short as it was going to be without searching again.
stickerFace(cubie, face)(cubie: Cubie, face: CubeFace) => CubeFace—Which face's colour the sticker on that side of the cubie now shows.
exposedFaces / shellCubies(cubie: Cubie, order: number) => CubeFace[]—The sides of a cubie that are on the outside, and the cubies worth drawing at all.

Source

src/lib/robocn/cube.ts
/**
 * cube — an N×N×N twisty puzzle as state, not as a pose.
 *
 * Every other solver in the set answers "where do the joints go for this
 * target". A twisty cube has no target: its whole configuration is a
 * permutation, and the only continuous quantity is how far through a turn it
 * currently is. So the state here is a lattice of cubies, each carrying an
 * integer orientation matrix, and a move is one exact matrix multiply — no
 * floating point ever enters the state, and `isSolved` is "every orientation
 * is the identity" rather than a string compare. Notes: `docs/rubiks-cube.md`.
 *
 * Pure functions over plain objects. No React, no three.js, no dependencies —
 * the drag maths the r3f rig runs on is the same code the tests sample.
 */

/** The six faces, in the order the standard colour scheme lists them. */
export type CubeFace = "U" | "D" | "L" | "R" | "F" | "B"

export type CubeAxis = "x" | "y" | "z"

export const cubeFaces: CubeFace[] = ["U", "D", "L", "R", "F", "B"]

/** Outward unit normal of each face, in the rig's world axes: x right, y up, z out. */
export const faceNormals: Record<CubeFace, readonly [number, number, number]> = {
  U: [0, 1, 0],
  D: [0, -1, 0],
  L: [-1, 0, 0],
  R: [1, 0, 0],
  F: [0, 0, 1],
  B: [0, 0, -1],
}

const axisIndex: Record<CubeAxis, 0 | 1 | 2> = { x: 0, y: 1, z: 2 }
const axisOrder: CubeAxis[] = ["x", "y", "z"]

/** The axis a face turns about, and whether its normal points up that axis. */
export const faceAxis = (face: CubeFace): CubeAxis =>
  face === "U" || face === "D" ? "y" : face === "L" || face === "R" ? "x" : "z"

const facePositive = (face: CubeFace) => face === "U" || face === "R" || face === "F"

/**
 * A 3×3 integer matrix, row-major, mapping the cubie's own axes onto the
 * world's. Integer because every entry is only ever -1, 0 or 1.
 */
export type CubeMatrix = readonly [
  number, number, number,
  number, number, number,
  number, number, number,
]

export const identityMatrix: CubeMatrix = [1, 0, 0, 0, 1, 0, 0, 0, 1]

/** One piece: where it sits on the lattice, and which way it points. */
export interface Cubie {
  /** Lattice indices, 0 … order-1 along x, y and z. */
  i: number
  j: number
  k: number
  orientation: CubeMatrix
}

export interface CubeState {
  order: number
  cubies: Cubie[]
}

/**
 * A move the way a person says it: quarter turns **clockwise looking at that
 * face from outside**, on a layer counted inward from that face.
 */
export interface CubeMove {
  face: CubeFace
  /** 0 is the face itself; 1 the layer behind it. */
  layer: number
  /** Quarter turns, 1 … 3. 3 is the same as one anticlockwise. */
  turns: number
}

/**
 * The same move the way the renderer wants it: quarter turns **right-handed
 * about the positive axis**, on a slice indexed along that axis.
 */
export interface CubeTurn {
  axis: CubeAxis
  slice: number
  quarterTurns: number
}

const clampOrder = (order: number) =>
  Number.isFinite(order) ? Math.max(2, Math.min(7, Math.round(order))) : 3

/* ----------------------------------------------------------------- matrices */

/** Right-handed rotation about `axis` by `quarterTurns` × 90°, exactly. */
export function rotationMatrix(axis: CubeAxis, quarterTurns: number): CubeMatrix {
  const q = ((Math.round(Number.isFinite(quarterTurns) ? quarterTurns : 0) % 4) + 4) % 4
  const c = [1, 0, -1, 0][q]
  const s = [0, 1, 0, -1][q]
  if (axis === "x") return [1, 0, 0, 0, c, -s, 0, s, c]
  if (axis === "y") return [c, 0, s, 0, 1, 0, -s, 0, c]
  return [c, -s, 0, s, c, 0, 0, 0, 1]
}

export function multiplyMatrix(a: CubeMatrix, b: CubeMatrix): CubeMatrix {
  const out = new Array(9).fill(0)
  for (let row = 0; row < 3; row++) {
    for (let column = 0; column < 3; column++) {
      out[row * 3 + column] =
        a[row * 3] * b[column] +
        a[row * 3 + 1] * b[3 + column] +
        a[row * 3 + 2] * b[6 + column]
    }
  }
  return out as unknown as CubeMatrix
}

export function applyMatrix(
  matrix: CubeMatrix,
  vector: readonly [number, number, number],
): [number, number, number] {
  return [
    matrix[0] * vector[0] + matrix[1] * vector[1] + matrix[2] * vector[2],
    matrix[3] * vector[0] + matrix[4] * vector[1] + matrix[5] * vector[2],
    matrix[6] * vector[0] + matrix[7] * vector[1] + matrix[8] * vector[2],
  ]
}

const transpose = (m: CubeMatrix): CubeMatrix =>
  [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]] as unknown as CubeMatrix

/* -------------------------------------------------------------------- state */

/** A solved cube of `order` cubies to a side. */
export function createCube(order = 3): CubeState {
  const n = clampOrder(order)
  const cubies: Cubie[] = []
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      for (let k = 0; k < n; k++) {
        cubies.push({ i, j, k, orientation: identityMatrix })
      }
    }
  }
  return { order: n, cubies }
}

/**
 * Solved is what a person holding one means by solved: **every face one
 * colour**. Not "every orientation is the identity", and not even "every
 * sticker shows its own face" — both of those call a cube unsolved that
 * nobody would, because both count things you cannot see. A centre spun about
 * its own normal shows the same square; a cube turned round in your hands, or
 * one whose middle slices have been turned, has its whites somewhere else and
 * is still solved.
 *
 * It is no weaker for it: nine stickers of each colour means two faces cannot
 * show the same one, so six uniform faces is a solved cube. And it is exact —
 * integer comparisons through `stickerFace`, never a tolerance.
 */
export function isSolved(state: CubeState): boolean {
  const cubies = state?.cubies
  if (!cubies?.length) return false
  const shown: Partial<Record<CubeFace, CubeFace>> = {}
  for (const cubie of cubies) {
    for (const face of exposedFaces(cubie, state.order)) {
      const shows = stickerFace(cubie, face)
      const seen = shown[face]
      if (seen === undefined) shown[face] = shows
      else if (seen !== shows) return false
    }
  }
  return true
}

/** Lattice index → centred coordinate, doubled so it stays an integer. */
const centred = (index: number, order: number) => 2 * index - (order - 1)
const uncentred = (value: number, order: number) => (value + order - 1) / 2

/* -------------------------------------------------------------------- moves */

/** A person's move as the renderer's turn. */
export function moveToTurn(move: CubeMove, order: number): CubeTurn {
  const n = clampOrder(order)
  const face = cubeFaces.includes(move?.face) ? move.face : "U"
  const layer = Number.isFinite(move?.layer)
    ? Math.max(0, Math.min(n - 1, Math.round(move.layer)))
    : 0
  const turns = Number.isFinite(move?.turns) ? Math.round(move.turns) : 1
  const positive = facePositive(face)
  return {
    axis: faceAxis(face),
    slice: positive ? n - 1 - layer : layer,
    // Clockwise from outside is a negative right-handed turn about a positive
    // normal, and a positive one about a negative normal. The whole sign
    // convention of the library lives in this line.
    quarterTurns: (((positive ? -turns : turns) % 4) + 4) % 4,
  }
}

/** The renderer's turn as the move a person would write. */
export function turnToMove(turn: CubeTurn, order: number): CubeMove {
  const n = clampOrder(order)
  const axis = axisOrder.includes(turn?.axis) ? turn.axis : "y"
  const slice = Number.isFinite(turn?.slice)
    ? Math.max(0, Math.min(n - 1, Math.round(turn.slice)))
    : 0
  const quarters = (((Math.round(turn?.quarterTurns ?? 0) % 4) + 4) % 4)
  const [positiveFace, negativeFace]: [CubeFace, CubeFace] =
    axis === "x" ? ["R", "L"] : axis === "y" ? ["U", "D"] : ["F", "B"]
  // Name it after the nearer face, which is what a person would have called it.
  const fromPositive = slice >= n / 2
  return {
    face: fromPositive ? positiveFace : negativeFace,
    layer: fromPositive ? n - 1 - slice : slice,
    turns: quarters === 0 ? 0 : fromPositive ? 4 - quarters : quarters,
  }
}

/** Is this cubie in the slice the turn moves? */
export const inTurn = (cubie: Cubie, turn: CubeTurn): boolean =>
  (turn.axis === "x" ? cubie.i : turn.axis === "y" ? cubie.j : cubie.k) ===
  turn.slice

/** The state after one turn. Immutable: the previous state is untouched. */
export function applyTurn(state: CubeState, turn: CubeTurn): CubeState {
  const n = state.order
  const quarters = (((Math.round(turn?.quarterTurns ?? 0) % 4) + 4) % 4)
  if (!quarters || !axisOrder.includes(turn?.axis)) return state
  const slice = Math.max(0, Math.min(n - 1, Math.round(turn.slice)))
  const rotation = rotationMatrix(turn.axis, quarters)

  return {
    order: n,
    cubies: state.cubies.map((cubie) => {
      if (!inTurn(cubie, { ...turn, slice })) return cubie
      const position: [number, number, number] = [
        centred(cubie.i, n),
        centred(cubie.j, n),
        centred(cubie.k, n),
      ]
      const moved = applyMatrix(rotation, position)
      return {
        i: uncentred(moved[0], n),
        j: uncentred(moved[1], n),
        k: uncentred(moved[2], n),
        orientation: multiplyMatrix(rotation, cubie.orientation),
      }
    }),
  }
}

export const applyMove = (state: CubeState, move: CubeMove): CubeState =>
  applyTurn(state, moveToTurn(move, state.order))

export const applyMoves = (state: CubeState, moves: CubeMove[]): CubeState =>
  (moves ?? []).reduce(applyMove, state)

export const invertMove = (move: CubeMove): CubeMove => ({
  ...move,
  turns: (4 - ((((move?.turns ?? 1) % 4) + 4) % 4)) % 4 || 4,
})

export const invertMoves = (moves: CubeMove[]): CubeMove[] =>
  [...(moves ?? [])].reverse().map(invertMove)

/* ------------------------------------------------------------------ writing */

/** `R`, `U'`, `F2`, `2R'` — face, optional depth prefix, optional modifier. */
export function parseMove(token: string): CubeMove | null {
  const match = /^([2-7])?([UDLRFB])(['2₂]?)$/.exec((token ?? "").trim())
  if (!match) return null
  const layer = match[1] ? Number(match[1]) - 1 : 0
  const turns = match[3] === "'" ? 3 : match[3] === "" ? 1 : 2
  return { face: match[2] as CubeFace, layer, turns }
}

export function parseAlgorithm(text: string): CubeMove[] {
  return (text ?? "")
    .split(/[\s,]+/)
    .filter(Boolean)
    .map(parseMove)
    .filter((move): move is CubeMove => move !== null)
}

export function formatMove(move: CubeMove): string {
  const turns = (((move?.turns ?? 1) % 4) + 4) % 4 || 4
  const suffix = turns === 1 ? "" : turns === 2 ? "2" : turns === 3 ? "'" : ""
  const depth = move.layer > 0 ? String(move.layer + 1) : ""
  return `${depth}${move.face}${suffix}`
}

export const formatAlgorithm = (moves: CubeMove[]): string =>
  (moves ?? []).map(formatMove).join(" ")

/* ---------------------------------------------------------------- scramble */

/** A deterministic 32-bit generator, so a seed always scrambles the same way. */
function generator(seed: number) {
  let value = (Number.isFinite(seed) ? Math.floor(seed) : 1) >>> 0 || 0x9e3779b9
  return () => {
    value ^= value << 13
    value >>>= 0
    value ^= value >>> 17
    value ^= value << 5
    value >>>= 0
    return value / 0x100000000
  }
}

/**
 * A scramble that reads like a scramble: never the same face twice running,
 * and never a move that only undoes the one before it.
 */
export function scrambleMoves(order = 3, count = 20, seed = 1): CubeMove[] {
  const n = clampOrder(order)
  const random = generator(seed)
  const length = Number.isFinite(count) ? Math.max(0, Math.round(count)) : 20
  const moves: CubeMove[] = []
  let previous: CubeFace | null = null
  while (moves.length < length) {
    const face = cubeFaces[Math.floor(random() * cubeFaces.length) % 6]
    if (face === previous) continue
    previous = face
    moves.push({
      face,
      layer: Math.floor(random() * Math.floor(n / 2)),
      turns: 1 + Math.floor(random() * 3),
    })
  }
  return moves
}

/* --------------------------------------------------------------- stickers */

/**
 * Which face's colour the sticker on `face` of this cubie shows. The
 * orientation maps the cubie's own axes onto the world's, and this question
 * runs the other way, so it is the transpose that is applied.
 */
export function stickerFace(cubie: Cubie, face: CubeFace): CubeFace {
  const local = applyMatrix(transpose(cubie.orientation), faceNormals[face])
  for (const candidate of cubeFaces) {
    const normal = faceNormals[candidate]
    if (
      local[0] === normal[0] &&
      local[1] === normal[1] &&
      local[2] === normal[2]
    ) {
      return candidate
    }
  }
  return face
}

/** The faces of this cubie that are on the outside of the cube. */
export function exposedFaces(cubie: Cubie, order: number): CubeFace[] {
  const n = clampOrder(order)
  const faces: CubeFace[] = []
  if (cubie.i === n - 1) faces.push("R")
  if (cubie.i === 0) faces.push("L")
  if (cubie.j === n - 1) faces.push("U")
  if (cubie.j === 0) faces.push("D")
  if (cubie.k === n - 1) faces.push("F")
  if (cubie.k === 0) faces.push("B")
  return faces
}

/** Cubies with at least one sticker showing — the only ones worth drawing. */
export const shellCubies = (state: CubeState): Cubie[] =>
  state.cubies.filter((cubie) => exposedFaces(cubie, state.order).length > 0)

/* -------------------------------------------------------------------- drag */

export interface CubeDrag {
  /** The face the pointer went down on. */
  face: CubeFace
  /** The cubie it went down on. */
  cubie: Pick<Cubie, "i" | "j" | "k">
  /** Pointer travel in world units, in the same axes as the cube. */
  direction: readonly [number, number, number]
  order: number
}

export interface CubeGrab {
  /** The turn the drag is winding on. */
  turn: CubeTurn
  /** The same thing as a person would write it. */
  move: CubeMove
  /** The unit direction along which travel winds `turn` forwards. */
  tangent: readonly [number, number, number]
}

/**
 * The whole drag, not just its answer: the turn it is winding, and the
 * direction the hand has to keep pulling to keep winding it.
 *
 * The drag is projected onto the two axes in the plane of the grabbed face,
 * the dominant one is taken, and the rotation axis is its cross product with
 * the face normal — so dragging up the right-hand face lifts that column,
 * whichever way the camera happens to be pointing. A rig that follows the
 * pointer rather than snapping at a threshold needs the tangent as well, and
 * it is the same geometry, so it comes from here rather than being re-derived
 * against a camera. Null when the drag has no direction in the face's plane.
 */
export function grabFromDrag(drag: CubeDrag): CubeGrab | null {
  if (!drag || !cubeFaces.includes(drag.face)) return null
  const n = clampOrder(drag.order)
  const normal = faceNormals[drag.face]
  const direction = drag.direction ?? [0, 0, 0]
  if (!direction.every?.((value) => Number.isFinite(value))) return null

  // Everything in the face's plane; the component out of it is the camera's
  // problem, not the cube's.
  const planar = direction.map(
    (value, index) => value - normal[index] * dot(normal, direction),
  ) as [number, number, number]

  let best = -1
  let axis: CubeAxis = "x"
  for (const candidate of axisOrder) {
    const magnitude = Math.abs(planar[axisIndex[candidate]])
    if (magnitude > best) {
      best = magnitude
      axis = candidate
    }
  }
  if (best < 1e-6) return null

  const sign = Math.sign(planar[axisIndex[axis]]) || 1
  const along: [number, number, number] = [0, 0, 0]
  along[axisIndex[axis]] = sign

  // n × d: turning right-handed about this takes the sticker along the drag.
  const rotation = cross(normal, along)
  let turnAxis: CubeAxis = "x"
  for (const candidate of axisOrder) {
    if (rotation[axisIndex[candidate]] !== 0) turnAxis = candidate
  }
  const quarterTurns = rotation[axisIndex[turnAxis]] > 0 ? 1 : 3
  const cubie = drag.cubie ?? { i: 0, j: 0, k: 0 }
  const slice = Math.max(
    0,
    Math.min(
      n - 1,
      Math.round(
        turnAxis === "x" ? cubie.i : turnAxis === "y" ? cubie.j : cubie.k,
      ),
    ),
  )
  const turn: CubeTurn = { axis: turnAxis, slice, quarterTurns }
  return { turn, move: turnToMove(turn, n), tangent: along }
}

/** The turn a drag across a face asks for, and nothing else. */
export const moveFromDrag = (drag: CubeDrag): CubeMove | null =>
  grabFromDrag(drag)?.move ?? null

const dot = (
  a: readonly [number, number, number],
  b: readonly [number, number, number],
) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]

const cross = (
  a: readonly [number, number, number],
  b: readonly [number, number, number],
): [number, number, number] => [
  a[1] * b[2] - a[2] * b[1],
  a[2] * b[0] - a[0] * b[2],
  a[0] * b[1] - a[1] * b[0],
]

/* --------------------------------------------------------------- solving */

/**
 * A layer-by-layer solve, and the only part of this file that searches.
 *
 * The method is the beginner's one — cross, first-layer corners, middle
 * edges, last-layer cross, twist, place — but it is not written as a hundred
 * hand-cased positions. Each stage is an iterative-deepening search a few
 * symbols deep over an alphabet of *macros*: the U turns, plus one standard
 * algorithm rotated into each of the four side slots. A small search over big
 * steps, rather than a big search over small ones.
 *
 * The search runs on a packed encoding — where each piece sits (0 … 26) and
 * which of the 24 rotations it carries — so a turn is 27 table lookups and no
 * allocation. `solveCube` decodes nothing back until the end, replays the
 * answer through `applyMoves`, and returns `null` unless that replay comes
 * home. It never returns a line it has not checked.
 */

/** The eighteen face turns of a 3×3, in a fixed order the tables index by. */
const basicMoves: CubeMove[] = cubeFaces.flatMap((face) =>
  [1, 2, 3].map((turns) => ({ face, layer: 0, turns })),
)

/** The 24 orientations a cubie can carry, identity first. */
const orientations: CubeMatrix[] = (() => {
  const key = (m: CubeMatrix) => m.join(",")
  const list: CubeMatrix[] = [identityMatrix]
  const seen = new Set([key(identityMatrix)])
  const generators = [
    rotationMatrix("x", 1),
    rotationMatrix("y", 1),
    rotationMatrix("z", 1),
  ]
  for (let index = 0; index < list.length; index++) {
    for (const generator of generators) {
      const next = multiplyMatrix(generator, list[index])
      if (seen.has(key(next))) continue
      seen.add(key(next))
      list.push(next)
    }
  }
  return list
})()

const orientationIndex = new Map(orientations.map((m, index) => [m.join(","), index]))

/** Is this orientation's own +y still pointing at the world's +y? */
const upright: boolean[] = orientations.map((m) => m[1] === 0 && m[4] === 1 && m[7] === 0)

/** Lattice cell ↔ slot number, for a 3×3. A piece's home slot is its index. */
const slotOf = (i: number, j: number, k: number) => i * 9 + j * 3 + k

/** Per move: where each slot goes, how an orientation turns, which slots move. */
const moveTables = basicMoves.map((move) => {
  const turn = moveToTurn(move, 3)
  const rotation = rotationMatrix(turn.axis, turn.quarterTurns)
  const slots = new Uint8Array(27)
  const moved = new Uint8Array(27)
  for (let i = 0; i < 3; i++) {
    for (let j = 0; j < 3; j++) {
      for (let k = 0; k < 3; k++) {
        const slot = slotOf(i, j, k)
        const here = { i, j, k, orientation: identityMatrix }
        if (!inTurn(here, turn)) {
          slots[slot] = slot
          continue
        }
        const [x, y, z] = applyMatrix(rotation, [
          centred(i, 3),
          centred(j, 3),
          centred(k, 3),
        ])
        slots[slot] = slotOf(uncentred(x, 3), uncentred(y, 3), uncentred(z, 3))
        moved[slot] = 1
      }
    }
  }
  const turns = new Uint8Array(24)
  for (let index = 0; index < 24; index++) {
    turns[index] =
      orientationIndex.get(multiplyMatrix(rotation, orientations[index]).join(",")) ?? index
  }
  return { slots, moved, turns }
})

/** The face a unit normal points at, or null. */
const faceFromNormal = (normal: readonly [number, number, number]): CubeFace | null => {
  for (const face of cubeFaces) {
    const [x, y, z] = faceNormals[face]
    if (normal[0] === x && normal[1] === y && normal[2] === z) return face
  }
  return null
}

/**
 * The 24 ways a whole cube can sit in your hands, as remaps of the packed
 * form. Turning a middle slice moves the centres, and a cube whose centres
 * have moved cannot be solved by face turns in the frame it is sitting in —
 * so the solver turns the *cube* first, in its head, and writes the answer
 * back out in the frame you are holding.
 */
const wholeRotations = orientations.map((matrix) => {
  const slots = new Uint8Array(27)
  for (let i = 0; i < 3; i++) {
    for (let j = 0; j < 3; j++) {
      for (let k = 0; k < 3; k++) {
        const [x, y, z] = applyMatrix(matrix, [centred(i, 3), centred(j, 3), centred(k, 3)])
        slots[slotOf(i, j, k)] = slotOf(uncentred(x, 3), uncentred(y, 3), uncentred(z, 3))
      }
    }
  }
  const turns = new Uint8Array(24)
  for (let index = 0; index < 24; index++) {
    turns[index] =
      orientationIndex.get(multiplyMatrix(matrix, orientations[index]).join(",")) ?? index
  }
  // Where each face ends up, and the way back — which is what writes a move
  // found in the turned frame back into the one the caller is holding.
  const faceTo = {} as Record<CubeFace, CubeFace>
  const faceBack = {} as Record<CubeFace, CubeFace>
  for (const face of cubeFaces) {
    const moved = faceFromNormal(applyMatrix(matrix, faceNormals[face])) ?? face
    faceTo[face] = moved
    faceBack[moved] = face
  }
  return { slots, turns, faceBack }
})

/** Where every piece is and which way it points, as two flat arrays. */
interface PackedCube {
  /** `slot[p]` — the slot piece `p` currently occupies. `p` is its home slot. */
  slot: Uint8Array
  /** `spin[p]` — its orientation, as an index into the 24. 0 is home. */
  spin: Uint8Array
}

const packCube = (state: CubeState): PackedCube => {
  const slot = new Uint8Array(27)
  const spin = new Uint8Array(27)
  state.cubies.forEach((cubie, piece) => {
    slot[piece] = slotOf(cubie.i, cubie.j, cubie.k)
    spin[piece] = orientationIndex.get(cubie.orientation.join(",")) ?? 0
  })
  return { slot, spin }
}

/** One face turn, in place. Each piece is read and written on its own, so it is safe. */
function turnPacked(cube: PackedCube, move: number) {
  const table = moveTables[move]
  for (let piece = 0; piece < 27; piece++) {
    const at = cube.slot[piece]
    if (!table.moved[at]) continue
    cube.slot[piece] = table.slots[at]
    cube.spin[piece] = table.turns[cube.spin[piece]]
  }
}

/* ----------------------------------------------------------- the alphabet */

/** The four side faces in the order a turn about the U axis cycles them. */
const sideFaces: CubeFace[] = ["F", "R", "B", "L"]

/** The same algorithm, rotated `k` quarter turns about the U–D axis. */
const yaw = (moves: CubeMove[], k: number): CubeMove[] =>
  moves.map((move) => {
    const side = sideFaces.indexOf(move.face)
    return side < 0 ? move : { ...move, face: sideFaces[(side + k) % 4] }
  })

const moveNumber = new Map(
  basicMoves.map((move, index) => [`${move.face}${move.turns}`, index]),
)

const asMacro = (moves: CubeMove[]): number[] =>
  moves.map((move) => moveNumber.get(`${move.face}${move.turns}`) as number)

/** A symbol the stage search may play: a macro, and what it must not repeat. */
interface CubeSymbol {
  moves: number[]
  /** Two symbols of the same group never run back to back — they'd merge. */
  group: string | null
}

const uTurns: CubeSymbol[] = [1, 2, 3].map((turns) => ({
  moves: asMacro([{ face: "U", layer: 0, turns }]),
  group: "U",
}))

const everyFaceTurn: CubeSymbol[] = basicMoves.map((move, index) => ({
  moves: [index],
  group: move.face,
}))

/** One algorithm at all four side slots. */
const atEverySlot = (text: string): CubeSymbol[] => {
  const moves = parseAlgorithm(text)
  return [0, 1, 2, 3].map((k) => ({ moves: asMacro(yaw(moves, k)), group: null }))
}

const sexyMove = atEverySlot("R U R' U'")
/** The short corner inserts, which is what a first layer is actually built from. */
const cornerInserts = [
  "R U R'", "R U' R'", "R U2 R'",
  "F' U F", "F' U' F", "F' U2 F",
].flatMap(atEverySlot)
const rightInsert = atEverySlot("U R U' R' U' F' U F")
const leftInsert = atEverySlot("U' L' U L U F U' F'")
const crossAlgorithm = atEverySlot("F R U R' U' F'")
const sune = [
  ...atEverySlot("R U R' U R U2 R'"),
  ...atEverySlot("R U2 R' U' R U' R'"),
]
const cornerCycle = atEverySlot("U R U' L' U R' U' L")
const edgeCycle = [
  ...atEverySlot("R U' R U R U R U' R' U' R2"),
  ...atEverySlot("R2 U R U R' U' R' U' R' U R'"),
]

/* ------------------------------------------------------------- the stages */

const edgeAt = (i: number, j: number, k: number) => slotOf(i, j, k)

const downEdges = [edgeAt(1, 0, 2), edgeAt(2, 0, 1), edgeAt(1, 0, 0), edgeAt(0, 0, 1)]
const downCorners = [slotOf(2, 0, 2), slotOf(2, 0, 0), slotOf(0, 0, 0), slotOf(0, 0, 2)]
const middleEdges = [slotOf(2, 1, 2), slotOf(2, 1, 0), slotOf(0, 1, 0), slotOf(0, 1, 2)]
const upEdges = [edgeAt(1, 2, 2), edgeAt(2, 2, 1), edgeAt(1, 2, 0), edgeAt(0, 2, 1)]
const upCorners = [slotOf(2, 2, 2), slotOf(2, 2, 0), slotOf(0, 2, 0), slotOf(0, 2, 2)]
/** The six face centres, which is what says which way round the cube is. */
const cubeCentres = [
  slotOf(0, 1, 1), slotOf(2, 1, 1),
  slotOf(1, 0, 1), slotOf(1, 2, 1),
  slotOf(1, 1, 0), slotOf(1, 1, 2),
]

const homeYet = (cube: PackedCube, pieces: number[]) =>
  pieces.every((piece) => cube.slot[piece] === piece && cube.spin[piece] === 0)

const placedYet = (cube: PackedCube, pieces: number[]) =>
  pieces.every((piece) => cube.slot[piece] === piece)

const uprightYet = (cube: PackedCube, pieces: number[]) =>
  pieces.every((piece) => upright[cube.spin[piece]])

interface CubeStage {
  /**
   * Tried in order. A first pass over many short algorithms finds the tidy
   * answer nearly every time; the fallback is a small alphabet searched deep,
   * which always has one but spends moves to get it.
   */
  tiers: { alphabet: CubeSymbol[]; depth: number }[]
  done: (cube: PackedCube) => boolean
}

/**
 * The method, in order. Each stage's goal carries every earlier stage's, so a
 * search cannot buy one piece by spending another.
 */
function stages(): CubeStage[] {
  const list: CubeStage[] = []
  const firstLayer = [...downEdges, ...downCorners]
  const twoLayers = [...firstLayer, ...middleEdges]

  downEdges.forEach((_, count) => {
    const wanted = downEdges.slice(0, count + 1)
    list.push({
      tiers: [{ alphabet: everyFaceTurn, depth: 6 }],
      done: (cube) => homeYet(cube, wanted),
    })
  })
  downCorners.forEach((_, count) => {
    const wanted = [...downEdges, ...downCorners.slice(0, count + 1)]
    list.push({
      tiers: [
        { alphabet: [...uTurns, ...cornerInserts], depth: 3 },
        { alphabet: [...uTurns, ...sexyMove], depth: 8 },
      ],
      done: (cube) => homeYet(cube, wanted),
    })
  })
  middleEdges.forEach((_, count) => {
    const wanted = [...firstLayer, ...middleEdges.slice(0, count + 1)]
    list.push({
      tiers: [{ alphabet: [...uTurns, ...rightInsert, ...leftInsert], depth: 6 }],
      done: (cube) => homeYet(cube, wanted),
    })
  })
  list.push({
    tiers: [{ alphabet: [...uTurns, ...crossAlgorithm], depth: 5 }],
    done: (cube) => homeYet(cube, twoLayers) && uprightYet(cube, upEdges),
  })
  list.push({
    tiers: [{ alphabet: [...uTurns, ...sune], depth: 6 }],
    done: (cube) =>
      homeYet(cube, twoLayers) && uprightYet(cube, upEdges) && uprightYet(cube, upCorners),
  })
  list.push({
    tiers: [{ alphabet: [...uTurns, ...cornerCycle], depth: 5 }],
    done: (cube) =>
      homeYet(cube, twoLayers) &&
      uprightYet(cube, upEdges) &&
      uprightYet(cube, upCorners) &&
      placedYet(cube, upCorners),
  })
  list.push({
    tiers: [{ alphabet: [...uTurns, ...edgeCycle], depth: 5 }],
    done: (cube) => homeYet(cube, [...twoLayers, ...upEdges, ...upCorners]),
  })
  return list
}

/* ------------------------------------------------------------- the search */

/** Iterative deepening over one alphabet. Move numbers, or null. */
function searchTier(
  cube: PackedCube,
  done: (cube: PackedCube) => boolean,
  alphabet: CubeSymbol[],
  depth: number,
): number[] | null {
  if (done(cube)) return []
  // One scratch cube per level, reused: the search allocates nothing per node.
  const levels = Array.from({ length: depth + 1 }, () => ({
    slot: new Uint8Array(27),
    spin: new Uint8Array(27),
  }))
  levels[0].slot.set(cube.slot)
  levels[0].spin.set(cube.spin)
  const played: CubeSymbol[] = []

  const walk = (level: number, left: number): boolean => {
    if (!left) return false
    const here = levels[level]
    const next = levels[level + 1]
    for (const symbol of alphabet) {
      if (symbol.group && symbol.group === played[level - 1]?.group) continue
      next.slot.set(here.slot)
      next.spin.set(here.spin)
      for (const move of symbol.moves) turnPacked(next, move)
      played[level] = symbol
      if (done(next)) {
        played.length = level + 1
        return true
      }
      if (walk(level + 1, left - 1)) return true
    }
    return false
  }

  for (let limit = 1; limit <= depth; limit++) {
    played.length = 0
    if (walk(0, limit)) return played.flatMap((symbol) => symbol.moves)
  }
  return null
}

/** The stage's tiers in order: the tidy answer first, the sure one second. */
function searchStage(cube: PackedCube, stage: CubeStage): number[] | null {
  for (const tier of stage.tiers) {
    const line = searchTier(cube, stage.done, tier.alphabet, tier.depth)
    if (line) return line
  }
  return null
}

/* ------------------------------------------------------------- the answer */

/** `R R` → `R2`, `R R'` → nothing, `R2 R2` → nothing. Same face, same layer. */
export function simplifyMoves(moves: CubeMove[]): CubeMove[] {
  const out: CubeMove[] = []
  for (const move of moves ?? []) {
    const turns = (((Math.round(move?.turns ?? 0) % 4) + 4) % 4)
    if (!turns || !cubeFaces.includes(move.face)) continue
    const last = out.at(-1)
    if (last && last.face === move.face && last.layer === move.layer) {
      out.pop()
      const merged = (last.turns + turns) % 4
      if (merged) out.push({ ...last, turns: merged })
      continue
    }
    out.push({ ...move, turns })
  }
  return out
}

/**
 * A line that takes this cube home, or `null`.
 *
 * 3×3 only — the method is the 3×3 one, and a 4×4 has parities it does not
 * know about, so every other order is honestly `null` rather than a guess.
 * The answer is replayed and checked before it is handed back.
 */
export function solveCube(state: CubeState): CubeMove[] | null {
  if (!state || state.order !== 3 || state.cubies?.length !== 27) return null
  if (isSolved(state)) return []
  const cube = packCube(state)

  // Sit the cube the right way up first: the method turns faces, and faces
  // are named by the centres they carry, so the centres have to be home
  // before any of it means anything. A legal cube always has exactly such a
  // rotation; anything else is not a cube this can solve.
  const aligned = wholeRotations.find((rotation) => {
    for (const centre of cubeCentres) {
      if (rotation.slots[cube.slot[centre]] !== centre) return false
    }
    return true
  })
  if (!aligned) return null
  for (let piece = 0; piece < 27; piece++) {
    cube.slot[piece] = aligned.slots[cube.slot[piece]]
    cube.spin[piece] = aligned.turns[cube.spin[piece]]
  }

  // A cube a few turns from home is solved *properly* rather than by the
  // method: a short exhaustive search first, so undoing five turns of
  // fiddling is five moves back and not a hundred and thirty.
  const everything = [...downEdges, ...downCorners, ...middleEdges, ...upEdges, ...upCorners]
  const short = searchTier(
    cube,
    (near) => homeYet(near, everything) && placedYet(near, cubeCentres),
    everyFaceTurn,
    5,
  )

  const found: number[] = short ?? []
  for (const stage of short ? [] : stages()) {
    const line = searchStage(cube, stage)
    if (!line) return null
    for (const move of line) turnPacked(cube, move)
    found.push(...line)
  }
  const solution = simplifyMoves(
    // Back into the frame the caller is holding: the same turn, on the face
    // that was standing where this one is now.
    found.map((move) => ({ ...basicMoves[move], face: aligned.faceBack[basicMoves[move].face] })),
  )
  return isSolved(applyMoves(state, solution)) ? solution : null
}

/** The next move of a solve — which is what a hint is. */
export const solveStep = (state: CubeState): CubeMove | null =>
  solveCube(state)?.[0] ?? null