#region Copyright & License Information /* * Copyright (c) The OpenRA Developers and Contributors * This file is part of OpenRA, which is free software. It is made * available to you under the terms of the GNU General Public License * as published by the Free Software Foundation, either version 3 of * the License, or (at your option) any later version. For more * information, see COPYING. */ #endregion using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.Linq; using OpenRA.Primitives; using OpenRA.Support; namespace OpenRA.Mods.Common.MapGenerator { /// Path to be tiled onto a map using MultiBrushSegments. public sealed class TilingPath { /// Describes the type and direction of the start or end of a TilingPath. public struct Terminal { public string Type; /// /// Direction to use for this terminal. /// If the direction here is null, it will be determined automatically later. /// public Direction? Direction; /// /// A string which can match the format used by MultiBrushSegment's Start or End. /// public readonly string SegmentType { get { var direction = Direction ?? throw new InvalidOperationException("Direction is null"); return $"{Type}.{direction}"; } } public Terminal(string type, Direction? direction) { Type = type; Direction = direction; } } /// /// Describes the permitted start, middle, and end segments/MultiBrushes that can be used /// to tile a path. /// public sealed class PermittedSegments { public readonly ImmutableArray Start; public readonly ImmutableArray Inner; public readonly ImmutableArray End; public IEnumerable All => Start.Union(Inner).Union(End); public PermittedSegments( IEnumerable start, IEnumerable inner, IEnumerable end) { Start = start.ToImmutableArray(); Inner = inner.ToImmutableArray(); End = end.ToImmutableArray(); } public PermittedSegments( IReadOnlyList multiBrushes, IEnumerable all) { var array = all.ToImmutableArray(); Start = array; Inner = array; End = array; } /// /// Creates a PermittedSegments using only the given types. /// public static PermittedSegments FromType( IReadOnlyList multiBrushes, IEnumerable types) => new(multiBrushes, FindSegments(multiBrushes, types)); /// /// Creates a PermittedSegments suitable for a path with given inner and terminal types /// at the start and end. /// public static PermittedSegments FromInnerAndTerminalTypes( IReadOnlyList multiBrushes, IEnumerable innerTypes, IEnumerable terminalTypes) { var innerTypesArray = innerTypes.ToImmutableArray(); var terminalTypesArray = terminalTypes.ToImmutableArray(); return new( FindSegments(multiBrushes, terminalTypesArray, innerTypesArray, innerTypesArray), FindSegments(multiBrushes, innerTypesArray), FindSegments(multiBrushes, innerTypesArray, innerTypesArray, terminalTypesArray)); } /// /// Creates a PermittedSegments suitable for a path with given inner and terminal types /// at the start and end. /// public static PermittedSegments FromTypes( IReadOnlyList multiBrushes, IEnumerable startTypes, IEnumerable innerTypes, IEnumerable endTypes) { var startTypesArray = startTypes.ToImmutableArray(); var innerTypesArray = innerTypes.ToImmutableArray(); var endTypesArray = endTypes.ToImmutableArray(); return new( FindSegments(multiBrushes, startTypesArray, innerTypesArray, innerTypesArray), FindSegments(multiBrushes, innerTypesArray), FindSegments(multiBrushes, innerTypesArray, innerTypesArray, endTypesArray)); } /// /// Equivalent to FindSegments(multiBrushes, types, types, types). /// public static IEnumerable FindSegments( IReadOnlyList multiBrushes, IEnumerable types) { var array = types.ToImmutableArray(); return FindSegments(multiBrushes, array, array, array); } /// /// Filter MultiBrushes to segments that use the given start, inner, and end types. /// public static IEnumerable FindSegments( IReadOnlyList multiBrushes, IEnumerable startTypes, IEnumerable innerTypes, IEnumerable endTypes) { var filtered = new List(); var startTypesArray = startTypes.ToImmutableArray(); var innerTypesArray = innerTypes.ToImmutableArray(); var endTypesArray = endTypes.ToImmutableArray(); foreach (var multiBrush in multiBrushes) if (startTypesArray.Any(multiBrush.Segment.HasStartType) && innerTypesArray.Any(multiBrush.Segment.HasInnerType) && endTypesArray.Any(multiBrush.Segment.HasEndType)) { filtered.Add(multiBrush); } return [.. filtered]; } } public Map Map; /// /// /// Target point sequence to fit MultiBrushSegments to. Whether these CPos positions /// represent cell corners or cell centers is dependent on the system used by the path's /// PermittedSegments' MultiBrushSegments. /// /// /// If null, Tiling will be a no-op. If non-null, must have at least two points. /// /// /// A loop must have the start and end points equal. /// /// public CPos[] Points; /// /// Maximum permitted Chebyshev distance that layed MultiBrushSegments may be from the /// specified points. /// public int MaxDeviation; /// /// Determines how much corner-cutting is allowed. /// A value of zero will result in a value being derived from MaxDeviation. /// public int MaxSkip; /// /// Increases separation between permitted tiling regions of different parts of the path. /// public int MinSeparation; /// /// 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. /// public int MaxEndDeviation; /// /// Stores start type and direction. /// public Terminal Start; /// /// Stores end type and direction. /// public Terminal End; public PermittedSegments Brushes; /// Whether the start and end points are the same. public bool IsLoop { get => Points != null && Points[0] == Points[^1]; } public TilingPath( Map map, CPos[] points, int maxDeviation, string startType, string endType, PermittedSegments permittedTemplates) { Map = map; Points = points; MaxDeviation = maxDeviation; MaxSkip = 0; MinSeparation = 0; MaxEndDeviation = 0; Start = new Terminal(startType, null); End = new Terminal(endType, null); Brushes = permittedTemplates; } /// /// Convenience method to create TilingPaths using common settings. /// Start and end terminal types are the same. /// PermittedSegments are derived from brushes and inner/terminal types. /// Loops will automatically use only the inner type. /// Uses automatic end deviation and loop optimization. /// public static TilingPath QuickCreate( Map map, IReadOnlyList brushes, CPos[] points, int maxDeviation, string innerSegmentType, string terminalSegmentType) { var nonLoopedRoadPermittedTemplates = PermittedSegments.FromInnerAndTerminalTypes( brushes, [innerSegmentType], [terminalSegmentType]); var loopedRoadPermittedTemplates = PermittedSegments.FromType(brushes, [innerSegmentType]); var isLoop = points[0] == points[^1]; TilingPath path; if (isLoop) path = new TilingPath( map, points, maxDeviation, innerSegmentType, innerSegmentType, loopedRoadPermittedTemplates); else path = new TilingPath( map, points, maxDeviation, terminalSegmentType, terminalSegmentType, nonLoopedRoadPermittedTemplates); path .SetAutoEndDeviation() .OptimizeLoop(); return path; } sealed class TilingSegment { public readonly MultiBrush MultiBrush; public readonly int StartTypeId; public readonly int EndTypeId; public readonly CVec Offset; public readonly CVec Moves; public readonly CVec[] RelativePoints; public readonly Direction EndDirection; public TilingSegment(MultiBrush multiBrush, int startId, int endId) { MultiBrush = multiBrush; StartTypeId = startId; EndTypeId = endId; Offset = multiBrush.Segment.Points[0]; Moves = multiBrush.Segment.Points[^1] - Offset; RelativePoints = multiBrush.Segment.Points .Select(p => p - multiBrush.Segment.Points[0]) .ToArray(); EndDirection = multiBrush.Segment.EndDirection; for (var i = 0; i < RelativePoints.Length - 1; i++) { var direction = DirectionExts.FromCVec(RelativePoints[i + 1] - RelativePoints[i]); if (direction == Direction.None) throw new ArgumentException("MultiBrushSegment has duplicate points in sequence"); } } } /// /// /// Attempt to tile the given path, producing a new MultiBrush if the path could be tiled, /// or null if the path could not be tiled within constraints. /// /// /// The resulting MultiBrush is created from stitching MultiBrushes from the /// PermittedSegments together, and will contain a segment that represents the stitched /// segments of the constituent MultiBrushes. /// /// public MultiBrush Tile(MersenneTwister random) { // This is essentially a Dijkstra's algorithm best-first search. // // The search is performed over a 3-dimensional space: (x, y, connection type). // Connection types correspond to the .Start or .End values of MultiBrushSegments. // // The best found costs of the nodes in this space are stored as an array of matrices. // There is a matrix for each possible connection type, and each matrix stores the // (current) best costs at the (x, y) locations for that given connection type. // // The directed edges between the nodes of this 3-dimensional space are defined by the // MultiBrushSegments within the permitted set of segments. For example, a segment // defined as // // Segment: // Start: Beach.L // End: Beach.D // Points: 3,1, 2,1, 2,2, 2,3 // // may connect a node from (10, 10) in the "Beach.L" matrix to node (9, 12) in the // "Beach.D" matrix. (The overall point displacement is (2,3) - (3,1) = (-1, +2)) // // The cost of a transition/link/edge between nodes is defined by how well the // MultiBrushSegment fits the path (how little "deviation" is accumulates). However, in // order for a transition to be allowed at all, it must satisfy some constraints: // // - It must not regress backward along the path (but no immediate progress is OK). // - If it makes exactly zero progress, it must not end facing towards overall // decreasing progress or strictly neutral progress (neither earliest or latest // closest points differ). // - It must not deviate at any point in the segment beyond MaxDeviation from the path. // - It must not skip to much later path points (which may be within MaxDeviation). // // Progress is measured as a combo of both the earliest and latest closest path points. // // 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 // segment which connects back to a previous node where the difference in cost is // that of the segment's cost, implying that the previous node is on an // optimal path towards the end node. This process repeats until the start node is // reached, merging MultiBrushes into a result along the way. // // Note that this algorithm makes a few (reasonable) assumptions about the shapes of // MultiBrushes, such as that they don't individually snake around too much. The actual // tiles of a MultiBrush are ignored during the search, with only the segment being // used to calculate transition cost and validity. if (Points == null) return null; var start = Start; var end = End; start.Direction ??= DirectionExts.FromCVec(Points[1] - Points[0]); end.Direction ??= DirectionExts.FromCVec(IsLoop ? Points[1] - Points[0] : Points[^1] - Points[^2]); var maxSkip = MaxSkip > 0 ? MaxSkip : (2 * MaxDeviation + 1); var scanRange = MaxDeviation + MinSeparation; var minPoint = new CPos( Points.Min(p => p.X) - scanRange, Points.Min(p => p.Y) - scanRange); var maxPoint = new CPos( Points.Max(p => p.X) + scanRange, Points.Max(p => p.Y) + scanRange); var points = Points .Select(point => point - minPoint) .ToArray(); var isLoop = IsLoop; // grid points (not squares), so these are offset 0.5 from tile centers. var size = new int2(1 + maxPoint.X - minPoint.X, 1 + maxPoint.Y - minPoint.Y); var sizeXY = size.X * size.Y; const int OverDeviation = int.MaxValue; const int InvalidProgress = int.MaxValue; // How far away from the path this point is. var deviations = new Matrix(size).Fill(OverDeviation); var lowProgress = new Matrix(size).Fill(InvalidProgress); var highProgress = new Matrix(size).Fill(InvalidProgress); var progressModulus = IsLoop ? points.Length - 1 : points.Length; // The following only apply to looped paths var forwardProgressLimit = (progressModulus + 1) / 2; var backwardProgressLimit = progressModulus / 2; // MinValue essentially means "never match me". var oppositeProgress = (IsLoop && forwardProgressLimit == backwardProgressLimit) ? forwardProgressLimit : int.MinValue; // Find the progress difference of two progress values. For loops high progress values // wrap around to low ones. (Think of loops' progress like a 24 hour clock, // where 22 -> 2 is a difference of 4, and 2 -> 22 is a difference of -4). int Progress(int from, int to) { if (IsLoop) { var progress = (progressModulus + to - from) % progressModulus; if (progress < forwardProgressLimit) return progress; else if (progress > backwardProgressLimit) return progress - progressModulus; else return oppositeProgress; } else { return to - from; } } { var progressSeeds = new List<(int2, int)>(); for (var pointI = 0; pointI < progressModulus; pointI++) { var point = points[pointI]; lowProgress[point.X, point.Y] = pointI; highProgress[point.X, point.Y] = pointI; progressSeeds.Add((new int2(point.X, point.Y), 0)); } (int Low, int High) FindLowAndHigh(List values) { if (values.Count == 0) return (InvalidProgress, InvalidProgress); if (values.Count == 1) return (values[0], values[0]); if (IsLoop) { // For loops, with a list of 2+ sorted progress values, there are 2 cases: // - The values are spatially grouped, such that the values are contained // in under a half of the progress range, and the largest gap between // values is more than half of the progress range. This means that going // from before the gap to after it is an overall negative progress // change. (It must be the only negative progress change one as there can // only be one gap that is over half of the progress range.) In this // case, there is an obvious start and end to the group, with an overall // positive progress change. // - The values are dispersed such that there is no obvious start or end. if (Progress(values[^1], values[0]) < 0) return (values[0], values[^1]); for (var i = 0; i < values.Count - 1; i++) if (Progress(values[i], values[i + 1]) < 0) return (values[i + 1], values[i]); return (InvalidProgress, InvalidProgress); } else { return (values[0], values[^1]); } } var lows = new List(8); var highs = new List(8); int? ProgressFiller(int2 xy, int deviation) { if (deviations[xy] != OverDeviation) return null; deviations[xy] = deviation; // low and high progress is preset for 0-deviation. if (deviation == 0) return 1; lows.Clear(); highs.Clear(); foreach (var offset in DirectionExts.Spread8) { var neighbor = xy + offset; if (!deviations.ContainsXY(neighbor) || deviations[neighbor] >= deviation || lowProgress[neighbor] == InvalidProgress || highProgress[neighbor] == InvalidProgress) { continue; } lows.Add(lowProgress[neighbor]); highs.Add(highProgress[neighbor]); } lows.Sort(); highs.Sort(); (lowProgress[xy], _) = FindLowAndHigh(lows); (_, highProgress[xy]) = FindLowAndHigh(highs); if (deviation == scanRange) return null; return deviation + 1; } MatrixUtils.FloodFill( size, progressSeeds, ProgressFiller, DirectionExts.Spread8); var separationSeeds = new List<(int2, int)>(); for (var y = 0; y < size.Y; y++) for (var x = 0; x < size.X; x++) { var xy = new int2(x, y); var low = lowProgress[xy]; var high = highProgress[xy]; if (low == InvalidProgress || high == InvalidProgress) { separationSeeds.Add((xy, MinSeparation)); continue; } if (MinSeparation > 0) { foreach (var offset in DirectionExts.Spread8) { var neighbor = xy + offset; if (!deviations.ContainsXY(neighbor) || Math.Abs(Progress(low, lowProgress[neighbor])) > maxSkip || Math.Abs(Progress(high, highProgress[neighbor])) > maxSkip) { separationSeeds.Add((xy, MinSeparation - 1)); break; } } // Last so that any greater range seeds take priority. if (deviations[xy] > MaxDeviation) separationSeeds.Add((xy, 0)); } } int? SeparationFiller(int2 xy, int range) { if (deviations[xy] == 0 || deviations[xy] == OverDeviation) return null; deviations[xy] = OverDeviation; if (range == 0) return null; return range - 1; } MatrixUtils.FloodFill( size, separationSeeds, SeparationFiller, DirectionExts.Spread8); } var pathStart = points[0]; var pathEnd = points[^1]; var orderedPermittedBrushes = Brushes.All.ToImmutableArray(); var permittedStartBrushes = Brushes.Start.ToHashSet(); var permittedInnerBrushes = Brushes.Inner.ToHashSet(); var permittedEndBrushes = Brushes.End.ToHashSet(); const int MaxCost = int.MaxValue; var segmentTypeToId = new Dictionary(); var segmentsByStart = new List>(); var segmentsByEnd = new List>(); // 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(size).Fill(MaxCost); var innerCosts = new List>(); { void RegisterSegmentType(string type) { if (segmentTypeToId.ContainsKey(type)) return; var newId = segmentTypeToId.Count; segmentTypeToId.Add(type, newId); segmentsByStart.Add([]); segmentsByEnd.Add([]); innerCosts.Add(new Matrix(size).Fill(MaxCost)); } foreach (var multiBrush in orderedPermittedBrushes) { var segment = multiBrush.Segment; RegisterSegmentType(segment.Start); RegisterSegmentType(segment.End); var startTypeId = segmentTypeToId[segment.Start]; var endTypeId = segmentTypeToId[segment.End]; var tilePathSegment = new TilingSegment(multiBrush, startTypeId, endTypeId); var tuple = ( tilePathSegment, permittedStartBrushes.Contains(multiBrush) && segment.Start == start.SegmentType, permittedInnerBrushes.Contains(multiBrush), permittedEndBrushes.Contains(multiBrush) && segment.End == end.SegmentType); segmentsByStart[startTypeId].Add(tuple); segmentsByEnd[endTypeId].Add(tuple); } } var totalTypeIds = segmentTypeToId.Count; var priorities = new PriorityArray(totalTypeIds * size.X * size.Y, MaxCost); void SetPriorityAt(int typeId, CVec pos, int priority) => priorities[typeId * sizeXY + pos.Y * size.X + pos.X] = priority; (int TypeId, CVec Pos, int Priority) GetNextPriority() { var index = priorities.GetMinIndex(); var priority = priorities[index]; var typeId = index / sizeXY; var xy = index % sizeXY; return (typeId, new CVec(xy % size.X, xy / size.X), priority); } if (!segmentTypeToId.TryGetValue(start.SegmentType, out var pathStartTypeId)) return null; if (!segmentTypeToId.TryGetValue(end.SegmentType, out var pathEndTypeId)) return null; // Lower (closer to zero) costs are better matches. // MaxScore means totally unacceptable. int ScoreSegment(TilingSegment segment, CVec from) { var to = from + segment.Moves; 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; var lowProgressionAcc = 0; var highProgressionAcc = 0; var lastPointI = segment.RelativePoints.Length - 1; for (var pointI = 0; pointI <= lastPointI; pointI++) { var point = from + segment.RelativePoints[pointI]; if (!deviations.ContainsXY(point.X, point.Y) || deviations[point.X, point.Y] == OverDeviation) { // Point escapes bounds or is in an excluded position. return MaxCost; } if (pointI < lastPointI) { var pointNext = from + segment.RelativePoints[pointI + 1]; if (!deviations.ContainsXY(pointNext.X, pointNext.Y) || deviations[pointNext.X, pointNext.Y] == OverDeviation) { // Next point escapes bounds or is in an excluded position. return MaxCost; } var lowProgression = Progress(lowProgress[point.X, point.Y], lowProgress[pointNext.X, pointNext.Y]); var highProgression = Progress(highProgress[point.X, point.Y], highProgress[pointNext.X, pointNext.Y]); if (Math.Abs(lowProgression) > maxSkip || Math.Abs(highProgression) > maxSkip) { // Fails skip rule. return MaxCost; } lowProgressionAcc += lowProgression; highProgressionAcc += highProgression; } // pointI > 0 is needed to avoid double-counting the segments's start with the // previous one's end. if (pointI > 0) deviationAcc += deviations[point.X, point.Y]; } if (lowProgressionAcc < 0 || highProgressionAcc < 0) { // Fails progression rule. return MaxCost; } // If it's a zero-progress segment without deviation, only allow it if it directs // towards a positive progression. if (lowProgressionAcc == 0 && highProgressionAcc == 0) { var point = to; var pointNext = to + segment.EndDirection.ToCVec(); if (!deviations.ContainsXY(pointNext.X, pointNext.Y) || deviations[pointNext.X, pointNext.Y] == OverDeviation) { // Projected point escapes bounds or is in an excluded position. return MaxCost; } lowProgressionAcc = Progress(lowProgress[point.X, point.Y], lowProgress[pointNext.X, pointNext.Y]); highProgressionAcc = Progress(highProgress[point.X, point.Y], highProgress[pointNext.X, pointNext.Y]); if ((lowProgressionAcc <= 0 && highProgressionAcc <= 0) || lowProgressionAcc + highProgressionAcc < 0) return MaxCost; } // Satisfies all requirements. return deviationAcc; } void UpdateFrom(CVec from, int fromTypeId, bool isForStart) { 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; // Most likely to fail. Check first. if (deviations[to.X, to.Y] == OverDeviation) { // End escapes bounds. continue; } var segmentCost = ScoreSegment(segment, from); if (segmentCost == MaxCost) continue; var toCost = fromCost + segmentCost; var toTypeId = segment.EndTypeId; if ((canStart || canInner) && toCost < innerCosts[toTypeId][to.X, to.Y]) { 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); } UpdateFrom(pathStart, pathStartTypeId, true); while (true) { var (fromTypeId, from, priority) = GetNextPriority(); if (priority == MaxCost) break; UpdateFrom(from, fromTypeId, false); } // Trace back and update tiles var resultPoints = new List(); var compositeBrush = new MultiBrush(); (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(); 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; // Most likely to fail. Check first. if (deviations[from.X, from.Y] == OverDeviation) { // Start escapes bounds. continue; } var segmentCost = ScoreSegment(segment, from); if (segmentCost == MaxCost) continue; var fromCost = toCost - segmentCost; var requiredFromCost = mustStart ? 0 : innerCosts[segment.StartTypeId][from.X, from.Y]; if (fromCost == requiredFromCost) candidates.Add(segment); } Debug.Assert(candidates.Count >= 1, "TraceBack didn't find an original route"); var weights = candidates .Select(c => c.MultiBrush.Weight) .ToArray(); var chosenSegment = candidates[random.PickWeighted(weights)]; var chosenFrom = to - chosenSegment.Moves; compositeBrush.MergeFrom( chosenSegment.MultiBrush, chosenFrom - chosenSegment.Offset + minPoint - CPos.Zero, Map.Grid.Type); // Skip end point as it is recorded in the previous segment. for (var i = chosenSegment.RelativePoints.Length - 2; i >= 0; i--) { var point = chosenFrom + chosenSegment.RelativePoints[i] + minPoint - CPos.Zero; resultPoints.Add(point); } return (chosenFrom, chosenSegment.StartTypeId); } { var toTypeId = pathEndTypeId; 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(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, DirectionExts.Spread4); var bestDistance = fallbackDistances.Data.Min(); if (bestDistance == Unreached || bestDistance == Unsolved) return null; // Find the lowest cost candidate end point. var fallbackCosts = new Matrix(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, true); // No need to check direction. If that is an issue, I have bigger problems to worry about. while (to != pathStart || toTypeId != pathStartTypeId) (to, toTypeId) = TraceBackStep(to, toTypeId, false); } // Traced back in reverse, so reverse the reversal. resultPoints.Reverse(); var compositeSegment = new MultiBrushSegment( start.SegmentType, "(Tiled Path)", end.SegmentType, [.. resultPoints]); compositeBrush.ReplaceSegment(compositeSegment); return compositeBrush; } /// /// /// Extend the start and end of a path by extensionLength points. The directions of the /// extensions are based on the overall direction of the outermost inertialRange points. /// /// /// Returns the object being called on. /// /// public TilingPath InertiallyExtend(int extensionLength, int inertialRange) { Points = InertiallyExtendPathPoints(Points, extensionLength, inertialRange); return this; } /// /// Extend the start and end of a path by extensionLength points. The directions of the /// extensions are based on the overall direction of the outermost inertialRange points. /// Loops are left unmodified. /// public static CPos[] InertiallyExtendPathPoints(CPos[] points, int extensionLength, int inertialRange) { if (points == null) return null; if (points[0] == points[^1]) { // Is a loop. return points; } if (inertialRange > points.Length - 1) inertialRange = points.Length - 1; var sd = DirectionExts.FromCVecNonDiagonal(points[inertialRange] - points[0]); var ed = DirectionExts.FromCVecNonDiagonal(points[^1] - points[^(inertialRange + 1)]); var newPoints = new CPos[points.Length + extensionLength * 2]; for (var i = 0; i < extensionLength; i++) newPoints[i] = points[0] - sd.ToCVec() * (extensionLength - i); Array.Copy(points, 0, newPoints, extensionLength, points.Length); for (var i = 0; i < extensionLength; i++) newPoints[extensionLength + points.Length + i] = points[^1] + ed.ToCVec() * (i + 1); return newPoints; } /// /// /// For map edge-connected (non-loop) starts/ends, the path is extended beyond the edge. /// For loops or paths which don't connect to the map edge, no change is applied. /// /// /// For the purposes of this function, the map edges are defined as the borders of a /// minimal CPos-aligned rectangle covering the entire map. These are not the true edges of /// a RectangularIsometric map. /// /// /// Starts/ends which are corner-connected or already extend beyond the edge are unaltered. /// /// /// Returns the object being called on. /// /// public TilingPath ExtendEdge(int extensionLength) { Points = ExtendEdgePathPoints(Points, CellLayerUtils.CellBounds(Map), extensionLength); return this; } /// /// /// For bounds edge-connected (non-loop) starts/ends, the path is extended beyond the edge. /// For loops or paths which don't connect to the edges, the input points are returned /// unaltered. /// /// /// Starts/ends which are corner-connected or already extend beyond the edge are unaltered. /// /// public static CPos[] ExtendEdgePathPoints(CPos[] points, Rectangle bounds, int extensionLength) { if (points == null) return null; if (points[0] == points[^1]) { // Is a loop. return points; } var left = bounds.Left; var top = bounds.Top; var right = bounds.Right; var bottom = bounds.Bottom; CPos[] Extend(CPos point) { var ox = (point.X == left) ? -1 : (point.X == right) ? 1 : 0; var oy = (point.Y == top) ? -1 : (point.Y == bottom) ? 1 : 0; if (ox == oy) { // We're either not on an edge or we're at a corner, so don't extend. return []; } var offset = new CVec(ox, oy); var extension = new CPos[extensionLength]; var newPoint = point; for (var i = 0; i < extensionLength; i++) { newPoint += offset; extension[i] = newPoint; } return extension; } // Open paths. Extend if beyond edges. var startExt = Extend(points[0]).Reverse().ToArray(); var endExt = Extend(points[^1]); // [...startExt, ...points, ...endExt]; var tweaked = new CPos[points.Length + startExt.Length + endExt.Length]; Array.Copy(startExt, 0, tweaked, 0, startExt.Length); Array.Copy(points, 0, tweaked, startExt.Length, points.Length); Array.Copy(endExt, 0, tweaked, points.Length + startExt.Length, endExt.Length); return tweaked; } /// /// /// For loops, points are rotated such that the start/end reside in the longest straight. /// For non-loops, the input points are returned unaltered. /// /// /// Returns the object being called on. /// /// public TilingPath OptimizeLoop() { Points = OptimizeLoopPathPoints(Points); return this; } /// /// For loops, points are rotated such that the start/end reside in the longest straight. /// For non-loops, the input points are returned unaltered. /// public static CPos[] OptimizeLoopPathPoints(CPos[] points) { if (points == null) return null; if (points[0] == points[^1]) { // Closed loop. Find the longest straight // (nrlen excludes the repeated point at the end.) var nrlen = points.Length - 1; var prevDim = -1; var scanStart = -1; var bestScore = -1; var bestBend = -1; var prevBend = -1; var prevI = 0; for (var i = 1; ; i++) { if (i == nrlen) i = 0; var dim = points[i].X == points[prevI].X ? 1 : 0; if (prevDim != -1 && prevDim != dim) { if (scanStart == -1) { // This is technically just after the bend. But that's fine. scanStart = i; } else { var score = prevI - prevBend; if (score < 0) score += nrlen; if (score > bestScore) { bestBend = prevBend; bestScore = score; } if (i == scanStart) break; } prevBend = prevI; } prevDim = dim; prevI = i; } var favouritePoint = (bestBend + (bestScore >> 1)) % nrlen; // Repeat the start at the end. // [...points.slice(favouritePoint, nrlen), ...points.slice(0, favouritePoint + 1)]; var tweaked = new CPos[points.Length]; Array.Copy(points, favouritePoint, tweaked, 0, nrlen - favouritePoint); Array.Copy(points, 0, tweaked, nrlen - favouritePoint, favouritePoint + 1); return tweaked; } else { return points; } } /// /// /// Shrink a path by a given amount at both ends. If the number of points in the path drops /// below minimumLength, the path is nullified. /// /// /// If a loop is provided, the path is not shrunk, but the minimumLength requirement still /// holds. /// /// /// Returns the object being called on. /// /// public TilingPath Shrink(int shrinkBy, int minimumLength) { Points = ShrinkPathPoints(Points, shrinkBy, minimumLength); return this; } /// /// /// Shrink a path by a given amount at both ends. If the number of points in the path drops /// below minimumLength, null is returned. /// /// /// If a loop is provided, the path is not shrunk, but the minimumLength requirement still /// holds. /// /// public static CPos[] ShrinkPathPoints(CPos[] points, int shrinkBy, int minimumLength) { if (points == null) return null; if (minimumLength <= 1) throw new ArgumentException("minimumLength must be greater than 1"); if (points[0] == points[^1]) { // Loop. if (points.Length < minimumLength) return null; return points[0..^0]; } if (points.Length < shrinkBy * 2 + minimumLength) return null; return points[shrinkBy..(points.Length - shrinkBy)]; } /// /// /// Takes a path and normalizes its progression direction around the map center. /// Normalized but opposing paths rotate around the center in the same direction. /// /// /// The measureFromCenter function must convert CVec positions to WVec offsets from the map /// center. /// /// public TilingPath ChirallyNormalize(Func measureFromCenter) { Points = ChirallyNormalizePathPoints(Points, measureFromCenter); return this; } /// /// /// Takes a path and normalizes its progression direction around the map center. /// Normalized but opposing paths rotate around the center in the same direction. /// /// /// Loops are normalized to rotate in a consistent direction, regardless of position. /// /// /// The measureFromCenter function must convert CVec positions to WVec offsets from the map /// center. /// /// public static CPos[] ChirallyNormalizePathPoints(CPos[] points, Func measureFromCenter) { if (points == null || points.Length < 2) return points; var normalized = (CPos[])points.Clone(); var start = points[0]; var end = points[^1]; if (start == end) { // Is a loop. // Find the top-left-most corner point (on the convex hull) and // sample which way the points are bending. var topLeftIndex = 0; var topLeftPoint = points[0]; for (var i = 1; i < points.Length; i++) { var point = points[i]; if (point.Y < topLeftPoint.Y || (point.Y == topLeftPoint.Y && point.X < topLeftPoint.X)) { topLeftIndex = i; topLeftPoint = point; } } var inOffset = points[topLeftIndex] - points[(topLeftIndex + points.Length - 1) % points.Length]; var outOffset = points[(topLeftIndex + points.Length + 1) % points.Length] - points[topLeftIndex]; var crossProd = inOffset.X * outOffset.Y - inOffset.Y * outOffset.X; // crossProd should never be 0 for a valid input. if (crossProd < 0) Array.Reverse(normalized); } else { // Is not a loop. bool ShouldReverse(CPos start, CPos end) { var v1 = measureFromCenter(start); var v2 = measureFromCenter(end); // Rotation around center? var crossProd = v1.X * v2.Y - v2.X * v1.Y; if (crossProd != 0) return crossProd < 0; // Distance from center? var r1 = v1.X * v1.X + v1.Y * v1.Y; var r2 = v2.X * v2.X + v2.Y * v2.Y; if (r1 != r2) return r1 < r2; // Absolute angle return v1.Y == v2.Y ? v1.X > v2.X : v1.Y > v2.Y; } if (ShouldReverse(start, end)) Array.Reverse(normalized); } return normalized; } /// /// /// Retains paths which have no points in common with earlier (previous and retained) paths /// from the input. /// /// /// The underlying point sequences are NOT cloned. /// /// /// All input sequences must be non-null. /// /// public static CPos[][] RetainDisjointPaths(IEnumerable inputs) { var outputs = new List(); var lookup = new HashSet(); foreach (var points in inputs) { var retain = true; foreach (var point in points) { if (lookup.Contains(point)) { retain = false; break; } } if (retain) { outputs.Add(points); foreach (var point in points) lookup.Add(point); } } return outputs.ToArray(); } /// Nullify the path's points if they aren't suitable for tiling. public TilingPath RetainIfValid() { if (!ValidatePathPoints(Points)) Points = null; return this; } public static bool ValidatePathPoints(CPos[] points) { if (points == null || points.Length == 0) return false; var isLoop = points[0] == points[^1]; if (points.Length < (isLoop ? 3 : 2)) return false; // Duplicate points check if (points.Distinct().Count() != points.Length - (isLoop ? 1 : 0)) return false; // All steps must be (non-diagonal) unit offsets. var lastPoint = points[0]; for (var i = 1; i < points.Length; i++) { var offset = lastPoint - points[i]; if (DirectionExts.FromCVecNonDiagonal(offset).ToCVec() != offset) return false; lastPoint = points[i]; } return true; } /// Applies StraightenEndsPathPoints to this TilingPath, returning this. public TilingPath StraightenEnds( int shrink, int grow, int minimumLength, int growthInertialRange) { Points = StraightenEndsPathPoints( Points, CellLayerUtils.CellBounds(Map), shrink, grow, minimumLength, growthInertialRange); return this; } /// /// Straighten the start and end of a path by shrinking and regrowing a straight section. /// /// Points of the path. /// Map bounds, used to identify paths touching edges. /// Distance to shrink path ends (before regrowing them). /// Distance to regrow path ends with straightening (after shrinking). /// The minimum length (after shrinking, before growth) that paths may be. /// How many points are used to decide the regrowth direction. public static CPos[] StraightenEndsPathPoints( CPos[] points, Rectangle bounds, int shrink, int grow, int minimumLength, int growthInertialRange) { points = ExtendEdgePathPoints(points, bounds, 2 * shrink + minimumLength); points = ShrinkPathPoints(points, shrink, minimumLength); points = InertiallyExtendPathPoints(points, grow, growthInertialRange); return points; } /// Set MaxEndDeviation. public TilingPath SetMaxEndDeviation(int maxEndDeviation) { MaxEndDeviation = maxEndDeviation; return this; } /// Allow end point deviation as far as MaxDeviation will allow. public TilingPath SetAutoEndDeviation() { MaxEndDeviation = int.MaxValue; return this; } } }