summaryrefslogtreecommitdiff
path: root/core/src/jvmTest/kotlin/coffee/liz/rookery/TnoodleAgreementTest.kt
blob: dd5e9a872e81471296c626cf91f350392ba801a8 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
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<NetSticker> = Regex("""<path [^>]*>""").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<NetSticker>, n: Int): List<P2> {
        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<V3>, c2: List<P2>): 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<List<V3>>, faceColors: List<String>) {
        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<Int, List<P2>>, 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<Int, List<P2>>()
        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"))
    }
}