Skip to content

Commit e418b57

Browse files
authored
Merge pull request #514 from memo33/tla-tile-orientations
fix TLA orientations in metarules
2 parents a93c7a5 + 5d5aecb commit e418b57

7 files changed

Lines changed: 94 additions & 63 deletions

File tree

build.sbt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import io.github.memo33.metarules.meta._, com.sc4nam.module, module.syntax._
2020
import Implicits._, Network._, Flags._, RotFlip._, Rule.{CopyTile => %}, group.SymGroup._
2121
lazy val resolve = module.Main.resolveSafely
2222
lazy val preimage = module.ReverseResolver.create()
23-
implicit lazy val context: RuleTransducer.Context = RuleTransducer.Context(resolve, module.RegenerateTileOrientationCache.loadCache(), module.MirrorVariants.preprocessor)
23+
implicit lazy val context: RuleTransducer.Context = RuleTransducer.Context(module.Main.resolve, module.RegenerateTileOrientationCache.loadCache(), module.MirrorVariants.preprocessor)
2424
def transduce(rule: Rule[SymTile]): Unit = RuleTransducer(rule)(context).map(_.toRul2String).foreach(println)
2525
"""
2626

@@ -71,4 +71,4 @@ libraryDependencies += "tv.cntt" %% "scaposer" % "1.11.1" cross CrossVersion.for
7171

7272
libraryDependencies += "io.github.memo33" %% "scdbpf" % "0.2.0" cross CrossVersion.for3Use2_13
7373

74-
libraryDependencies += "io.github.memo33" %% "metarules" % "0.7.0"
74+
libraryDependencies += "io.github.memo33" %% "metarules" % "0.7.1"

src/main/scala/module/CompileAllMetarules.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ object CompileAllMetarules {
3131

3232
/** Add additional rule generators here.
3333
*/
34-
def compileMetarulesOnce(tileOrientationCache: collection.mutable.Map[Int, Set[RotFlip]]): Unit = {
34+
def compileMetarulesOnce(tileOrientationCache: RuleTransducer.TileOrientationCache): Unit = {
3535
LOGGER.info("compiling FlexFly metarule code")
3636
flexfly.CompileFlexFlyCode.start(tileOrientationCache = tileOrientationCache)
3737
LOGGER.info("compiling RRW metarule code")

src/main/scala/module/Main.scala

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,17 @@ abstract class AbstractMain {
2525
lazy val resolveSafely: IdResolver = new PartialFunction[Tile, IdTile] {
2626
def isDefinedAt(tile: Tile) = resolve.isDefinedAt(tile)
2727
def apply(tile: Tile) = try resolve.apply(tile) catch {
28-
case scala.util.control.NonFatal(e) =>
29-
throw new IllegalArgumentException(s"ID resolution failed for tile $tile", e)
28+
case e: RuleTransducer.ResolutionFailed => throw e
29+
case scala.util.control.NonFatal(e) => throw new RuleTransducer.ResolutionFailed(tile, rule = None, reason = e, frame = None)
3030
}
3131
}
3232

33+
private lazy val shouldIgnoreMirroredOrientations: Set[Int] = MirrorVariants.ignoreMirroredOrientations(resolve)
34+
3335
def main(args: Array[String]): Unit = start()
3436

3537
/** Creates a generator with a new context, runs its start method and outputs the resulting RUL2 code to file. */
36-
def start(file: File = file, tileOrientationCache: collection.mutable.Map[Int, Set[RotFlip]] = null): Unit = {
38+
def start(file: File = file, tileOrientationCache: RuleTransducer.TileOrientationCache = null): Unit = {
3739
if (tileOrientationCache == null) {
3840
RegenerateTileOrientationCache.withCache { cache =>
3941
start(file, cache)
@@ -54,6 +56,14 @@ abstract class AbstractMain {
5456
printer.println(rule.toRul2String)
5557
}
5658
}
59+
// finally remove accumulated orientations that we want to ignore (like accidentally mirrored TLAs)
60+
tileOrientationCache.accum.mapValuesInPlace { (id, repr) =>
61+
if (shouldIgnoreMirroredOrientations(id)) repr.filterNot(_.flipped)
62+
else repr
63+
}
64+
tileOrientationCache.accum.filterInPlace { (id, repr) => // remove from accum if equal to cache (so that regenerateTileOrientationCache stabilizes eventually)
65+
!tileOrientationCache.cache.get(id).contains(repr)
66+
}
5767
}
5868
}
5969
}

src/main/scala/module/MirrorVariants.scala

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,23 +54,43 @@ object MirrorVariants {
5454
case _ => tile
5555
}
5656

57-
private val tlaPreprocessor: Rule[SymTile] => Iterator[Rule[SymTile]] = rule => {
58-
if (!rule.exists(containsTlaFlags)) {
59-
Iterator(rule)
60-
} else if (rule.forall(shouldProjectTlaLeftOnly)) {
61-
Iterator(rule.map(projectTlaLeft))
62-
} else {
63-
Iterator(rule.map(projectTlaLeft), rule.map(projectTlaRight))
64-
}
57+
val preprocessor: Rule[SymTile] => Iterator[Rule[SymTile]] = rule => {
58+
val hasMirrorVariant = rule.exists(tile => mirrorVariants.contains(tile))
59+
for {
60+
rule <- if (hasMirrorVariant) Iterator( // yield the two projected rules
61+
rule.map(tile => mirrorVariants.get(tile).map(_._1).getOrElse(tile)),
62+
rule.map(tile => mirrorVariants.get(tile).map(_._2).getOrElse(tile)),
63+
)
64+
else Iterator(rule)
65+
hasTlaFlags = rule.exists(containsTlaFlags)
66+
rule <- if (!hasTlaFlags) Iterator(rule)
67+
else if (rule.forall(shouldProjectTlaLeftOnly)) Iterator(rule.map(projectTlaLeft))
68+
else Iterator(rule.map(projectTlaLeft), rule.map(projectTlaRight))
69+
// We duplicate the rule by its R2F1 variant if there are TLAs or mirror
70+
// variants involved, as the left/right distinction can make them resolve
71+
// to different IDs (and the generators do not necessarily account for
72+
// this). For example, this is needed for Tla5/Avenue D×D.
73+
rule <- if (hasMirrorVariant || hasTlaFlags) Iterator(rule, rule.map(_ * R2F1)).distinct
74+
else Iterator(rule)
75+
} yield rule
6576
}
6677

67-
val preprocessor: Rule[SymTile] => Iterator[Rule[SymTile]] = rule => {
68-
if (!rule.exists(tile => mirrorVariants.contains(tile))) {
69-
Iterator(rule)
70-
} else {
71-
Iterator( // yield the two projected rules
72-
rule.map(tile => mirrorVariants.get(tile).map(_._1).getOrElse(tile)),
73-
rule.map(tile => mirrorVariants.get(tile).map(_._2).getOrElse(tile)))
78+
/* For plain orthogonal and diagonal tiles of TLA networks, we ignore any
79+
* remapping defined in `tileOrientationCache` that leads to mirroring, as
80+
* that would amplify the mirroring problems due to the presence of turning
81+
* lanes. Not a perfect solution, as occasional mirroring problems will remain.
82+
*/
83+
def ignoreMirroredOrientations(resolve: IdResolver): Set[Int] = {
84+
val ids = Set.newBuilder[Int]
85+
for {
86+
n <- Seq(Tla3, Tla5, Tla7m, Road, Onewayroad) // TODO consider adding all the networks
87+
dir <- Seq(NS, ES, SE) // these are arbitrary orth/diag directions to allow us find the IDs
88+
tile <- Seq(NetworkProperties.projectTlaLeft(n~dir), NetworkProperties.projectTlaRight(n~dir))
89+
idTile <- resolve.lift(tile)
90+
} {
91+
ids += idTile.id
7492
}
75-
}.flatMap(tlaPreprocessor)
93+
ids.result()
94+
}
95+
7696
}

src/main/scala/module/RegenerateTileOrientationCache.scala

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.sc4nam.module
22

33
import java.io.File
44
import io.github.memo33.metarules.meta.RotFlip
5+
import syntax.RuleTransducer.TileOrientationCache
56

67
/** Manages the tile orientation cache. The cache is necessary to maintain
78
* information about non-standard orientations:
@@ -23,19 +24,24 @@ object RegenerateTileOrientationCache {
2324

2425
/** Repeatedly compiles the metarule code until the cache does not get changed anymore.
2526
*/
26-
def compileMetarulesUntilStable(tileOrientationCache: collection.mutable.Map[Int, Set[RotFlip]]): Unit = {
27+
def compileMetarulesUntilStable(cache: collection.mutable.Map[Int, Set[RotFlip]]): Unit = {
28+
val tileOrientationCache = TileOrientationCache(cache = cache, accum = collection.mutable.Map.empty[Int, Set[RotFlip]])
2729
var j = 0
2830
var stabilized = false
29-
var previous: collection.immutable.Map[Int, Set[RotFlip]] = tileOrientationCache.toMap
30-
while (j < 3 && !stabilized) {
31-
LOGGER.info(s"> metarules compilation: iteration $j")
31+
val maxIter = 5
32+
while (j < 5 && !stabilized) {
3233
j += 1
34+
LOGGER.info(s"> metarules compilation: iteration $j")
3335
CompileAllMetarules.compileMetarulesOnce(tileOrientationCache)
34-
val next = tileOrientationCache.toMap
35-
if (next == previous) {
36+
if (tileOrientationCache.accum.nonEmpty) {
37+
tileOrientationCache.cache ++= tileOrientationCache.accum
38+
tileOrientationCache.accum.clear()
39+
if (j == maxIter) {
40+
LOGGER.warning(s"Tile orientation cache could not be regenerated in $maxIter iterations. Increase the number of iterations or check if there is a bug.")
41+
}
42+
} else {
3643
stabilized = true
3744
}
38-
previous = next
3945
}
4046
}
4147

@@ -50,7 +56,7 @@ object RegenerateTileOrientationCache {
5056
}
5157
}
5258

53-
def loadCache(): collection.mutable.Map[Int, Set[RotFlip]] = {
59+
def loadCache(): TileOrientationCache = {
5460
scala.util.Using.resource(new java.util.Scanner(cacheFile, "UTF-8")) { scanner =>
5561
val cache = collection.mutable.Map.empty[Int, Set[RotFlip]]
5662
while(scanner.hasNextLine()) {
@@ -62,21 +68,18 @@ object RegenerateTileOrientationCache {
6268
cache(id) = orientations
6369
}
6470
}
65-
cache
71+
TileOrientationCache(cache = cache, accum = collection.mutable.Map.empty[Int, Set[RotFlip]])
6672
}
6773
}
6874

6975
/** Loads the cache and gives a warning at the end if it was changed.
7076
*/
71-
def withCache[U](body: collection.mutable.Map[Int, Set[RotFlip]] => U): U = {
72-
var previous: collection.immutable.Map[Int, Set[RotFlip]] = null
73-
val cache = loadCache()
77+
def withCache[U](body: TileOrientationCache => U): U = {
78+
val tileOrientationCache = loadCache()
7479
try {
75-
previous = cache.toMap
76-
body(cache)
80+
body(tileOrientationCache)
7781
} finally {
78-
assert(previous != null)
79-
if (previous != cache.toMap) {
82+
if (tileOrientationCache.accum.nonEmpty) {
8083
LOGGER.warning(s"The file ${cacheFile} is outdated. Rebuild it with `sbt regenerateTileOrientationCache` and commit the changes.")
8184
}
8285
}

src/test/scala/meta/RuleTransducerSpec.scala

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,64 +4,62 @@ import scala.collection.immutable.StringOps
44
import org.scalatest.wordspec.AnyWordSpec
55
import org.scalatest.matchers.should.Matchers
66
import com.sc4nam.module, module.syntax._, Implicits._
7+
import com.sc4nam.module.{NetworkProperties => NP}
78
import group._, RotFlip._, SymGroup._, Network._, Flags._
89
import RuleTransducer._
910

1011
class RuleTransducerSpec extends AnyWordSpec with Matchers {
1112

1213
val resolver = new module.RhwResolver orElse new module.NwmResolver orElse new module.MiscResolver
13-
val tileOrientationCache = collection.mutable.Map.empty[Int, Set[RotFlip]]
14-
val context = RuleTransducer.Context(resolver, tileOrientationCache, module.MirrorVariants.preprocessor)
14+
val context = RuleTransducer.Context(resolver, preprocess = module.MirrorVariants.preprocessor)
1515

1616
"preprocessor" should {
1717
"produce expected number of rules for Tla3" in {
1818
val orth: Seq[Rule[SymTile]] = context.preprocess( Tla3~WE | (Road ~> Tla3)~WE ).toSeq
1919
orth should have size (1)
2020
orth.asInstanceOf[Seq[Rule[Tile]]].exists(_.exists(_.segs.exists(s => s.flags.manifest == Flag.RightSpinBi && s.flags.exists(_ == 2)))) should be (false)
21-
createRules(orth.head.map(_.toIdSymTile(resolver)), tileOrientationCache).toSeq should have size (2)
21+
createRules(orth.head.map(_.toIdSymTile(resolver)), context.tileOrientationCache.cache, context.tileOrientationCache.accum).toSeq should have size (2)
2222

2323
context.preprocess( Tla3~WE & Road~NS | (Road ~> Tla3)~WE ).toSeq should have size (1)
2424
val diag = context.preprocess( Tla3~WE & Road~WS | (Road ~> Tla3)~WE ).toSeq
25-
diag should have size (2)
25+
diag should have size (4)
2626
for (r <- diag) {
27-
createRules(r.map(_.toIdSymTile(resolver)), tileOrientationCache).toSeq should have size (2)
27+
createRules(r.map(_.toIdSymTile(resolver)), context.tileOrientationCache.cache, context.tileOrientationCache.accum).toSeq should have size (2)
2828
}
2929
}
3030
}
3131

32-
def makeTileLeft(t: Tile): Tile = Tile(t.segs map (s => if (!s.network.isTla) s else s.copy(flags = s.flags.spinLeft)))
33-
def makeTileRight(t: Tile): Tile = Tile(t.segs map (s => if (!s.network.isTla) s else s.copy(flags = s.flags.spinRight)))
3432
"Resolver" should {
3533
"resolve left/right-spinned TLAs correctly" in {
3634
val tiles = Seq[Tile]( Tla3~WE & Road~ES, Tla3~WE & Ard3~ES, Tla3~WE & Tla3~ES )
3735
val tile2 = Seq[Tile]( Tla3~WE, Tla3~WE & Road~NS, Tla3~WE & Ard3~NS, Tla3~WE & Tla3~NS )
3836
for (t <- tiles) {
39-
resolver(makeTileLeft(t)) should not be resolver(makeTileRight(t))
37+
resolver(NP.projectTlaLeft(t)) should not be resolver(NP.projectTlaRight(t))
4038
}
4139
for (t <- tile2) {
42-
resolver(makeTileLeft(t)) should be (resolver(makeTileRight(t)))
40+
resolver(NP.projectTlaLeft(t)) should be (resolver(NP.projectTlaRight(t)))
4341
}
4442
}
4543
"handle flipped left/right-spinned TLAs correctly" in {
4644
val (t1, t2) = ( Tla3~WE & Road~ES, Tla3~WE & Road~WS )
47-
resolver(makeTileLeft(t1)).id should not be (resolver(makeTileLeft(t2)).id)
45+
resolver(NP.projectTlaLeft(t1)).id should not be (resolver(NP.projectTlaLeft(t2)).id)
4846
for ((t, i) <- Seq(t1, t2).zipWithIndex) {
49-
makeTileLeft(t).toIdSymTile(resolver).repr.filter(_.flipped ^ (i!=0)) should be (Symbol("empty"))
50-
makeTileRight(t).toIdSymTile(resolver).repr.filter(!_.flipped ^ (i!=0)) should be (Symbol("empty"))
47+
NP.projectTlaLeft(t).toIdSymTile(resolver).repr.filter(_.flipped ^ (i!=0)) should be (Symbol("empty"))
48+
NP.projectTlaRight(t).toIdSymTile(resolver).repr.filter(!_.flipped ^ (i!=0)) should be (Symbol("empty"))
5149
}
5250
}
5351
"find RHS for TLA" in {
54-
val rule = (Tla3~WE | (Road ~> Tla3)~(2,0,11,0)) map makeTileLeft map (_.toIdSymTile(resolver))
52+
val rule = (Tla3~WE | (Road ~> Tla3)~(2,0,11,0)) map NP.projectTlaLeft map (_.toIdSymTile(resolver))
5553
possibleMapOrientation(Set(R0F0, R1F0), R3F0/R2F1, Quotient.Dih4, R1F1/R2F1) should not be (Symbol("empty"))
56-
createRules(rule, tileOrientationCache)
54+
createRules(rule, context.tileOrientationCache.cache, context.tileOrientationCache.accum)
5755
}
5856
"resolve diagonal TLA intersections" in {
59-
val t1 = makeTileLeft(Tla3~ES & Road~WS)
60-
val t2 = makeTileRight(Tla3~ES & Road~WS)
61-
val t3 = makeTileLeft(Tla3~WS & Road~ES)
57+
val t1 = NP.projectTlaLeft(Tla3~ES & Road~WS)
58+
val t2 = NP.projectTlaRight(Tla3~ES & Road~WS)
59+
val t3 = NP.projectTlaLeft(Tla3~WS & Road~ES)
6260
resolver(t3) * R0F1 should be (resolver(t2))
6361
resolver(t1).id should not be (resolver(t2).id)
64-
resolver(makeTileLeft(Tla3~ES & Tla3~WS)) should be (resolver(makeTileRight(Tla3~ES & Tla3~WS)))
62+
resolver(NP.projectTlaLeft(Tla3~ES & Tla3~WS)) should be (resolver(NP.projectTlaRight(Tla3~ES & Tla3~WS)))
6563
}
6664
}
6765
}

src/test/scala/module/MirrorVariantsSpec.scala

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,29 +56,29 @@ class MirrorVariantsSpec extends AnyWordSpec with Matchers {
5656

5757
"produce expected results for TLA network crossings" in {
5858
implicit val context = RuleTransducer.Context(resolve, preprocess = MirrorVariants.preprocessor)
59-
RuleTransducer(Tla3~WE | (Road~>Tla3)~WE & Rail~ES).toSeq shouldBe Seq(
59+
RuleTransducer(Tla3~WE | (Road~>Tla3)~WE & Rail~ES).toSeq should contain theSameElementsAs Seq(
6060
Rule(0x51000000,1,0, 0x03010200,1,0, 0x51000000,1,0, 0x51005500,3,0),
6161
Rule(0x51000000,3,0, 0x03010200,1,0, 0x51000000,3,0, 0x51005500,3,0),
6262
Rule(0x51000000,3,0, 0x03020500,3,1, 0x51000000,3,0, 0x51005500,1,1),
6363
Rule(0x51000000,1,0, 0x03020500,3,1, 0x51000000,1,0, 0x51005500,1,1))
64-
RuleTransducer(Tla5~WE | (Road~>Tla5)~WE & Rail~ES).toSeq shouldBe Seq(
64+
RuleTransducer(Tla5~WE | (Road~>Tla5)~WE & Rail~ES).toSeq should contain theSameElementsAs Seq(
6565
Rule(0x51100000,3,0, 0x03010200,1,0, 0x51100000,3,0, 0x51105500,3,0),
6666
Rule(0x51100000,1,0, 0x03020500,3,1, 0x51100000,1,0, 0x51105500,1,1))
67-
RuleTransducer(Tla5~WE | (Road~>Tla5)~WE & Road~ES).toSeq shouldBe Seq(
67+
RuleTransducer(Tla5~WE | (Road~>Tla5)~WE & Road~ES).toSeq should contain theSameElementsAs Seq(
6868
Rule(0x51100000,3,0, 0x00003900,1,0, 0x51100000,3,0, 0x51105100,3,0),
6969
Rule(0x51100000,1,0, 0x00003900,3,1, 0x51100000,1,0, 0x71105100,1,1)) // 0x71... variant
70-
RuleTransducer(Tla3~WE | (Road~>Tla3)~WE & Lightrail~ES).toSeq shouldBe Seq(
70+
RuleTransducer(Tla3~WE | (Road~>Tla3)~WE & Lightrail~ES).toSeq should contain theSameElementsAs Seq(
7171
Rule(0x51000000,1,0, 0x08DD1600,1,1, 0x51000000,1,0, 0x51005600,3,0),
7272
Rule(0x51000000,3,0, 0x08DD1600,1,1, 0x51000000,3,0, 0x51005600,3,0),
7373
Rule(0x51000000,3,0, 0x08DD1600,3,0, 0x51000000,3,0, 0x51005600,1,1),
7474
Rule(0x51000000,1,0, 0x08DD1600,3,0, 0x51000000,1,0, 0x51005600,1,1))
75-
RuleTransducer(Tla5~EW | (Avenue~>Tla5)~EW & Avenue~NS).toSet shouldBe Set(
75+
RuleTransducer(Tla5~EW | (Avenue~>Tla5)~EW & Avenue~NS).toSeq should contain theSameElementsAs Seq(
7676
Rule(0x51100000,3,0, 0x04009000,0,0, 0x51100000,3,0, 0x71101300,2,1),
7777
Rule(0x51100000,1,0, 0x04009000,1,0, 0x51100000,1,0, 0x51101300,0,0))
78-
RuleTransducer(Tla5~EW & Avenue~NS | (Avenue~>Tla5)~EW & Avenue~SN).toSet shouldBe Set(
78+
RuleTransducer(Tla5~EW & Avenue~NS | (Avenue~>Tla5)~EW & Avenue~SN).toSeq should contain theSameElementsAs Seq(
7979
Rule(0x71101300,2,1, 0x04009000,3,0, 0x71101300,2,1, 0x51101300,2,0),
8080
Rule(0x51101300,0,0, 0x04009000,2,0, 0x51101300,0,0, 0x71101300,0,1))
81-
RuleTransducer(Tla5~EW & Avenue~SN | (Avenue~>Tla5)~EW).toSet shouldBe Set(
81+
RuleTransducer(Tla5~EW & Avenue~SN | (Avenue~>Tla5)~EW).toSeq should contain theSameElementsAs Seq(
8282
Rule(0x51101300,2,0, 0x04006100,3,0, 0x51101300,2,0, 0x51100000,3,0),
8383
Rule(0x71101300,0,1, 0x04006100,1,0, 0x71101300,0,1, 0x51100000,1,0))
8484
}

0 commit comments

Comments
 (0)