Repository: OpenRA/OpenRA
Pull request #21845: Support relaxed end points in TilingPath
Author: login: ashleynewson
Created: 2025-04-12T00:23:59Z | Merged: 2025-04-18T08:13:24Z

Whilst TilingPath has always allowed deviating from the path points between the start and end point, it didn't allow deviation from the start or end points themselves. This change allows the end point to deviate if the tiling would otherwise fail. The start point remains strictly positioned, as before. Loop end points (which must necessarily match the start) also remain strict.

The ExperimentalMapGenerator is modified to benefit from the relaxed tiling constraints. As a result, failed map generation due to tiling failures is much rarer, and will make some otherwise very inflexible template categories viable for tiling.

Includes some impure refactoring around the treatment of start, intermediate, and end segments. As such, this changes map generation output, even for maps which already tiled perfectly.

A previous workaround used for CnC road templates, whereby additional segments were defined for road ending templates, is now redundant and cleaned up.


Diff:
diff --git a/OpenRA.Mods.Common/MapGenerator/MatrixUtils.cs b/OpenRA.Mods.Common/MapGenerator/MatrixUtils.cs
index b95e1a1479bd..14e87e99f227 100644
--- a/OpenRA.Mods.Common/MapGenerator/MatrixUtils.cs
+++ b/OpenRA.Mods.Common/MapGenerator/MatrixUtils.cs
@@ -15,6 +15,7 @@
 using System.Globalization;
 using System.Linq;
 using OpenRA.Primitives;
+using OpenRA.Support;
 
 namespace OpenRA.Mods.Common.MapGenerator
 {
@@ -1502,5 +1503,34 @@ public static Matrix<int> NormalizeRangeInPlace(Matrix<int> matrix, int targetAm
 
 			return matrix;
 		}
+
+		/// <summary>
+		/// Rank all cell values and select the best (greatest compared) value.
+		/// If there are equally good best candidates, choose one at random.
+		/// </summary>
+		public static (int2 MPos, T Value) FindRandomBest<T>(
+			Matrix<T> matrix,
+			MersenneTwister random,
+			Comparison<T> comparison)
+		{
+			var candidates = new List<int2>();
+			var best = matrix[new int2(0, 0)];
+			for (var y = 0; y < matrix.Size.Y; y++)
+				for (var x = 0; x < matrix.Size.X; x++)
+				{
+					var rank = comparison(matrix[x, y], best);
+					if (rank > 0)
+					{
+						best = matrix[x, y];
+						candidates.Clear();
+					}
+
+					if (rank >= 0)
+						candidates.Add(new int2(x, y));
+				}
+
+			var choice = candidates[random.Next(candidates.Count)];
+			return (choice, best);
+		}
 	}
 }
diff --git a/OpenRA.Mods.Common/MapGenerator/TilingPath.cs b/OpenRA.Mods.Common/MapGenerator/TilingPath.cs
index a1b4059601e2..671b8b4ccfd7 100644
--- a/OpenRA.Mods.Common/MapGenerator/TilingPath.cs
+++ b/OpenRA.Mods.Common/MapGenerator/TilingPath.cs
@@ -212,6 +212,13 @@ public IEnumerable<TerrainTile> PossibleTiles()
 		/// </summary>
 		public int MinSeparation;
 
+		/// <summary>
+		/// If the path cannot be tiled exactly, the resulting tiling is allowed to deviate from
+		/// target end point by this Chebychev distance. Ignored for loops. This will be capped to
+		/// MaxDeviation at tiling time.
+		/// </summary>
+		public int MaxEndDeviation;
+
 		/// <summary>
 		/// Stores start type and direction.
 		/// </summary>
@@ -242,6 +249,7 @@ public TilingPath(
 			MaxDeviation = maxDeviation;
 			MaxSkip = 0;
 			MinSeparation = 0;
+			MaxEndDeviation = 0;
 			Start = new Terminal(startType, null);
 			End = new Terminal(endType, null);
 			Segments = permittedTemplates;
@@ -337,6 +345,9 @@ public CPos[] Tile(MersenneTwister random)
 			// The search is conducted from the path start node until the best possible cost of
 			// the end node is confirmed. This also populates possible intermediate nodes' costs.
 			//
+			// If the original target end node is unreachable (at MaxCost), a nearby node may be
+			// selected as a fallback end point, provided the target path isn't a loop.
+			//
 			// Then, from the end node, it works backwards. It finds any (random) suitable template
 			// segment which connects back to a previous node where the difference in cost is
 			// that of the template segment's cost, implying that the previous node is on an
@@ -563,12 +574,25 @@ int Progress(int from, int to)
 			var pathEnd = points[^1];
 			var orderedPermittedSegments = Segments.All.ToImmutableArray();
 			var permittedSegments = orderedPermittedSegments.ToImmutableHashSet();
+			var permittedStartSegments = Segments.Start.ToImmutableHashSet();
+			var permittedInnerSegments = Segments.Inner.ToImmutableHashSet();
+			var permittedEndSegments = Segments.End.ToImmutableHashSet();
 
 			const int MaxCost = int.MaxValue;
 			var segmentTypeToId = new Dictionary<string, int>();
-			var segmentsByStart = new List<List<TilingSegment>>();
-			var segmentsByEnd = new List<List<TilingSegment>>();
-			var costs = new List<Matrix<int>>();
+			var segmentsByStart = new List<List<(TilingSegment Segment, bool CanStart, bool CanInner, bool CanEnd)>>();
+			var segmentsByEnd = new List<List<(TilingSegment Segment, bool CanStart, bool CanInner, bool CanEnd)>>();
+
+			// We store the end costs of valid end segments separately to inner costs.
+			//
+			// Note also that:
+			// - The start cost is always zero and only applies to a single node.
+			// - Permitted end and inner segments may be distinct, but the end terminal could exist
+			//   in the permitted inner segments and shouldn't be a valid intermediate cost.
+			// - Avoids confusing start, inner, and end costs when processing looped paths.
+			// - We may be interested in multiple end costs if MaxEndDeviation is non-zero.
+			var endCosts = new Matrix<int>(size).Fill(MaxCost);
+			var innerCosts = new List<Matrix<int>>();
 			{
 				void RegisterSegmentType(string type)
 				{
@@ -578,7 +602,7 @@ void RegisterSegmentType(string type)
 					segmentTypeToId.Add(type, newId);
 					segmentsByStart.Add([]);
 					segmentsByEnd.Add([]);
-					costs.Add(new Matrix<int>(size).Fill(MaxCost));
+					innerCosts.Add(new Matrix<int>(size).Fill(MaxCost));
 				}
 
 				foreach (var segment in orderedPermittedSegments)
@@ -589,8 +613,13 @@ void RegisterSegmentType(string type)
 					var startTypeId = segmentTypeToId[segment.Start];
 					var endTypeId = segmentTypeToId[segment.End];
 					var tilePathSegment = new TilingSegment(template, segment, startTypeId, endTypeId);
-					segmentsByStart[startTypeId].Add(tilePathSegment);
-					segmentsByEnd[endTypeId].Add(tilePathSegment);
+					var tuple = (
+						tilePathSegment,
+						permittedStartSegments.Contains(segment) && segment.Start == start.SegmentType,
+						permittedInnerSegments.Contains(segment),
+						permittedEndSegments.Contains(segment) && segment.End == end.SegmentType);
+					segmentsByStart[startTypeId].Add(tuple);
+					segmentsByEnd[endTypeId].Add(tuple);
 				}
 			}
 
@@ -610,43 +639,18 @@ void SetPriorityAt(int typeId, CVec pos, int priority)
 
 			var pathStartTypeId = segmentTypeToId[start.SegmentType];
 			var pathEndTypeId = segmentTypeToId[end.SegmentType];
-			var innerTypeIds = Segments.Inner
-				.SelectMany(segment => new[] { segment.Start, segment.End })
-				.Select(segmentType => segmentTypeToId[segmentType])
-				.ToImmutableHashSet();
 
 			// Lower (closer to zero) costs are better matches.
 			// MaxScore means totally unacceptable.
 			int ScoreSegment(TilingSegment segment, CVec from)
 			{
-				if (from == pathStart)
-				{
-					if (segment.StartTypeId != pathStartTypeId)
-						return MaxCost;
-				}
-				else
-				{
-					if (!innerTypeIds.Contains(segment.StartTypeId))
-						return MaxCost;
-				}
-
 				var to = from + segment.Moves;
-				if (to == pathEnd)
-				{
-					if (segment.EndTypeId != pathEndTypeId)
-						return MaxCost;
-				}
-				else
-				{
-					if (!innerTypeIds.Contains(segment.EndTypeId))
-						return MaxCost;
 
-					if (isLoop && lowProgress[from.X, from.Y] > highProgress[to.X, to.Y] && highProgress[to.X, to.Y] != 0)
-					{
-						// We've missed the start/end of the loop and have potentially gone past it
-						// (as far as low and high progress are concerned).
-						return MaxCost;
-					}
+				if (isLoop && to != pathEnd && lowProgress[from.X, from.Y] > highProgress[to.X, to.Y] && highProgress[to.X, to.Y] != 0)
+				{
+					// We've missed the start/end of the loop and have potentially gone past it
+					// (as far as low and high progress are concerned).
+					return MaxCost;
 				}
 
 				var deviationAcc = 0;
@@ -700,10 +704,23 @@ int ScoreSegment(TilingSegment segment, CVec from)
 				return deviationAcc;
 			}
 
-			void UpdateFrom(CVec from, int fromTypeId, int fromCost)
+			void UpdateFrom(CVec from, int fromTypeId, bool isForStart)
 			{
-				foreach (var segment in segmentsByStart[fromTypeId])
+				var fromCost = isForStart ? 0 : innerCosts[fromTypeId][from.X, from.Y];
+
+				foreach (var (segment, canStart, canInner, canEnd) in segmentsByStart[fromTypeId])
 				{
+					if (isForStart)
+					{
+						if (!canStart)
+							continue;
+					}
+					else
+					{
+						if (!(canEnd || canInner))
+							continue;
+					}
+
 					var to = from + segment.Moves;
 					if (to.X < 0 || to.X >= size.X || to.Y < 0 || to.Y >= size.Y)
 						continue;
@@ -721,44 +738,59 @@ void UpdateFrom(CVec from, int fromTypeId, int fromCost)
 
 					var toCost = fromCost + segmentCost;
 					var toTypeId = segment.EndTypeId;
-					if (toCost < costs[toTypeId][to.X, to.Y])
+
+					if ((canStart || canInner) && toCost < innerCosts[toTypeId][to.X, to.Y])
 					{
-						costs[toTypeId][to.X, to.Y] = toCost;
+						innerCosts[toTypeId][to.X, to.Y] = toCost;
 						SetPriorityAt(toTypeId, to, toCost);
 					}
+
+					if (canEnd && toCost < endCosts[to.X, to.Y])
+						endCosts[to.X, to.Y] = toCost;
 				}
 
 				SetPriorityAt(fromTypeId, from, MaxCost);
 			}
 
-			// costs[pathStartTypeId][pathStart.X, pathStart.Y] is preset to
-			// MaxCost, but we pass in a cost of 0 for the first iteration. We
-			// leave it like this in case this is a looped path with a shared
-			// start and end point. We set it to 0 later when tracing back.
-			UpdateFrom(pathStart, pathStartTypeId, 0);
+			UpdateFrom(pathStart, pathStartTypeId, true);
 
 			while (true)
 			{
 				var (fromTypeId, from, priority) = GetNextPriority();
 
-				if (priority == MaxCost || from == pathEnd)
+				if (priority == MaxCost)
 					break;
 
-				UpdateFrom(from, fromTypeId, costs[fromTypeId][from.X, from.Y]);
+				UpdateFrom(from, fromTypeId, false);
 			}
 
 			// Trace back and update tiles
-			var resultPoints = new List<CPos>
-			{
-				new(pathEnd.X + minPoint.X, pathEnd.Y + minPoint.Y)
-			};
+			var resultPoints = new List<CPos>();
 
-			(CVec From, int FromTypeId) TraceBackStep(CVec to, int toTypeId, int toCost)
+			(CVec From, int FromTypeId) TraceBackStep(CVec to, int toTypeId, bool isForEnd)
 			{
+				var toCost = isForEnd ? endCosts[to.X, to.Y] : innerCosts[toTypeId][to.X, to.Y];
 				var candidates = new List<TilingSegment>();
-				foreach (var segment in segmentsByEnd[toTypeId])
+				foreach (var (segment, canStart, canInner, canEnd) in segmentsByEnd[toTypeId])
 				{
+					if (isForEnd)
+					{
+						if (!canEnd)
+							continue;
+					}
+					else
+					{
+						if (!(canStart || canInner))
+							continue;
+					}
+
 					var from = to - segment.Moves;
+					var mustStart =
+						from == pathStart && segment.StartTypeId == pathStartTypeId;
+
+					if (mustStart && !canStart)
+						continue;
+
 					if (from.X < 0 || from.X >= size.X || from.Y < 0 || from.Y >= size.Y)
 						continue;
 
@@ -774,7 +806,9 @@ void UpdateFrom(CVec from, int fromTypeId, int fromCost)
 						continue;
 
 					var fromCost = toCost - segmentCost;
-					if (fromCost == costs[segment.StartTypeId][from.X, from.Y])
+					var requiredFromCost =
+						mustStart ? 0 : innerCosts[segment.StartTypeId][from.X, from.Y];
+					if (fromCost == requiredFromCost)
 						candidates.Add(segment);
 				}
 
@@ -794,24 +828,80 @@ void UpdateFrom(CVec from, int fromTypeId, int fromCost)
 			}
 
 			{
-				var to = pathEnd;
 				var toTypeId = pathEndTypeId;
-				var bestCost = costs[toTypeId][to.X, to.Y];
-				if (bestCost == MaxCost)
-					return null;
 
-				// For non-loops, this remained unset at MaxCost. For loops,
-				// this was the shared start and end point and got set to
-				// bestCost. We set it to 0 for traceback, but perform the
-				// first iteration using bestCost. (The opposite of how we
-				// traced forward.)
-				costs[pathStartTypeId][pathStart.X, pathStart.Y] = 0;
+				if (endCosts[pathEnd.X, pathEnd.Y] == MaxCost)
+				{
+					// There isn't a tiling solution to the exact target end point. If enabled,
+					// search for an alternative, nearby end point.
+					var maxEndDeviation = Math.Min(MaxEndDeviation, MaxDeviation);
+					if (maxEndDeviation == 0 || isLoop)
+						return null;
+
+					// Find the closest points which are near the original target end point and
+					// have a tiling solution.
+					const int Unreached = int.MaxValue;
+					const int Unsolved = int.MaxValue - 1;
+					var fallbackDistances =
+						new Matrix<int>(maxEndDeviation * 2 + 1, maxEndDeviation * 2 + 1)
+							.Fill(Unreached);
+
+					int? FallbacksFiller(int2 xy, int distance)
+					{
+						if (fallbackDistances[xy] != Unreached)
+							return null;
+
+						var p = new int2(pathEnd.X - maxEndDeviation, pathEnd.Y - maxEndDeviation) + xy;
+
+						if (!deviations.ContainsXY(p.X, p.Y) || deviations[p.X, p.Y] == OverDeviation)
+						{
+							fallbackDistances[xy] = Unsolved;
+							return null;
+						}
+
+						fallbackDistances[xy] =
+							endCosts[p.X, p.Y] != MaxCost ? distance : Unsolved;
+						return distance + 1;
+					}
+
+					MatrixUtils.FloodFill(
+						fallbackDistances.Size,
+						[(new int2(maxEndDeviation, maxEndDeviation), 0)],
+						FallbacksFiller,
+						Direction.Spread4);
+
+					var bestDistance = fallbackDistances.Data.Min();
+					if (bestDistance == Unreached || bestDistance == Unsolved)
+						return null;
+
+					// Find the lowest cost candidate end point.
+					var fallbackCosts = new Matrix<int>(maxEndDeviation * 2 + 1, maxEndDeviation * 2 + 1);
+					for (var y = -maxEndDeviation; y <= maxEndDeviation; y++)
+						for (var x = -maxEndDeviation; x <= maxEndDeviation; x++)
+						{
+							var fallbackXy = new int2(x + maxEndDeviation, y + maxEndDeviation);
+							var p = new int2(x + pathEnd.X, y + pathEnd.Y);
+							fallbackCosts[fallbackXy] =
+								(fallbackDistances[fallbackXy] == bestDistance) ? endCosts[p] : MaxCost;
+						}
+
+					var (chosenXy, _) = MatrixUtils.FindRandomBest(
+						fallbackCosts,
+						random,
+						(a, b) => b.CompareTo(a));
+
+					pathEnd = new CVec(chosenXy.X - maxEndDeviation, chosenXy.Y - maxEndDeviation) + pathEnd;
+				}
+
+				var to = pathEnd;
+
+				resultPoints.Add(new(to.X + minPoint.X, to.Y + minPoint.Y));
 
-				(to, toTypeId) = TraceBackStep(to, toTypeId, bestCost);
+				(to, toTypeId) = TraceBackStep(to, toTypeId, true);
 
 				// No need to check direction. If that is an issue, I have bigger problems to worry about.
 				while (to != pathStart)
-					(to, toTypeId) = TraceBackStep(to, toTypeId, costs[toTypeId][to.X, to.Y]);
+					(to, toTypeId) = TraceBackStep(to, toTypeId, false);
 			}
 
 			// Traced back in reverse, so reverse the reversal.
@@ -1275,5 +1365,19 @@ public static bool ValidatePathPoints(CPos[] points)
 
 			return true;
 		}
+
+		/// <summary>Set MaxEndDeviation.</summary>
+		public TilingPath SetMaxEndDeviation(int maxEndDeviation)
+		{
+			MaxEndDeviation = maxEndDeviation;
+			return this;
+		}
+
+		/// <summary>Allow end point deviation as far as MaxDeviation will allow.</summary>
+		public TilingPath SetAutoEndDeviation()
+		{
+			MaxEndDeviation = int.MaxValue;
+			return this;
+		}
 	}
 }
diff --git a/OpenRA.Mods.Common/Traits/World/ExperimentalMapGenerator.cs b/OpenRA.Mods.Common/Traits/World/ExperimentalMapGenerator.cs
index 43ee401f19fb..337ad4d28396 100644
--- a/OpenRA.Mods.Common/Traits/World/ExperimentalMapGenerator.cs
+++ b/OpenRA.Mods.Common/Traits/World/ExperimentalMapGenerator.cs
@@ -662,6 +662,7 @@ TerrainTile PickTile(ushort tileType)
 						beachPermittedTemplates);
 					beachPath
 						.ExtendEdge(4)
+						.SetAutoEndDeviation()
 						.OptimizeLoop();
 					tiledBeaches[i] =
 						beachPath.Tile(beachTilingRandom)
@@ -728,6 +729,7 @@ TerrainTile PickTile(ushort tileType)
 							nonLoopedCliffPermittedTemplates);
 					cliffPath
 						.ExtendEdge(4)
+						.SetAutoEndDeviation()
 						.OptimizeLoop();
 					if (cliffPath.Tile(cliffTilingRandom) == null)
 						throw new MapGenerationException("Could not fit tiles for exterior circle cliffs");
@@ -808,6 +810,7 @@ TerrainTile PickTile(ushort tileType)
 								nonLoopedCliffPermittedTemplates);
 						cliffPath
 							.ExtendEdge(4)
+							.SetAutoEndDeviation()
 							.OptimizeLoop();
 						if (cliffPath.Tile(cliffTilingRandom) == null)
 							throw new MapGenerationException("Could not fit tiles for cliffs");
@@ -1172,6 +1175,7 @@ void TestSymmetry(CPos[] sources, CPos destination)
 						.ExtendEdge(2 * roadTotalShrink + RoadMinimumShrinkLength)
 						.Shrink(roadTotalShrink, RoadMinimumShrinkLength)
 						.InertiallyExtend(RoadStraightenGrow, RoadInertialRange)
+						.SetAutoEndDeviation()
 						.OptimizeLoop()
 						.RetainIfValid();
 
diff --git a/mods/cnc/tilesets/desert.yaml b/mods/cnc/tilesets/desert.yaml
index 0141818b43f3..dce8aab34a15 100644
--- a/mods/cnc/tilesets/desert.yaml
+++ b/mods/cnc/tilesets/desert.yaml
@@ -1069,16 +1069,6 @@ Templates:
 				Inner: Road
 				Points: 2,1, 2,0
 				Start: Clear.U
-			Segment@1extended:
-				End: Clear.D
-				Inner: Road
-				Points: 1,0, 1,1, 1,2
-				Start: Road.D
-			Segment@2extended:
-				End: Road.U
-				Inner: Road
-				Points: 2,2, 2,1, 2,0
-				Start: Clear.U
 	Template@94:
 		Id: 94
 		Images: d02.des
@@ -1100,16 +1090,6 @@ Templates:
 				Inner: Road
 				Points: 1,1, 2,1
 				Start: Clear.R
-			Segment@1extended:
-				End: Clear.L
-				Inner: Road
-				Points: 2,0, 1,0, 0,0
-				Start: Road.L
-			Segment@2extended:
-				End: Road.R
-				Inner: Road
-				Points: 0,1, 1,1, 2,1
-				Start: Clear.R
 	Template@95:
 		Id: 95
 		Images: d03.des
@@ -1129,16 +1109,6 @@ Templates:
 				Inner: Road
 				Points: 0,1, 0,2
 				Start: Clear.D
-			Segment@1extended:
-				End: Clear.U
-				Inner: Road
-				Points: 1,2, 1,1, 1,0
-				Start: Road.U
-			Segment@2extended:
-				End: Road.D
-				Inner: Road
-				Points: 0,0, 0,1, 0,2
-				Start: Clear.D
 	Template@96:
 		Id: 96
 		Images: d04.des
@@ -1159,16 +1129,6 @@ Templates:
 				Inner: Road
 				Points: 1,1, 0,1
 				Start: Clear.L
-			Segment@1extended:
-				End: Clear.R
-				Inner: Road
-				Points: 0,2, 1,2, 2,2
-				Start: Road.R
-			Segment@2extended:
-				End: Road.L
-				Inner: Road
-				Points: 2,1, 1,1, 0,1
-				Start: Clear.L
 	Template@97:
 		Id: 97
 		Images: d05.des
diff --git a/mods/cnc/tilesets/snow.yaml b/mods/cnc/tilesets/snow.yaml
index ac0bd2937a17..7d6de69f253f 100644
--- a/mods/cnc/tilesets/snow.yaml
+++ b/mods/cnc/tilesets/snow.yaml
@@ -1143,16 +1143,6 @@ Templates:
 				Inner: Road
 				Points: 2,1, 2,0
 				Start: Clear.U
-			Segment@1extended:
-				End: Clear.D
-				Inner: Road
-				Points: 1,0, 1,1, 1,2
-				Start: Road.D
-			Segment@2extended:
-				End: Road.U
-				Inner: Road
-				Points: 2,2, 2,1, 2,0
-				Start: Clear.U
 	Template@94:
 		Id: 94
 		Images: d02.sno
@@ -1174,16 +1164,6 @@ Templates:
 				Inner: Road
 				Points: 1,1, 2,1
 				Start: Clear.R
-			Segment@1extended:
-				End: Clear.L
-				Inner: Road
-				Points: 2,0, 1,0, 0,0
-				Start: Road.L
-			Segment@2extended:
-				End: Road.R
-				Inner: Road
-				Points: 0,1, 1,1, 2,1
-				Start: Clear.R
 	Template@95:
 		Id: 95
 		Images: d03.sno
@@ -1203,16 +1183,6 @@ Templates:
 				Inner: Road
 				Points: 0,1, 0,2
 				Start: Clear.D
-			Segment@1extended:
-				End: Clear.U
-				Inner: Road
-				Points: 1,2, 1,1, 1,0
-				Start: Road.U
-			Segment@2extended:
-				End: Road.D
-				Inner: Road
-				Points: 0,0, 0,1, 0,2
-				Start: Clear.D
 	Template@96:
 		Id: 96
 		Images: d04.sno
@@ -1233,16 +1203,6 @@ Templates:
 				Inner: Road
 				Points: 1,1, 0,1
 				Start: Clear.L
-			Segment@1extended:
-				End: Clear.R
-				Inner: Road
-				Points: 0,2, 1,2, 2,2
-				Start: Road.R
-			Segment@2extended:
-				End: Road.L
-				Inner: Road
-				Points: 2,1, 1,1, 0,1
-				Start: Clear.L
 	Template@97:
 		Id: 97
 		Images: d05.sno
diff --git a/mods/cnc/tilesets/temperat.yaml b/mods/cnc/tilesets/temperat.yaml
index 47549b053772..d8d1342a0614 100644
--- a/mods/cnc/tilesets/temperat.yaml
+++ b/mods/cnc/tilesets/temperat.yaml
@@ -1131,16 +1131,6 @@ Templates:
 				Inner: Road
 				Points: 2,1, 2,0
 				Start: Clear.U
-			Segment@1extended:
-				End: Clear.D
-				Inner: Road
-				Points: 1,0, 1,1, 1,2
-				Start: Road.D
-			Segment@2extended:
-				End: Road.U
-				Inner: Road
-				Points: 2,2, 2,1, 2,0
-				Start: Clear.U
 	Template@94:
 		Id: 94
 		Images: d02.tem
@@ -1162,16 +1152,6 @@ Templates:
 				Inner: Road
 				Points: 1,1, 2,1
 				Start: Clear.R
-			Segment@1extended:
-				End: Clear.L
-				Inner: Road
-				Points: 2,0, 1,0, 0,0
-				Start: Road.L
-			Segment@2extended:
-				End: Road.R
-				Inner: Road
-				Points: 0,1, 1,1, 2,1
-				Start: Clear.R
 	Template@95:
 		Id: 95
 		Images: d03.tem
@@ -1191,16 +1171,6 @@ Templates:
 				Inner: Road
 				Points: 0,1, 0,2
 				Start: Clear.D
-			Segment@1extended:
-				End: Clear.U
-				Inner: Road
-				Points: 1,2, 1,1, 1,0
-				Start: Road.U
-			Segment@2extended:
-				End: Road.D
-				Inner: Road
-				Points: 0,0, 0,1, 0,2
-				Start: Clear.D
 	Template@96:
 		Id: 96
 		Images: d04.tem
@@ -1221,16 +1191,6 @@ Templates:
 				Inner: Road
 				Points: 1,1, 0,1
 				Start: Clear.L
-			Segment@1extended:
-				End: Clear.R
-				Inner: Road
-				Points: 0,2, 1,2, 2,2
-				Start: Road.R
-			Segment@2extended:
-				End: Road.L
-				Inner: Road
-				Points: 2,1, 1,1, 0,1
-				Start: Clear.L
 	Template@97:
 		Id: 97
 		Images: d05.tem
diff --git a/mods/cnc/tilesets/winter.yaml b/mods/cnc/tilesets/winter.yaml
index ec15ffcc54ee..dd375c65d0e0 100644
--- a/mods/cnc/tilesets/winter.yaml
+++ b/mods/cnc/tilesets/winter.yaml
@@ -1115,16 +1115,6 @@ Templates:
 				Inner: Road
 				Points: 2,1, 2,0
 				Start: Clear.U
-			Segment@1extended:
-				End: Clear.D
-				Inner: Road
-				Points: 1,0, 1,1, 1,2
-				Start: Road.D
-			Segment@2extended:
-				End: Road.U
-				Inner: Road
-				Points: 2,2, 2,1, 2,0
-				Start: Clear.U
 	Template@94:
 		Id: 94
 		Images: d02.win
@@ -1146,16 +1136,6 @@ Templates:
 				Inner: Road
 				Points: 1,1, 2,1
 				Start: Clear.R
-			Segment@1extended:
-				End: Clear.L
-				Inner: Road
-				Points: 2,0, 1,0, 0,0
-				Start: Road.L
-			Segment@2extended:
-				End: Road.R
-				Inner: Road
-				Points: 0,1, 1,1, 2,1
-				Start: Clear.R
 	Template@95:
 		Id: 95
 		Images: d03.win
@@ -1175,16 +1155,6 @@ Templates:
 				Inner: Road
 				Points: 0,1, 0,2
 				Start: Clear.D
-			Segment@1extended:
-				End: Clear.U
-				Inner: Road
-				Points: 1,2, 1,1, 1,0
-				Start: Road.U
-			Segment@2extended:
-				End: Road.D
-				Inner: Road
-				Points: 0,0, 0,1, 0,2
-				Start: Clear.D
 	Template@96:
 		Id: 96
 		Images: d04.win
@@ -1205,16 +1175,6 @@ Templates:
 				Inner: Road
 				Points: 1,1, 0,1
 				Start: Clear.L
-			Segment@1extended:
-				End: Clear.R
-				Inner: Road
-				Points: 0,2, 1,2, 2,2
-				Start: Road.R
-			Segment@2extended:
-				End: Road.L
-				Inner: Road
-				Points: 2,1, 1,1, 0,1
-				Start: Clear.L
 	Template@97:
 		Id: 97
 		Images: d05.win


Reviews:
user: login: PunkPun
body: The symmety now becomes quite broken (see roads)

<img width="302" alt="Screenshot 2025-04-13 at 12 56 16" src="https://github.com/user-attachments/assets/bd7de63a-4b99-46d6-ad21-410272b44ed0" />

<img width="288" alt="Screenshot 2025-04-13 at 12 42 37" src="https://github.com/user-attachments/assets/cb804210-847f-4cd5-9b1e-16c2145ba8b2" />

<img width="286" alt="Screenshot 2025-04-13 at 12 48 07" src="https://github.com/user-attachments/assets/9de22e22-38bb-41f1-9c84-998729f6788d" />

<img width="288" alt="Screenshot 2025-04-13 at 12 44 50" src="https://github.com/user-attachments/assets/156a0d61-b13a-4a20-a9f4-6c21c403d5c0" />

Could we maybe do road cutting? Where if the generation fails on one of the mirrors, we should cut it at others?

Or maybe expose the road tolerance to the map editor? For instance from a competitive aspect many of these deviations are unnaceptable, but in other circuimstances they might be reasonable.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-13T09:59:10Z
user: login: ashleynewson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T11:40:54Z
user: login: ashleynewson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T11:41:05Z
user: login: PunkPun
body: Worst case one can just disable roads if he wants more perfect symmetry
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-18T08:13:19Z

Review comments:
diff_hunk: @@ -1275,5 +1365,19 @@ public static bool ValidatePathPoints(CPos[] points)
 
 			return true;
 		}
+
+		/// <summary>Set MaxEndDeviation.</summary>
+		public TilingPath WithMaxEndDeviation(int maxEndDeviation)
path: OpenRA.Mods.Common/MapGenerator/TilingPath.cs
user: login: PunkPun
body: ```suggestion
		public TilingPath SetMaxEndDeviation(int maxEndDeviation)
```

?
created_at: 2025-04-13T09:36:01Z
author_association: MEMBER
diff_hunk: @@ -1275,5 +1365,19 @@ public static bool ValidatePathPoints(CPos[] points)
 
 			return true;
 		}
+
+		/// <summary>Set MaxEndDeviation.</summary>
+		public TilingPath WithMaxEndDeviation(int maxEndDeviation)
+		{
+			MaxEndDeviation = maxEndDeviation;
+			return this;
+		}
+
+		/// <summary>Allow end point deviation as far as MaxDeviation will allow.</summary>
+		public TilingPath AutoEndDeviation()
path: OpenRA.Mods.Common/MapGenerator/TilingPath.cs
user: login: PunkPun
body: ```suggestion
		public TilingPath SetAutoEndDeviation()
```

?
created_at: 2025-04-13T09:36:58Z
author_association: MEMBER
diff_hunk: @@ -1275,5 +1365,19 @@ public static bool ValidatePathPoints(CPos[] points)
 
 			return true;
 		}
+
+		/// <summary>Set MaxEndDeviation.</summary>
+		public TilingPath WithMaxEndDeviation(int maxEndDeviation)
path: OpenRA.Mods.Common/MapGenerator/TilingPath.cs
user: login: ashleynewson
body: Done.
created_at: 2025-04-13T11:40:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1275,5 +1365,19 @@ public static bool ValidatePathPoints(CPos[] points)
 
 			return true;
 		}
+
+		/// <summary>Set MaxEndDeviation.</summary>
+		public TilingPath WithMaxEndDeviation(int maxEndDeviation)
+		{
+			MaxEndDeviation = maxEndDeviation;
+			return this;
+		}
+
+		/// <summary>Allow end point deviation as far as MaxDeviation will allow.</summary>
+		public TilingPath AutoEndDeviation()
path: OpenRA.Mods.Common/MapGenerator/TilingPath.cs
user: login: ashleynewson
body: Done.
created_at: 2025-04-13T11:41:05Z
author_association: CONTRIBUTOR

PR comments:
user: login: ashleynewson
created_at: 2025-04-13T11:37:47Z
body: > The symmety now becomes quite broken (see roads)

Those road problems come from the road planning stage, not the road tiling stage, so these changes shouldn't be directly relevant to those issues. (Though, this change might make it easier to fix.)

Road symmetry was always a bit broken for non-trivial symmetries. Diagonals also became a little less reliable when I modified the road algorithm in an earlier change.

I can look into addressing this separately from this PR.
author_association: CONTRIBUTOR
user: login: PunkPun
created_at: 2025-04-18T08:10:50Z
body: This definitelly regresses road symmetry, even if it is not supposed to
author_association: MEMBER
user: login: PunkPun
created_at: 2025-04-18T08:14:29Z
body: [changelog](https://github.com/OpenRA/OpenRA/wiki/Changelog-(bleed)/_compare/b160b4d1c73a5b607360ab0d5d43dbbfab92e573...3542bda8c4319c9c0bd6ae09fcc50dc238e39c16)
author_association: MEMBER
user: login: ashleynewson
created_at: 2025-04-18T12:58:33Z
body: I haven't noticed specific regressions myself. If there is a regression, it could be due to:

- Previously failing seeds now producing relaxed tilings.
- The slight re-randomization in tile choices just happening to be slightly worse.
- Removal of the additional "extended" segments hack for CnC roads.

I'll take a closer look at a later time. I do intend to see if I can improve the _planning_ symmetry of roads though, so I'd probably be looking at that first.
author_association: CONTRIBUTOR