package coffee.liz.rookery.puzzle /** * Sticker-level model of an NxN cube, enough to draw scramble previews and replay notation. * * Faces are stored U R F D L B (Kociemba order), each n*n stickers row-major as you look at the * face on a standard net. Geometry uses doubled integer coordinates: cubie centres sit on odd/even * steps in [-(n-1), n-1] and stickers poke out to ±n, so quarter turns stay exact integers. * Axes: x→R, y→U, z→F. */ class NxnCube(val n: Int) { init { require(n in 1..12) } enum class Face { U, R, F, D, L, B } /** Colour index per sticker slot; the colour index is the face it started on. */ val stickers = IntArray(6 * n * n) { it / (n * n) } val isSolved: Boolean get() = (0 until 6).all { f -> (0 until n * n).all { stickers[f * n * n + it] == f } } fun copy(): NxnCube = NxnCube(n).also { stickers.copyInto(it.stickers) } fun apply(alg: String): NxnCube { for (m in Notation.parse(alg, n)) turn(m); return this } fun turn(m: Move) { val perm = permutation(m.axis, m.layers, m.quarter) val old = stickers.copyOf() for (i in perm.indices) stickers[perm[i]] = old[i] } // ---- geometry ---- private class P(val x: Int, val y: Int, val z: Int) private val positions: Array

= Array(6 * n * n) { slotPosition(it) } private val index: Map, Int> = positions.withIndex().associate { (i, p) -> Triple(p.x, p.y, p.z) to i } private fun slotPosition(slot: Int): P { val face = Face.entries[slot / (n * n)] val r = (slot % (n * n)) / n val c = slot % n val a = 2 * c - (n - 1) // left→right val b = 2 * r - (n - 1) // top→bottom return when (face) { Face.U -> P(a, n, b) Face.D -> P(a, -n, -b) Face.F -> P(a, -b, n) Face.B -> P(-a, -b, -n) Face.R -> P(n, -b, -a) Face.L -> P(-n, -b, a) } } private val cache = HashMap, Int>, IntArray>() /** * Where each slot goes for a turn of [layers] (0 = the +axis face layer, n-1 = the -axis face * layer) about [axis] (0=x,1=y,2=z), clockwise looking from +axis, [quarter] times (1..3). */ private fun permutation(axis: Int, layers: Set, quarter: Int): IntArray = cache.getOrPut(Triple(axis, layers, quarter)) { IntArray(positions.size) { i -> var p = positions[i] val coord = when (axis) { 0 -> p.x; 1 -> p.y; else -> p.z }.coerceIn(-(n - 1), n - 1) val layer = ((n - 1) - coord) / 2 if (layer in layers) repeat(quarter) { p = rotate(p, axis) } index.getValue(Triple(p.x, p.y, p.z)) } } private fun rotate(p: P, axis: Int): P = when (axis) { 0 -> P(p.x, p.z, -p.y) 1 -> P(-p.z, p.y, p.x) else -> P(p.y, -p.x, p.z) } /** Cubie position of a sticker slot, in doubled coordinates (each axis in -(n-1)..n-1). */ fun cubieOf(slot: Int): Triple { val p = positions[slot] return Triple(p.x.coerceIn(-(n - 1), n - 1), p.y.coerceIn(-(n - 1), n - 1), p.z.coerceIn(-(n - 1), n - 1)) } /** Outward normal of a sticker slot as an axis-aligned unit vector. */ fun normalOf(slot: Int): Triple { val p = positions[slot] return Triple(if (kotlin.math.abs(p.x) == n) p.x / n else 0, if (kotlin.math.abs(p.y) == n) p.y / n else 0, if (kotlin.math.abs(p.z) == n) p.z / n else 0) } fun face(f: Face): IntArray = stickers.copyOfRange(f.ordinal * n * n, (f.ordinal + 1) * n * n) } /** A turn of a set of layers about an axis, clockwise from the +axis side. */ data class Move(val axis: Int, val layers: Set, val quarter: Int) object Notation { private val token = Regex("""^(\d*)([URFDLBurfdlbxyzMES])(w?)(\d*)('?)$""") /** Parse WCA/SiGN notation (R, U2', Rw, 3Rw, r, 2R, x, M…) into layer turns for an n-cube. */ fun parse(alg: String, n: Int): List = alg .replace("(", " ").replace(")", " ") .split(Regex("\\s+")).filter { it.isNotEmpty() } .map { parseMove(it, n) ?: throw IllegalArgumentException("bad move '$it' for ${n}x$n") } fun parseMove(t: String, n: Int): Move? { val m = token.matchEntire(t) ?: return null val (prefix, letter, w, amountStr, prime) = m.destructured var amount = if (amountStr.isEmpty()) 1 else amountStr.toInt() if (prime.isNotEmpty()) amount = -amount val all = (0 until n).toSet() val lower = letter[0].isLowerCase() && letter[0] !in "xyz" val face = letter.uppercase()[0] val depth: IntRange = when { face in "XYZ" -> 0 until n face in "MES" -> if (n % 2 == 1 && n > 1) (n / 2)..(n / 2) else return null w.isNotEmpty() || lower -> 0 until (if (prefix.isEmpty()) 2 else prefix.toInt()) prefix.isNotEmpty() -> (prefix.toInt() - 1)..(prefix.toInt() - 1) else -> 0..0 } if (depth.last >= n) return null // axis, and whether this face sits on the -axis side (so layers count from the other end and direction flips) val (axis, negative) = when (face) { 'R', 'X' -> 0 to false; 'L', 'M' -> 0 to true 'U', 'Y' -> 1 to false; 'D', 'E' -> 1 to true 'F', 'Z', 'S' -> 2 to false; 'B' -> 2 to true else -> return null } val layers = if (face in "XYZ") all else depth.map { if (negative) n - 1 - it else it }.toSet() val q = ((if (negative) -amount else amount) % 4 + 4) % 4 return Move(axis, layers, if (q == 0) 4 else q) } /** Inverse of a move sequence, as text. */ fun invert(alg: String): String = alg.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }.reversed().joinToString(" ") { t -> when { t.endsWith("2'") -> t.dropLast(1) t.endsWith("2") -> t t.endsWith("'") -> t.dropLast(1) else -> "$t'" } } }