package coffee.liz.rookery import coffee.liz.rookery.puzzle.Puzzles import coffee.liz.rookery.puzzle.TwistyPuzzle import coffee.liz.rookery.puzzle.V3 import org.worldcubeassociation.tnoodle.scrambles.PuzzleRegistry import kotlin.math.atan2 import kotlin.test.Test import kotlin.test.assertEquals /** * Our 3D models must agree sticker-for-sticker with tnoodle's official 2D drawings. Each model * sticker is mapped onto tnoodle's net through its face (an affine map between the face's 3D and * 2D corners); the colour found there must match ours. */ class TnoodleAgreementTest { private data class P2(val x: Double, val y: Double) private data class NetSticker(val c: P2, val color: String) /** Sticker centroids and fills from a tnoodle SVG, applying each path's transform. */ private fun parse(svg: String): List = Regex("""]*>""").findAll(svg).mapNotNull { m -> fun attr(n: String) = Regex("""\b$n="([^"]*)"""").find(m.value)?.groupValues?.get(1) val d = attr("d") ?: return@mapNotNull null val fill = attr("fill") ?: return@mapNotNull null val t = attr("transform")?.let { Regex("""matrix\(([^)]*)\)""").find(it)?.groupValues?.get(1)?.split(',')?.map { v -> v.trim().toDouble() } } ?: listOf(1.0, 0.0, 0.0, 1.0, 0.0, 0.0) val pts = Regex("""[ML] ([-\d.]+) ([-\d.]+)""").findAll(d).map { val x = it.groupValues[1].toDouble(); val y = it.groupValues[2].toDouble() P2(t[0] * x + t[2] * y + t[4], t[1] * x + t[3] * y + t[5]) }.toList().distinct() NetSticker(P2(pts.sumOf { it.x } / pts.size, pts.sumOf { it.y } / pts.size), fill) }.toList() private fun d2(a: P2, b: P2) = (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) /** * Net face corners, in order around the face: the face's corner stickers pushed outwards from its * centre (corner-sticker centroids sit 2/3 of the way to the corner for both puzzles here). */ private fun netCorners(face: List, n: Int): List { val cx = face.sumOf { it.c.x } / face.size; val cy = face.sumOf { it.c.y } / face.size return face.sortedByDescending { d2(it.c, P2(cx, cy)) }.take(n) .sortedBy { atan2(it.c.y - cy, it.c.x - cx) } .map { P2(cx + (it.c.x - cx) * 1.5, cy + (it.c.y - cy) * 1.5) } } /** Map a point on a 3D face to the net via the face's first three corners (affine). */ private fun toNet(p: V3, c3: List, c2: List): P2 { val e1 = c3[1] - c3[0]; val e2 = c3[c3.size - 1] - c3[0]; val v = p - c3[0] val d11 = e1 dot e1; val d12 = e1 dot e2; val d22 = e2 dot e2 val den = d11 * d22 - d12 * d12 val s = ((v dot e1) * d22 - (v dot e2) * d12) / den val t = ((v dot e2) * d11 - (v dot e1) * d12) / den val a = c2[0]; val b = c2[1]; val d = c2[c2.size - 1] return P2(a.x + s * (b.x - a.x) + t * (d.x - a.x), a.y + s * (b.y - a.y) + t * (d.y - a.y)) } private fun checkAgreement(reg: PuzzleRegistry, model: TwistyPuzzle, faceCorners: List>, faceColors: List) { val scrambler = reg.scrambler val solvedNet = parse(scrambler.drawScramble("", null).toString()) val n = faceCorners[0].size val colorIndex = faceColors.withIndex().associate { (i, c) -> c to i } fun mismatches(scr: String, mapping: Map>, only: Int? = null): Int { val net = parse(scrambler.drawScramble(scr, null).toString()) val st = model.solved().apply(scr) return model.stickers.indices.count { i -> val f = model.stickerFace[i] if (only != null && f != only) return@count false val p = toNet(model.stickers[i].centroid, faceCorners[f], mapping.getValue(f)) colorIndex[net.minBy { d2(it.c, p) }.color] != st.colors[i] } } // Which net corner is which 3D corner: pick the rotation/reflection that fits reference scrambles // (several, since one scramble can leave a face symmetric enough to tie two alignments)… val refs = List(4) { scrambler.generateScramble() } val mapping = HashMap>() for ((color, face) in solvedNet.groupBy { it.color }) { val f = colorIndex.getValue(color) val c = netCorners(face, n) val options = (0 until n).flatMap { r -> val rot = c.drop(r) + c.take(r); listOf(rot, rot.reversed()) } mapping[f] = options.minBy { opt -> refs.sumOf { mismatches(it, mapping + (f to opt), f) } } } // …then that fixed alignment must hold for fresh scrambles. repeat(15) { val scr = scrambler.generateScramble() assertEquals(0, mismatches(scr, mapping), "${model.name} disagrees with tnoodle on $scr") } } @Test fun pyraminxMatchesTnoodle() { val m = Puzzles.pyraminx() val v = m.axes.associate { it.name to it.normal } val corners = listOf(listOf("U", "L", "R"), listOf("U", "L", "B"), listOf("U", "R", "B"), listOf("L", "R", "B")).map { f -> f.map(v::getValue) } checkAgreement(PuzzleRegistry.PYRA, m, corners, listOf("#00ff00", "#ff0000", "#0000ff", "#ffff00")) } @Test fun skewbMatchesTnoodle() { val corners = Puzzles.cube(1).stickers.map { it.points } checkAgreement(PuzzleRegistry.SKEWB, Puzzles.skewb(), corners, listOf("#ffffff", "#ff0000", "#00ff00", "#ffff00", "#ff8000", "#0000ff")) } }