matrix)
+ {
+ Console.Error.WriteLine($"{label}: {matrix.Size.X} by {matrix.Size.Y}, {matrix.Data.Min()} to {matrix.Data.Max()}");
+ for (var y = 0; y < matrix.Size.Y; y++)
+ {
+ for (var x = 0; x < matrix.Size.X; x++)
+ {
+ var v = matrix[x, y];
+ string formatted;
+ if (v > 0 && v < 0x80)
+ formatted = string.Format(NumberFormatInfo.InvariantInfo, "\u001b[1;42m{0:X2}\u001b[m ", v);
+ else if (v >= 0x80)
+ formatted = string.Format(NumberFormatInfo.InvariantInfo, "\u001b[1;41m{0:X2}\u001b[m ", v);
+ else
+ formatted = "\u001b[m 0 ";
+ Console.Error.Write(formatted);
+ }
+
+ Console.Error.Write("\n");
+ }
+
+ Console.Error.WriteLine("");
+ Console.Error.Flush();
+ }
+
+ ///
+ ///
+ /// Perform a generic flood fill starting at seeds [(xy, prop), ...].
+ ///
+ ///
+ /// For each point being considered for fill, filler(xy, prop) is
+ /// called with the current position (xy) and propagation value (prop).
+ /// filler should return the value to be propagated or null if not to be
+ /// propagated. Propagation happens to all neighbours (offsets) defined
+ /// by spread, regardless of whether they have previously been visited,
+ /// so filler is responsible for terminating propagation by returning
+ /// nulls. Usually, Direction.SPREAD4 or Direction.SPREAD8
+ /// is appropriate as a spread pattern.
+ ///
+ ///
+ /// filler should capture and manipulate any necessary input and output
+ /// arrays.
+ ///
+ ///
+ /// Each call to filler will have either an equal or greater
+ /// growth/propagation distance from their seed value than all calls
+ /// before it. (You can think of this as them being called in ordered
+ /// growth layers.)
+ ///
+ ///
+ /// Note that filler may be called multiple times for the same spot,
+ /// perhaps with different propagation values. Within the same
+ /// growth/propagation distance, filler will be called from values
+ /// propagated from earlier seeds before values propagated from later
+ /// seeds.
+ ///
+ ///
+ /// filler is not called for positions outside of the bounds defined by
+ /// size EXCEPT for points being processed as seed values.
+ ///
+ ///
+ public static void FloodFill(
+ int2 size,
+ IEnumerable<(int2 XY, P Prop)> seeds,
+ Func filler,
+ ImmutableArray spread) where P : struct
+ {
+ var next = seeds.ToList();
+ while (next.Count != 0)
+ {
+ var current = next;
+ next = new List<(int2, P)>();
+ foreach (var (source, prop) in current)
+ {
+ var newProp = filler(source, prop);
+ if (newProp != null)
+ foreach (var offset in spread)
+ {
+ var destination = source + offset;
+ if (destination.X >= 0 && destination.X < size.X && destination.Y >= 0 && destination.Y < size.Y)
+ next.Add((destination, (P)newProp));
+ }
+ }
+ }
+ }
+
+ ///
+ ///
+ /// Compute the in-game walking distances from a set of seeds.
+ ///
+ ///
+ /// The output matrix cells will contain either the distance (if reachable) or
+ /// PositiveInfinity.
+ ///
+ ///
+ public static Matrix WalkingDistances(Matrix passable, IEnumerable seeds, float maxDistance)
+ {
+ const float SQRT2 = 1.4142135623730951f;
+
+ if (maxDistance == float.PositiveInfinity)
+ maxDistance = float.MaxValue;
+
+ var output = new Matrix(passable.Size).Fill(float.PositiveInfinity);
+ var unprocessed = new PriorityArray(passable.Size.X * passable.Size.Y, float.PositiveInfinity);
+ foreach (var seed in seeds)
+ unprocessed[passable.Index(seed)] = 0;
+
+ while (true)
+ {
+ var i = unprocessed.GetMinIndex();
+ var distance = unprocessed[i];
+ var xy = passable.XY(i);
+
+ if (distance > maxDistance)
+ break;
+
+ if (distance <= maxDistance && output.ContainsXY(xy))
+ output[xy] = distance;
+ unprocessed[i] = float.PositiveInfinity;
+
+ foreach (var (offset, direction) in Direction.Spread8D)
+ {
+ var nextXY = xy + offset;
+ if (!passable.ContainsXY(nextXY))
+ continue;
+ if (!passable[nextXY])
+ continue;
+ if (output[nextXY] != float.PositiveInfinity)
+ continue;
+ float nextDistance;
+ if (Direction.IsDiagonal(direction))
+ nextDistance = distance + SQRT2;
+ else
+ nextDistance = distance + 1;
+
+ var nextI = passable.Index(nextXY);
+ if (nextDistance < unprocessed[nextI])
+ unprocessed[nextI] = nextDistance;
+ }
+ }
+
+ return output;
+ }
+
+ ///
+ ///
+ /// Shrinkwraps true space to be as far away from false space as possible, preserving
+ /// topology. The result is a kind of rough Voronoi diagram.
+ ///
+ ///
+ /// If the space matrix has width (w, h), the returned matrix will have width (w + 1, h + 1).
+ /// Each value in the returned matrix is a Direction bitmask describing the border structure
+ /// between the cells of the original space matrix.
+ ///
+ /// outsideSpace specified the space values for cells which are outside the space matrix.
+ ///
+ ///
+ ///
+ public static Matrix DeflateSpace(Matrix space, bool outsideSpace)
+ {
+ var size = space.Size;
+ var holes = new Matrix(size);
+ var holeCount = 0;
+ for (var y = 0; y < space.Size.Y; y++)
+ for (var x = 0; x < space.Size.X; x++)
+ if (!space[x, y] && holes[x, y] == 0)
+ {
+ holeCount++;
+ int? Filler(int2 xy, int holeId)
+ {
+ if (!space[xy] && holes[xy] == 0)
+ {
+ holes[xy] = holeId;
+ return holeId;
+ }
+ else
+ {
+ return null;
+ }
+ }
+
+ FloodFill(space.Size, new[] { (new int2(x, y), holeCount) }, Filler, Direction.Spread4);
+ }
+
+ const int UNASSIGNED = int.MaxValue;
+ var voronoi = new Matrix(size);
+ var distances = new Matrix(size).Fill(UNASSIGNED);
+ var closestN = new Matrix(size).Fill(UNASSIGNED);
+ var midN = (size.X * size.Y + 1) / 2;
+ var seeds = new List<(int2, (int, int2, int))>();
+ for (var y = 0; y < size.Y; y++)
+ for (var x = 0; x < size.X; x++)
+ {
+ var xy = new int2(x, y);
+ if (holes[xy] != 0)
+ seeds.Add((xy, (holes[xy], xy, closestN.Index(x, y))));
+ }
+
+ if (!outsideSpace)
+ {
+ holeCount++;
+ for (var x = 0; x < size.X; x++)
+ {
+ // Hack: closestN is actually inside, but starting x, y are outside.
+ seeds.Add((new int2(x, 0), (holeCount, new int2(x, -1), closestN.Index(x, 0))));
+ seeds.Add((new int2(x, size.Y - 1), (holeCount, new int2(x, size.Y), closestN.Index(x, size.Y - 1))));
+ }
+
+ for (var y = 0; y < size.Y; y++)
+ {
+ // Hack: closestN is actually inside, but starting x, y are outside.
+ seeds.Add((new int2(0, y), (holeCount, new int2(-1, y), closestN.Index(0, y))));
+ seeds.Add((new int2(size.X - 1, y), (holeCount, new int2(size.X, y), closestN.Index(size.X - 1, y))));
+ }
+ }
+
+ {
+ (int HoleId, int2 StartXY, int StartN)? Filler(int2 xy, (int HoleId, int2 StartXY, int StartN) prop)
+ {
+ var n = closestN.Index(xy);
+ var distance = (xy - prop.StartXY).LengthSquared;
+ if (distance < distances[n])
+ {
+ voronoi[n] = prop.HoleId;
+ distances[n] = distance;
+ closestN[n] = prop.StartN;
+ return (prop.HoleId, prop.StartXY, prop.StartN);
+ }
+ else if (distance == distances[n])
+ {
+ if (closestN[n] == prop.StartN)
+ {
+ return null;
+ }
+ else if (n <= midN == prop.StartN < closestN[n])
+ {
+ // For the first half of the map, lower seed indexes are preferred.
+ // For the second half of the map, higher seed indexes are preferred.
+ voronoi[n] = prop.HoleId;
+ closestN[n] = prop.StartN;
+ return (prop.HoleId, prop.StartXY, prop.StartN);
+ }
+ else
+ {
+ return null;
+ }
+ }
+ else
+ {
+ return null;
+ }
+ }
+
+ FloodFill(size, seeds, Filler, Direction.Spread4);
+ }
+
+ var deflatedSize = size + new int2(1, 1);
+ var deflated = new Matrix(deflatedSize);
+ var neighborhood = new int[4];
+ var scan = new int2[]
+ {
+ new(-1, -1),
+ new(0, -1),
+ new(-1, 0),
+ new(0, 0)
+ };
+ for (var cy = 0; cy < deflatedSize.Y; cy++)
+ for (var cx = 0; cx < deflatedSize.X; cx++)
+ {
+ for (var neighbor = 0; neighbor < 4; neighbor++)
+ {
+ var x = Math.Clamp(cx + scan[neighbor].X, 0, size.X - 1);
+ var y = Math.Clamp(cy + scan[neighbor].Y, 0, size.Y - 1);
+ neighborhood[neighbor] = voronoi[x, y];
+ }
+
+ deflated[cx, cy] = (byte)(
+ (neighborhood[0] != neighborhood[1] ? Direction.MU : 0) |
+ (neighborhood[1] != neighborhood[3] ? Direction.MR : 0) |
+ (neighborhood[3] != neighborhood[2] ? Direction.MD : 0) |
+ (neighborhood[2] != neighborhood[0] ? Direction.ML : 0));
+ }
+
+ return deflated;
+ }
+
+ ///
+ /// Convolute a kernel over a boolean input matrix.
+ /// If dilating, the values specified by the kernel are logically OR-ed.
+ /// If eroding, the values specified by the kernel are logically AND-ed.
+ ///
+ public static Matrix KernelDilateOrErode(Matrix input, Matrix kernel, int2 kernelCenter, bool dilate)
+ {
+ var output = new Matrix(input.Size).Fill(!dilate);
+ for (var cy = 0; cy < input.Size.Y; cy++)
+ for (var cx = 0; cx < input.Size.X; cx++)
+ {
+ void InnerLoop()
+ {
+ for (var ky = 0; ky < kernel.Size.Y; ky++)
+ for (var kx = 0; kx < kernel.Size.X; kx++)
+ {
+ var x = cx + kx - kernelCenter.X;
+ var y = cy + ky - kernelCenter.Y;
+ if (!input.ContainsXY(x, y))
+ continue;
+ if (kernel[kx, ky] && input[x, y] == dilate)
+ {
+ output[cx, cy] = dilate;
+ return;
+ }
+ }
+ }
+
+ InnerLoop();
+ }
+
+ return output;
+ }
+
+ ///
+ ///
+ /// Create a one-dimensional gaussian kernel.
+ ///
+ ///
+ /// This can be applied once, transposed, then applied again to perform a full gaussian blur.
+ /// See .
+ ///
+ ///
+ public static Matrix GaussianKernel1D(int radius, float standardDeviation)
+ {
+ var span = radius * 2 + 1;
+ var kernel = new Matrix(new int2(span, 1));
+ var dsd2 = 2 * standardDeviation * standardDeviation;
+ var total = 0.0f;
+ for (var x = -radius; x <= radius; x++)
+ {
+ var value = MathF.Exp(-x * x / dsd2);
+ kernel[x + radius] = value;
+ total += value;
+ }
+
+ // Instead of dividing by sqrt(PI * dsd2), divide by the total.
+ for (var i = 0; i < span; i++)
+ kernel[i] /= total;
+
+ return kernel;
+ }
+
+ ///
+ /// Apply an arithmetic convolution of a kernel over an input matrix.
+ ///
+ public static Matrix KernelBlur(Matrix input, Matrix kernel, int2 kernelCenter)
+ {
+ var output = new Matrix(input.Size);
+ for (var cy = 0; cy < input.Size.Y; cy++)
+ for (var cx = 0; cx < input.Size.X; cx++)
+ {
+ var total = 0.0f;
+ var samples = 0;
+ for (var ky = 0; ky < kernel.Size.Y; ky++)
+ for (var kx = 0; kx < kernel.Size.X; kx++)
+ {
+ var x = cx + kx - kernelCenter.X;
+ var y = cy + ky - kernelCenter.Y;
+ if (!input.ContainsXY(x, y))
+ continue;
+ total += input[x, y] * kernel[kx, ky];
+ samples++;
+ }
+
+ output[cx, cy] = total / samples;
+ }
+
+ return output;
+ }
+
+ ///
+ /// Apply a square gaussian blur to a matrix, returning a new matrix.
+ ///
+ public static Matrix GaussianBlur(Matrix input, int radius, float standardDeviation)
+ {
+ var kernel = GaussianKernel1D(radius, standardDeviation);
+ var stage1 = KernelBlur(input, kernel, new int2(radius, 0));
+ var stage2 = KernelBlur(stage1, kernel.Transpose(), new int2(0, radius));
+ return stage2;
+ }
+
+ ///
+ /// Finds the local variance of points in a grid (using a square sample area).
+ /// Sample areas are centered on data point corners, so output is (size + 1) * (size + 1).
+ ///
+ public static Matrix GridVariance(Matrix input, int radius)
+ {
+ var output = new Matrix(input.Size + new int2(1, 1));
+ for (var cy = 0; cy < output.Size.Y; cy++)
+ for (var cx = 0; cx < output.Size.X; cx++)
+ {
+ var total = 0.0f;
+ var samples = 0;
+ for (var ry = -radius; ry < radius; ry++)
+ for (var rx = -radius; rx < radius; rx++)
+ {
+ var y = cy + ry;
+ var x = cx + rx;
+ if (!input.ContainsXY(x, y))
+ continue;
+ total += input[x, y];
+ samples++;
+ }
+
+ var mean = total / samples;
+ var sumOfSquares = 0.0f;
+ for (var ry = -radius; ry < radius; ry++)
+ for (var rx = -radius; rx < radius; rx++)
+ {
+ var y = cy + ry;
+ var x = cx + rx;
+ if (!input.ContainsXY(x, y))
+ continue;
+ sumOfSquares += MathF.Pow(mean - input[x, y], 2);
+ }
+
+ output[cx, cy] = sumOfSquares / samples;
+ }
+
+ return output;
+ }
+
+ ///
+ ///
+ /// Blur a boolean matrix using a square kernel, only changing the value
+ /// if the neighborhood is significantly different based on a threshold.
+ ///
+ ///
+ /// For example, a threshold of 0.75 means any change requires a 75%
+ /// majority within the kernel.
+ ///
+ ///
+ /// The space outside of the matrix is treated as if the border was
+ /// extended out.
+ ///
+ ///
+ /// Along with the blured matrix, the number of changes compared to the
+ /// original is returned.
+ ///
+ ///
+ /// Runtime complexity is approximately O(input.Size) for small radii.
+ /// A more precise complexity would be
+ /// O((input.Size.X + radius) * input.Size.Y +
+ /// input.Size.X * (input.Size.Y + radius)).
+ ///
+ ///
+ public static (Matrix Output, int Changes) BooleanBlur(
+ Matrix input, int radius, float threshold)
+ {
+ if (threshold < 0.5f || threshold > 1.0f)
+ throw new ArgumentException("threshold must between 0.5 and 1.0 inclusive");
+
+ var output = new Matrix(input.Size);
+ var changes = 0;
+
+ // Sum radius-by-1 kernels first in O((size.X + radius) * size.Y) time using a diffing sliding
+ // window, then sum 1-by-radius kernels in O(size.X * (size.Y + radius)) time.
+ var hTrueCounts = new Matrix(input.Size);
+ var kernelArea = (2 * radius + 1) * (2 * radius + 1);
+ var trueThreshold = (int)MathF.Ceiling(kernelArea * threshold);
+ var falseThreshold = kernelArea - trueThreshold;
+
+ for (var cy = 0; cy < input.Size.Y; cy++)
+ {
+ var trueCount = 0;
+ for (var ox = -radius; ox <= radius; ox++)
+ if (input[input.ClampXY(new int2(ox, cy))])
+ trueCount++;
+ hTrueCounts[0, cy] = trueCount;
+
+ for (var cx = 1; cx < input.Size.X; cx++)
+ {
+ if (input[input.ClampXY(new int2(cx - radius - 1, cy))])
+ trueCount--;
+ if (input[input.ClampXY(new int2(cx + radius, cy))])
+ trueCount++;
+
+ hTrueCounts[cx, cy] = trueCount;
+ }
+ }
+
+ void OutputForXY(int x, int y, int trueCount)
+ {
+ var thisInput = input[x, y];
+ bool thisOutput;
+ if (trueCount <= falseThreshold)
+ thisOutput = false;
+ else if (trueCount >= trueThreshold)
+ thisOutput = true;
+ else
+ thisOutput = thisInput;
+ output[x, y] = thisOutput;
+ if (thisOutput != thisInput)
+ changes++;
+ }
+
+ for (var cx = 0; cx < input.Size.X; cx++)
+ {
+ var trueCount = 0;
+ for (var oy = -radius; oy <= radius; oy++)
+ trueCount += hTrueCounts[hTrueCounts.ClampXY(new int2(cx, oy))];
+ OutputForXY(cx, 0, trueCount);
+
+ for (var cy = 1; cy < input.Size.Y; cy++)
+ {
+ trueCount -= hTrueCounts[hTrueCounts.ClampXY(new int2(cx, cy - radius - 1))];
+ trueCount += hTrueCounts[hTrueCounts.ClampXY(new int2(cx, cy + radius))];
+
+ OutputForXY(cx, cy, trueCount);
+ }
+ }
+
+ return (output, changes);
+ }
+
+ ///
+ /// Preserves foreground cells that can be safely covered by a (possibly
+ /// out-of-bound) span-by-span square that doesn't touch any !foreground
+ /// cells, and sets any remaining cells to !foreground.
+ ///
+ public static (Matrix Output, int Changes) RetainThickRegions(
+ Matrix input, bool foreground, int span)
+ {
+ // The time complexity could be improved to O(input.Size) by using
+ // a technique similar to BooleanBlur, but, in practice, this
+ // hasn't needed optimizing yet.
+ var output = new Matrix(input.Size).Fill(!foreground);
+ for (var cy = 1 - span; cy < input.Size.Y; cy++)
+ for (var cx = 1 - span; cx < input.Size.X; cx++)
+ {
+ bool IsRetained()
+ {
+ for (var ry = 0; ry < span; ry++)
+ for (var rx = 0; rx < span; rx++)
+ {
+ var x = cx + rx;
+ var y = cy + ry;
+ if (!input.ContainsXY(x, y))
+ continue;
+ if (input[x, y] != foreground)
+ return false;
+ }
+
+ return true;
+ }
+
+ if (!IsRetained()) continue;
+
+ for (var ry = 0; ry < span; ry++)
+ for (var rx = 0; rx < span; rx++)
+ {
+ var x = cx + rx;
+ var y = cy + ry;
+ if (!input.ContainsXY(x, y))
+ continue;
+ output[x, y] = foreground;
+ }
+ }
+
+ var changes = 0;
+ for (var i = 0; i < input.Data.Length; i++)
+ if (input[i] != output[i])
+ changes++;
+
+ return (output, changes);
+ }
+
+ /// Read a linearly interpolated value between the cells of a matrix.
+ public static float Interpolate(Matrix matrix, float x, float y)
+ {
+ var xa = (int)MathF.Floor(x);
+ var xb = (int)MathF.Ceiling(x);
+ var ya = (int)MathF.Floor(y);
+ var yb = (int)MathF.Ceiling(y);
+
+ // "w" for "weight"
+ var xbw = x - xa;
+ var ybw = y - ya;
+ var xaw = 1.0f - xbw;
+ var yaw = 1.0f - ybw;
+
+ if (xa < 0)
+ {
+ xa = 0;
+ xb = 0;
+ }
+ else if (xb > matrix.Size.X - 1)
+ {
+ xa = matrix.Size.X - 1;
+ xb = matrix.Size.X - 1;
+ }
+
+ if (ya < 0)
+ {
+ ya = 0;
+ yb = 0;
+ }
+ else if (yb > matrix.Size.Y - 1)
+ {
+ ya = matrix.Size.Y - 1;
+ yb = matrix.Size.Y - 1;
+ }
+
+ var naa = matrix[xa, ya];
+ var nba = matrix[xb, ya];
+ var nab = matrix[xa, yb];
+ var nbb = matrix[xb, yb];
+ return (naa * xaw + nba * xbw) * yaw + (nab * xaw + nbb * xbw) * ybw;
+ }
+
+ ///
+ /// Finds the (linearly interpolated) value a given fraction through a sorted array.
+ ///
+ public static float ArrayQuantile(float[] array, float quantile)
+ {
+ if (array.Length == 0)
+ throw new ArgumentException("Cannot get quantile of empty array");
+
+ var iFloat = quantile * (array.Length - 1);
+ if (iFloat < 0)
+ iFloat = 0;
+
+ if (iFloat > array.Length - 1)
+ iFloat = array.Length - 1;
+
+ var iLow = (int)iFloat;
+ if (iLow == iFloat)
+ return array[iLow];
+
+ var iHigh = iLow + 1;
+ var weight = iFloat - iLow;
+ return array[iLow] * (1 - weight) + array[iHigh] * weight;
+ }
+
+ ///
+ /// Uniformally add to or subtract from all matrix cells such that the given quantile,
+ /// fraction, has the given target value.
+ ///
+ public static void CalibrateQuantileInPlace(Matrix matrix, float target, float fraction)
+ {
+ var sorted = (float[])matrix.Data.Clone();
+ Array.Sort(sorted);
+ var adjustment = target - ArrayQuantile(sorted, fraction);
+ for (var i = 0; i < matrix.Data.Length; i++)
+ matrix[i] += adjustment;
+ }
+
+ ///
+ /// For true cells, gives the Chebyshev distance to the closest false cell.
+ /// For false cells, gives the Chebyshev distance to the closest true cell as a negative.
+ /// outsideValue specifies whether cells outside of the matrix are true or false.
+ ///
+ public static Matrix ChebyshevRoom(Matrix input, bool outsideValue)
+ {
+ var roominess = new Matrix(input.Size);
+
+ var seeds = new List<(int2, int)>();
+
+ // Find true/false boundaries and map boundary
+ for (var cy = 0; cy < input.Size.Y; cy++)
+ for (var cx = 0; cx < input.Size.X; cx++)
+ {
+ var pCount = 0;
+ var nCount = 0;
+ for (var oy = -1; oy <= 1; oy++)
+ for (var ox = -1; ox <= 1; ox++)
+ {
+ var x = cx + ox;
+ var y = cy + oy;
+ if (input.ContainsXY(x, y) ? input[x, y] : outsideValue)
+ pCount++;
+ else
+ nCount++;
+ }
+
+ if (pCount != 9 && nCount != 9)
+ seeds.Add((new int2(cx, cy), 1));
+ }
+
+ if (seeds.Count == 0)
+ {
+ // There were no shores. Use minSpan or -minSpan as appropriate.
+ var minSpan = Math.Min(input.Size.X, input.Size.Y);
+ roominess.Fill(input[0] ? minSpan : -minSpan);
+ return roominess;
+ }
+
+ int? Filler(int2 xy, int room)
+ {
+ if (!roominess.ContainsXY(xy) || roominess[xy] != 0)
+ return null;
+ roominess[xy] = input[xy] ? room : -room;
+ return room + 1;
+ }
+
+ FloodFill(
+ roominess.Size,
+ seeds,
+ Filler,
+ Direction.Spread8);
+
+ return roominess;
+ }
+
+ ///
+ ///
+ /// Given a set of grid-intersection point arrays, creates a matrix where each cell
+ /// identifies whether the closest points are wrapping around it clockwise or
+ /// counter-clockwise (as defined in MapUtils.Direction).
+ ///
+ ///
+ /// Positive output values indicate the points are wrapping around it clockwise.
+ /// Negative output values indicate the points are wrapping around it counter-clockwise.
+ /// Outputs can be zero or non-unit magnitude if there are fighting point arrays.
+ ///
+ ///
+ public static Matrix PointsChirality(int2 size, IEnumerable pointArrayArray)
+ {
+ const int FirstPassSentinel = int.MinValue;
+
+ var chirality = new Matrix(size);
+ var seeds = new List<(int2, int)>();
+
+ void SeedChirality(int2 point, int value)
+ {
+ if (!chirality.ContainsXY(point))
+ return;
+ chirality[point] += value;
+ seeds.Add((point, FirstPassSentinel));
+ }
+
+ foreach (var pointArray in pointArrayArray)
+ for (var i = 1; i < pointArray.Length; i++)
+ {
+ var from = pointArray[i - 1];
+ var to = pointArray[i];
+ var direction = Direction.FromInt2(to - from);
+ var fx = from.X;
+ var fy = from.Y;
+ switch (direction)
+ {
+ case Direction.R:
+ SeedChirality(new int2(fx, fy), 1);
+ SeedChirality(new int2(fx, fy - 1), -1);
+ break;
+ case Direction.D:
+ SeedChirality(new int2(fx - 1, fy), 1);
+ SeedChirality(new int2(fx, fy), -1);
+ break;
+ case Direction.L:
+ SeedChirality(new int2(fx - 1, fy - 1), 1);
+ SeedChirality(new int2(fx - 1, fy), -1);
+ break;
+ case Direction.U:
+ SeedChirality(new int2(fx, fy - 1), 1);
+ SeedChirality(new int2(fx - 1, fy - 1), -1);
+ break;
+ default:
+ throw new ArgumentException("Unsupported direction for chirality");
+ }
+ }
+
+ int? FillChirality(int2 point, int prop)
+ {
+ if (prop == FirstPassSentinel)
+ return chirality[point];
+
+ if (chirality[point] != 0)
+ return null;
+ chirality[point] = prop;
+ return prop;
+ }
+
+ FloodFill(size, seeds, FillChirality, Direction.Spread4);
+
+ return chirality;
+ }
+
+ ///
+ ///
+ /// Trace the borders between true and false regions of an input matrix, returning an array
+ /// of point sequences.
+ ///
+ ///
+ /// Point sequences follow the borders keeping the true region on the right-hand side as it
+ /// traces forward. Loops have a matching start and end point.
+ ///
+ ///
+ /// If a mask is supplied, only borders between matrix cells in the mask are considered.
+ ///
+ ///
+ public static int2[][] BordersToPoints(Matrix matrix, Matrix mask = null)
+ {
+ if (mask != null && matrix.Size != mask.Size)
+ throw new ArgumentException("matrix and mask did not have same size");
+
+ // There is redundant memory/iteration, but I don't care enough.
+
+ // These are really only the signs of the gradients.
+ var gradientH = new Matrix(matrix.Size);
+ var gradientV = new Matrix(matrix.Size);
+ for (var y = 0; y < matrix.Size.Y; y++)
+ for (var x = 1; x < matrix.Size.X; x++)
+ if (mask == null || (mask[x - 1, y] && mask[x, y]))
+ {
+ var l = matrix[x - 1, y] ? 1 : 0;
+ var r = matrix[x, y] ? 1 : 0;
+ gradientV[x, y] = (sbyte)(r - l);
+ }
+
+ for (var y = 1; y < matrix.Size.Y; y++)
+ for (var x = 0; x < matrix.Size.X; x++)
+ if (mask == null || (mask[x, y - 1] && mask[x, y]))
+ {
+ var u = matrix[x, y - 1] ? 1 : 0;
+ var d = matrix[x, y] ? 1 : 0;
+ gradientH[x, y] = (sbyte)(d - u);
+ }
+
+ // Looping paths contain the start/end point twice.
+ var paths = new List();
+ void TracePath(int sx, int sy, int direction)
+ {
+ var points = new List();
+ var x = sx;
+ var y = sy;
+ points.Add(new int2(x, y));
+ do
+ {
+ switch (direction)
+ {
+ case Direction.R:
+ gradientH[x, y] = 0;
+ x++;
+ break;
+ case Direction.D:
+ gradientV[x, y] = 0;
+ y++;
+ break;
+ case Direction.L:
+ x--;
+ gradientH[x, y] = 0;
+ break;
+ case Direction.U:
+ y--;
+ gradientV[x, y] = 0;
+ break;
+ default:
+ throw new ArgumentException("direction assertion failed");
+ }
+
+ points.Add(new int2(x, y));
+ var r = gradientH.ContainsXY(x, y) && gradientH[x, y] > 0;
+ var d = gradientV.ContainsXY(x, y) && gradientV[x, y] < 0;
+ var l = gradientH.ContainsXY(x - 1, y) && gradientH[x - 1, y] < 0;
+ var u = gradientV.ContainsXY(x, y - 1) && gradientV[x, y - 1] > 0;
+ if (direction == Direction.R && u)
+ direction = Direction.U;
+ else if (direction == Direction.D && r)
+ direction = Direction.R;
+ else if (direction == Direction.L && d)
+ direction = Direction.D;
+ else if (direction == Direction.U && l)
+ direction = Direction.L;
+ else if (r)
+ direction = Direction.R;
+ else if (d)
+ direction = Direction.D;
+ else if (l)
+ direction = Direction.L;
+ else if (u)
+ direction = Direction.U;
+ else
+ break; // Dead end (not a loop)
+ }
+ while (x != sx || y != sy);
+
+ paths.Add(points.ToArray());
+ }
+
+ // Trace non-loops (from edge of map)
+ for (var x = 1; x < matrix.Size.X; x++)
+ {
+ if (gradientV[x, 0] < 0)
+ TracePath(x, 0, Direction.D);
+ if (gradientV[x, matrix.Size.Y - 1] > 0)
+ TracePath(x, matrix.Size.Y, Direction.U);
+ }
+
+ for (var y = 1; y < matrix.Size.Y; y++)
+ {
+ if (gradientH[0, y] > 0)
+ TracePath(0, y, Direction.R);
+ if (gradientH[matrix.Size.X - 1, y] < 0)
+ TracePath(matrix.Size.X, y, Direction.L);
+ }
+
+ // Trace loops
+ for (var y = 0; y < matrix.Size.Y; y++)
+ for (var x = 0; x < matrix.Size.X; x++)
+ {
+ if (gradientH[x, y] > 0)
+ TracePath(x, y, Direction.R);
+ else if (gradientH[x, y] < 0)
+ TracePath(x + 1, y, Direction.L);
+
+ if (gradientV[x, y] < 0)
+ TracePath(x, y, Direction.D);
+ else if (gradientV[x, y] > 0)
+ TracePath(x, y + 1, Direction.U);
+ }
+
+ return paths.ToArray();
+ }
+
+ ///
+ ///
+ /// Takes an input boolean matrix and performs adjustments to improve the local consistency
+ /// of the true and false regions, making them "blotchy":
+ ///
+ ///
+ /// - Smoothing via thresholded median blurs.
+ ///
+ ///
+ /// - A minimum thickness is enforced for all true/false regions. More formally, eroding
+ /// and then dilating the true or false regions by minimumThickness results in no change.
+ ///
+ ///
+ /// - No grid points connect diagonally-crossing true and false regions. In other words,
+ /// these 2x2 patterns never appear in the output matrix:
+ ///
+ /// 10 01
+ /// 01 or 10
+ ///
+ ///
+ ///
+ /// A new matrix is returned. The input is unmodified.
+ ///
+ ///
+ public static Matrix BooleanBlotch(
+ Matrix input,
+ int terrainSmoothing,
+ float smoothingThreshold,
+ int minimumThickness,
+ bool bias)
+ {
+ var maxSpan = Math.Max(input.Size.X, input.Size.Y);
+ var matrix = input;
+
+ (matrix, _) = BooleanBlur(matrix, terrainSmoothing, 0.5f);
+ for (var i1 = 0; i1 < /*max passes*/16; i1++)
+ {
+ for (var i2 = 0; i2 < maxSpan; i2++)
+ {
+ int changes;
+ var changesAcc = 0;
+ for (var r = 1; r <= terrainSmoothing; r++)
+ {
+ (matrix, changes) = BooleanBlur(matrix, r, smoothingThreshold);
+ changesAcc += changes;
+ }
+
+ if (changesAcc == 0)
+ break;
+ }
+
+ {
+ var changesAcc = 0;
+ int changes;
+ (matrix, changes) = RetainThickRegions(matrix, true, minimumThickness);
+ changesAcc += changes;
+ changes = DilateThinRegionsInPlaceFull(matrix, true, minimumThickness);
+ changesAcc += changes;
+
+ var midFixLandmass = matrix.Clone();
+
+ (matrix, changes) = RetainThickRegions(matrix, false, minimumThickness);
+ changesAcc += changes;
+ changes = DilateThinRegionsInPlaceFull(matrix, false, minimumThickness);
+ changesAcc += changes;
+ if (changesAcc == 0)
+ break;
+
+ if (i1 >= 8 && i1 % 4 == 0)
+ {
+ var diff = Matrix.Zip(midFixLandmass, matrix, (a, b) => a != b);
+ for (var y = 0; y < matrix.Size.Y; y++)
+ for (var x = 0; x < matrix.Size.X; x++)
+ {
+ if (diff[x, y])
+ OverCircle(
+ matrix: matrix,
+ center: new float2(x, y),
+ radius: minimumThickness * 2,
+ outside: false,
+ action: (xy, _) => matrix[xy] = bias);
+ }
+ }
+ }
+ }
+
+ return matrix;
+ }
+
+ ///
+ /// Repeatedly calls DilateThinRegionsInPlace until no changes are made.
+ ///
+ static int DilateThinRegionsInPlaceFull(Matrix input, bool foreground, int width)
+ {
+ int changes;
+ var changesAcc = 0;
+
+ do
+ {
+ changes = DilateThinRegionsInPlace(input, foreground, width);
+ changesAcc += changes;
+ }
+ while (changes > 0);
+
+ return changesAcc;
+ }
+
+ ///
+ ///
+ /// If foreground true, finds the thinnest true regions and dilates them.
+ /// If foreground false, finds the thinnest false regions and dilates them.
+ /// Each call only dilates thin regions by one cell's thickness on each border.
+ ///
+ ///
+ /// Only regions with a thickness less than width (in Chebychev distance) are considered.
+ ///
+ ///
+ /// Returns the number of changes made.
+ ///
+ ///
+ static int DilateThinRegionsInPlace(Matrix input, bool foreground, int width)
+ {
+ var sizeMinus1 = input.Size - new int2(1, 1);
+ var cornerMaskSpan = width + 1;
+
+ // Zero means ignore.
+ var cornerMask = new Matrix(cornerMaskSpan, cornerMaskSpan);
+
+ for (var y = 0; y < cornerMaskSpan; y++)
+ for (var x = 0; x < cornerMaskSpan; x++)
+ cornerMask[x, y] = 1 + width + width - x - y;
+
+ cornerMask[0] = 0;
+
+ // Higher number indicates a thinner area.
+ var thinness = new Matrix(input.Size);
+ void SetThinness(int x, int y, int v)
+ {
+ if (!input.ContainsXY(x, y))
+ return;
+ if (input[x, y] == foreground)
+ return;
+ thinness[x, y] = Math.Max(v, thinness[x, y]);
+ }
+
+ for (var cy = 0; cy < input.Size.Y; cy++)
+ for (var cx = 0; cx < input.Size.X; cx++)
+ {
+ if (input[cx, cy] == foreground)
+ continue;
+
+ // _L_eft _R_ight _U_p _D_own
+ var l = input[Math.Max(cx - 1, 0), cy] == foreground;
+ var r = input[Math.Min(cx + 1, sizeMinus1.X), cy] == foreground;
+ var u = input[cx, Math.Max(cy - 1, 0)] == foreground;
+ var d = input[cx, Math.Min(cy + 1, sizeMinus1.Y)] == foreground;
+ var lu = l && u;
+ var ru = r && u;
+ var ld = l && d;
+ var rd = r && d;
+ for (var ry = 0; ry < cornerMaskSpan; ry++)
+ for (var rx = 0; rx < cornerMaskSpan; rx++)
+ {
+ if (rd)
+ {
+ var x = cx + rx;
+ var y = cy + ry;
+ SetThinness(x, y, cornerMask[rx, ry]);
+ }
+
+ if (ru)
+ {
+ var x = cx + rx;
+ var y = cy - ry;
+ SetThinness(x, y, cornerMask[rx, ry]);
+ }
+
+ if (ld)
+ {
+ var x = cx - rx;
+ var y = cy + ry;
+ SetThinness(x, y, cornerMask[rx, ry]);
+ }
+
+ if (lu)
+ {
+ var x = cx - rx;
+ var y = cy - ry;
+ SetThinness(x, y, cornerMask[rx, ry]);
+ }
+ }
+ }
+
+ var thinnest = thinness.Data.Max();
+ if (thinnest == 0)
+ {
+ // No fixes
+ return 0;
+ }
+
+ var changes = 0;
+ for (var y = 0; y < input.Size.Y; y++)
+ for (var x = 0; x < input.Size.X; x++)
+ if (thinness[x, y] == thinnest)
+ {
+ input[x, y] = foreground;
+ changes++;
+ }
+
+ // Fixes made, with potentially more that can be done in another pass.
+ return changes;
+ }
+
+ static Matrix RemoveJunctionsFromDirectionMap(Matrix input)
+ {
+ var output = input.Clone();
+ for (var cy = 0; cy < input.Size.Y; cy++)
+ for (var cx = 0; cx < input.Size.X; cx++)
+ {
+ var dm = input[cx, cy];
+ if (Direction.Count(dm) > 2)
+ {
+ output[cx, cy] = 0;
+ foreach (var (offset, d) in Direction.Spread8D)
+ {
+ var xy = new int2(cx + offset.X, cy + offset.Y);
+ if (!input.ContainsXY(xy))
+ continue;
+ var dr = Direction.Reverse(d);
+ output[xy] = (byte)(output[xy] & ~(1 << dr));
+ }
+ }
+ }
+
+ for (var x = 0; x < input.Size.X; x++)
+ {
+ output[x, 0] = (byte)(output[x, 0] & ~(Direction.MLU | Direction.MU | Direction.MRU));
+ output[x, input.Size.Y - 1] = (byte)(output[x, input.Size.Y - 1] & ~(Direction.MRD | Direction.MD | Direction.MLD));
+ }
+
+ for (var y = 0; y < input.Size.Y; y++)
+ {
+ output[0, y] = (byte)(output[0, y] & ~(Direction.MLD | Direction.ML | Direction.MLU));
+ output[input.Size.X - 1, y] &= (byte)(output[input.Size.X - 1, y] & ~(Direction.MRU | Direction.MR | Direction.MRD));
+ }
+
+ return output;
+ }
+
+ ///
+ /// Traces a matrix of directions into a set of point sequences.
+ /// Any junctions in the input direction map are dropped.
+ ///
+ public static int2[][] DirectionMapToPaths(Matrix input)
+ {
+ input = RemoveJunctionsFromDirectionMap(input);
+
+ // Loops not handled, but these would be extremely rare anyway.
+ var pointArrays = new List();
+ for (var sy = 0; sy < input.Size.Y; sy++)
+ for (var sx = 0; sx < input.Size.X; sx++)
+ {
+ var sdm = input[sx, sy];
+ if (Direction.FromMask(sdm) != Direction.None)
+ {
+ var points = new List();
+ var xy = new int2(sx, sy);
+ var reverseDm = 0;
+
+ bool AddPoint()
+ {
+ points.Add(xy);
+ var dm = input[xy] & ~reverseDm;
+ foreach (var (offset, d) in Direction.Spread8D)
+ if ((dm & (1 << d)) != 0)
+ {
+ xy += offset;
+ if (!input.ContainsXY(xy))
+ throw new ArgumentException("input should not link out of bounds");
+ reverseDm = 1 << Direction.Reverse(d);
+ return true;
+ }
+
+ return false;
+ }
+
+ while (AddPoint()) { }
+
+ pointArrays.Add(points.ToArray());
+ }
+ }
+
+ return pointArrays.ToArray();
+ }
+
+ ///
+ ///
+ /// Given a set of point sequences and a stencil mask that defines permitted point positions,
+ /// remove points that are disallowed, splitting or dropping point sequences as needed.
+ ///
+ ///
+ /// The outside of the matrix is considered false (points disallowed).
+ ///
+ ///
+ /// Sequences with fewer than 2 points are dropped.
+ ///
+ ///
+ public static int2[][] MaskPathPoints(IEnumerable pointArrayArray, Matrix mask)
+ {
+ var newPointArrayArray = new List();
+
+ foreach (var pointArray in pointArrayArray)
+ {
+ if (pointArray == null || pointArray.Length < 2)
+ continue;
+
+ var isLoop = pointArray[0] == pointArray[^1];
+ int firstBad;
+ for (firstBad = 0; firstBad < pointArray.Length; firstBad++)
+ if (!(mask.ContainsXY(pointArray[firstBad]) && mask[pointArray[firstBad]]))
+ break;
+
+ if (firstBad == pointArray.Length)
+ {
+ // The path is entirely within the mask already.
+ newPointArrayArray.Add(pointArray);
+ continue;
+ }
+
+ var startAt = isLoop ? firstBad : 0;
+ var wrapAt = isLoop ? pointArray.Length - 1 : pointArray.Length;
+ var i = startAt;
+ List currentPointArray = null;
+ do
+ {
+ if (mask.ContainsXY(pointArray[i]) && mask[pointArray[i]])
+ {
+ currentPointArray ??= new List();
+ currentPointArray.Add(pointArray[i]);
+ }
+ else
+ {
+ if (currentPointArray != null && currentPointArray.Count > 1)
+ newPointArrayArray.Add(currentPointArray.ToArray());
+ currentPointArray = null;
+ }
+
+ i++;
+ if (i == wrapAt)
+ i = 0;
+ }
+ while (i != startAt);
+
+ if (currentPointArray != null && currentPointArray.Count > 1)
+ newPointArrayArray.Add(currentPointArray.ToArray());
+ }
+
+ return newPointArrayArray.ToArray();
+ }
+
+ ///
+ ///
+ /// Run an action over the inside or outside of a circle of given center and radius. The
+ /// action is called with the int2 position and the squared distance to the circle's
+ /// center. If outside is true, the action is run for cells outside of the circle instead
+ /// of the inside.
+ ///
+ ///
+ /// A matrix cell is inside the circle if its position is <= radius from center.
+ /// Coordinates outside of the Matrix are ignored.
+ ///
+ ///
+ public static void OverCircle(
+ Matrix matrix,
+ float2 center,
+ float radius,
+ bool outside,
+ Action action)
+ {
+ var size = matrix.Size;
+ int minX;
+ int minY;
+ int maxX;
+ int maxY;
+ if (outside)
+ {
+ minX = 0;
+ minY = 0;
+ maxX = size.X - 1;
+ maxY = size.Y - 1;
+ }
+ else
+ {
+ minX = (int)MathF.Floor(center.X - radius);
+ minY = (int)MathF.Floor(center.Y - radius);
+ maxX = (int)MathF.Ceiling(center.X + radius);
+ maxY = (int)MathF.Ceiling(center.Y + radius);
+ if (minX < 0)
+ minX = 0;
+ if (minY < 0)
+ minY = 0;
+ if (maxX >= size.X)
+ maxX = size.X - 1;
+ if (maxY >= size.Y)
+ maxY = size.Y - 1;
+ }
+
+ var radiusSquared = radius * radius;
+ for (var y = minY; y <= maxY; y++)
+ for (var x = minX; x <= maxX; x++)
+ {
+ var rx = x - center.X;
+ var ry = y - center.Y;
+ var thisRadiusSquared = rx * rx + ry * ry;
+ if (thisRadiusSquared <= radiusSquared != outside)
+ action(new int2(x, y), thisRadiusSquared);
+ }
+ }
+ }
+}
diff --git a/OpenRA.Mods.Common/MapGenerator/MultiBrush.cs b/OpenRA.Mods.Common/MapGenerator/MultiBrush.cs
new file mode 100644
index 0000000000..aa44853812
--- /dev/null
+++ b/OpenRA.Mods.Common/MapGenerator/MultiBrush.cs
@@ -0,0 +1,455 @@
+#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.Linq;
+using OpenRA.Mods.Common.Terrain;
+using OpenRA.Support;
+
+namespace OpenRA.Mods.Common.MapGenerator
+{
+ ///
+ /// MiniYaml-loaded definition of a MultiBrush. Can be loaded into a MultiBrush once a map is
+ /// available.
+ ///
+ public sealed class MultiBrushInfo
+ {
+ public readonly float Weight;
+ public readonly ImmutableArray Actors;
+ public readonly TerrainTile? BackingTile;
+ public readonly ImmutableArray Templates;
+ public readonly ImmutableArray Tiles;
+
+ // Currently doesn't support specifying offsets. Add this capability if/when needed.
+ public MultiBrushInfo(MiniYaml my)
+ {
+ Weight = 1.0f;
+ var actors = new List();
+ var templates = new List();
+ var tiles = new List();
+ foreach (var node in my.Nodes)
+ switch (node.Key.Split('@')[0])
+ {
+ case "Weight":
+ if (!Exts.TryParseFloatOrPercentInvariant(node.Value.Value, out Weight))
+ throw new YamlException($"Invalid MultiBrush Weight `${node.Value.Value}`");
+ break;
+ case "Actor":
+ actors.Add(node.Value.Value);
+ break;
+ case "BackingTile":
+ if (TerrainTile.TryParse(node.Value.Value, out var backingTile))
+ BackingTile = backingTile;
+ else
+ throw new YamlException($"Invalid MultiBrush BackingTile `${node.Value.Value}`");
+ break;
+ case "Template":
+ if (Exts.TryParseUshortInvariant(node.Value.Value, out var template))
+ templates.Add(template);
+ else
+ throw new YamlException($"Invalid MultiBrush Template `${node.Value.Value}`");
+ break;
+ case "Tile":
+ if (TerrainTile.TryParse(node.Value.Value, out var tile))
+ Tiles.Add(tile);
+ else
+ throw new YamlException($"Invalid MultiBrush Tile `${node.Value.Value}`");
+ break;
+ default:
+ throw new YamlException($"Unrecognized MultiBrush key {node.Key.Split('@')[0]}");
+ }
+
+ Actors = actors.ToImmutableArray();
+ Templates = templates.ToImmutableArray();
+ Tiles = tiles.ToImmutableArray();
+ }
+
+ public static ImmutableArray ParseCollection(MiniYaml my)
+ {
+ var brushes = new List();
+ foreach (var node in my.Nodes)
+ if (node.Key.Split('@')[0] == "MultiBrush")
+ brushes.Add(new MultiBrushInfo(node.Value));
+ else
+ throw new YamlException($"Expected `MultiBrush@*` but got `{node.Key}`");
+ return brushes.ToImmutableArray();
+ }
+ }
+
+ /// A super template that can be used to paint both tiles and actors.
+ sealed class MultiBrush
+ {
+ public enum Replaceability
+ {
+ /// Area cannot be replaced by a tile or obstructing actor.
+ None = 0,
+
+ /// Area must be replaced by a different tile, and may optionally be given an actor.
+ Tile = 1,
+
+ /// Area must be given an actor, but the underlying tile must not change.
+ Actor = 2,
+
+ /// Area can be replaced by a tile and/or actor.
+ Any = 3,
+ }
+
+ public float Weight;
+ readonly List<(CVec, TerrainTile)> tiles;
+ readonly List actorPlans;
+ CVec[] shape;
+
+ public IEnumerable<(CVec XY, TerrainTile Tile)> Tiles => tiles;
+ public IEnumerable ActorPlans => actorPlans;
+ public bool HasTiles => tiles.Count != 0;
+ public bool HasActors => actorPlans.Count != 0;
+ public IEnumerable Shape => shape;
+ public int Area => shape.Length;
+ public Replaceability Contract()
+ {
+ var hasTiles = tiles.Count != 0;
+ var hasActorPlans = actorPlans.Count != 0;
+ if (hasTiles && hasActorPlans)
+ return Replaceability.Any;
+ else if (hasTiles && !hasActorPlans)
+ return Replaceability.Tile;
+ else if (!hasTiles && hasActorPlans)
+ return Replaceability.Actor;
+ else
+ throw new ArgumentException("MultiBrush has no tiles or actors");
+ }
+
+ ///
+ /// Create a new empty MultiBrush with a default weight of 1.0.
+ ///
+ public MultiBrush()
+ {
+ Weight = 1.0f;
+ tiles = new List<(CVec, TerrainTile)>();
+ actorPlans = new List();
+ shape = Array.Empty();
+ }
+
+ MultiBrush(MultiBrush other)
+ {
+ Weight = other.Weight;
+ tiles = new List<(CVec, TerrainTile)>(other.tiles);
+ actorPlans = new List(other.actorPlans);
+ shape = other.shape.ToArray();
+ }
+
+ public MultiBrush(Map map, MultiBrushInfo info)
+ : this()
+ {
+ WithWeight(info.Weight);
+ foreach (var actor in info.Actors)
+ WithActor(new ActorPlan(map, actor).AlignFootprint());
+ if (info.BackingTile != null)
+ WithBackingTile((TerrainTile)info.BackingTile);
+ foreach (var template in info.Templates)
+ WithTemplate(map, template);
+ foreach (var tile in info.Tiles)
+ WithTile(tile);
+ }
+
+ /// Load a named MultiBrush collection from a map's tileset.
+ public static ImmutableArray LoadCollection(Map map, string name)
+ {
+ var templatedTerrainInfo = map.Rules.TerrainInfo as ITemplatedTerrainInfo;
+ return templatedTerrainInfo.MultiBrushCollections[name]
+ .Select(info => new MultiBrush(map, info))
+ .ToImmutableArray();
+ }
+
+ ///
+ /// Clone the brush. Note that this does not deep clone any ActorPlans.
+ ///
+ public MultiBrush Clone()
+ {
+ return new MultiBrush(this);
+ }
+
+ void UpdateShape()
+ {
+ var xys = new HashSet();
+
+ foreach (var (xy, _) in tiles)
+ xys.Add(xy);
+
+ foreach (var actorPlan in actorPlans)
+ foreach (var cpos in actorPlan.Footprint().Keys)
+ xys.Add(new CVec(cpos.X, cpos.Y));
+
+ shape = xys.OrderBy(xy => (xy.Y, xy.X)).ToArray();
+ }
+
+ ///
+ /// Add tiles from a template, optionally with a given offset. By
+ /// default, it will be auto-offset such that the first tile is
+ /// under (0, 0).
+ ///
+ public MultiBrush WithTemplate(Map map, ushort templateId, CVec? offset = null)
+ {
+ var tileset = map.Rules.TerrainInfo as ITemplatedTerrainInfo;
+ if (!tileset.Templates.TryGetValue(templateId, out var templateInfo))
+ throw new ArgumentException($"Map's tileset does not contain template with ID {templateId}.");
+ return WithTemplate(templateInfo, offset);
+ }
+
+ public MultiBrush WithTemplate(TerrainTemplateInfo templateInfo, CVec? offset = null)
+ {
+ if (templateInfo.PickAny)
+ throw new ArgumentException("PickAny not supported - create separate MultiBrushes using WithTile instead.");
+ for (var y = 0; y < templateInfo.Size.Y; y++)
+ for (var x = 0; x < templateInfo.Size.X; x++)
+ {
+ var i = y * templateInfo.Size.X + x;
+ if (templateInfo[i] != null)
+ {
+ if (offset == null)
+ offset = new CVec(-x, -y);
+ var tile = new TerrainTile(templateInfo.Id, (byte)i);
+ tiles.Add((new CVec(x, y) + (CVec)offset, tile));
+ }
+ }
+
+ UpdateShape();
+ return this;
+ }
+
+ ///
+ /// Add a single tile, optionally with a given offset. By default, it
+ /// will be positioned under (0, 0).
+ ///
+ public MultiBrush WithTile(TerrainTile tile, CVec? offset = null)
+ {
+ tiles.Add((offset ?? new CVec(0, 0), tile));
+ UpdateShape();
+ return this;
+ }
+
+ /// Add an actor (using the ActorPlan's location as an offset).
+ public MultiBrush WithActor(ActorPlan actor)
+ {
+ actorPlans.Add(actor);
+ UpdateShape();
+ return this;
+ }
+
+ ///
+ /// For all spaces occupied by the brush, add the given tile.
+ /// This is useful for adding a backing tile for actors.
+ ///
+ public MultiBrush WithBackingTile(TerrainTile tile)
+ {
+ if (Area == 0)
+ throw new InvalidOperationException("No area");
+ foreach (var xy in shape)
+ tiles.Add((xy, tile));
+
+ return this;
+ }
+
+ /// Update the weight.
+ public MultiBrush WithWeight(float weight)
+ {
+ if (!(weight > 0.0f))
+ throw new ArgumentException("Weight was not > 0.0");
+ Weight = weight;
+ return this;
+ }
+
+ ///
+ /// Paint tiles onto the map and/or add actors to actorPlans at the given location.
+ /// contract specifies whether tiles or actors are allowed to be painted.
+ /// If nothing could be painted, throws ArgumentException.
+ ///
+ public void Paint(Map map, List actorPlans, CPos paintAt, Replaceability contract)
+ {
+ switch (contract)
+ {
+ case Replaceability.None:
+ throw new ArgumentException("Cannot paint: Replaceability.None");
+ case Replaceability.Any:
+ if (this.actorPlans.Count > 0)
+ PaintActors(map, actorPlans, paintAt);
+ else if (tiles.Count > 0)
+ PaintTiles(map, paintAt);
+ else
+ throw new ArgumentException("Cannot paint: no tiles or actors");
+ break;
+ case Replaceability.Tile:
+ if (tiles.Count == 0)
+ throw new ArgumentException("Cannot paint: no tiles");
+ PaintTiles(map, paintAt);
+ PaintActors(map, actorPlans, paintAt);
+ break;
+ case Replaceability.Actor:
+ if (this.actorPlans.Count == 0)
+ throw new ArgumentException("Cannot paint: no actors");
+ PaintActors(map, actorPlans, paintAt);
+ break;
+ }
+ }
+
+ void PaintTiles(Map map, CPos paintAt)
+ {
+ foreach (var (xy, tile) in tiles)
+ {
+ var mpos = (paintAt + xy).ToMPos(map);
+ if (map.Tiles.Contains(mpos))
+ map.Tiles[mpos] = tile;
+ }
+ }
+
+ void PaintActors(Map map, List actorPlans, CPos paintAt)
+ {
+ foreach (var actorPlan in this.actorPlans)
+ {
+ if (map != actorPlan.Map)
+ throw new ArgumentException("ActorPlan is for a different map");
+ var plan = actorPlan.Clone();
+ var offset = plan.Location;
+ plan.Location = paintAt + new CVec(offset.X, offset.Y);
+ actorPlans.Add(plan);
+ }
+ }
+
+ ///
+ /// Paint an area defined by replace onto map and actorPlans using availableBrushes.
+ ///
+ public static void PaintArea(
+ Map map,
+ List actorPlans,
+ CellLayer replace,
+ IReadOnlyList availableBrushes,
+ MersenneTwister random)
+ {
+ var brushesByAreaDict = new Dictionary>();
+ foreach (var brush in availableBrushes)
+ {
+ if (!brushesByAreaDict.ContainsKey(brush.Area))
+ brushesByAreaDict.Add(brush.Area, new List());
+ brushesByAreaDict[brush.Area].Add(brush);
+ }
+
+ var brushesByArea = brushesByAreaDict
+ .OrderBy(kv => -kv.Key)
+ .ToList();
+ var brushTotalArea = availableBrushes.Sum(t => t.Area);
+ var brushTotalWeight = availableBrushes.Sum(t => t.Weight);
+
+ // Give 1-by-1 actors the final pass, as they are most flexible.
+ brushesByArea.Add(
+ new KeyValuePair>(
+ 1,
+ availableBrushes.Where(o => o.HasActors && o.Area == 1).ToList()));
+ var size = map.MapSize;
+ var replaceMposes = new List();
+ var remaining = new CellLayer(map);
+ for (var v = 0; v < size.Y; v++)
+ for (var u = 0; u < size.X; u++)
+ {
+ var mpos = new MPos(u, v);
+ if (replace[mpos] != Replaceability.None)
+ {
+ remaining[mpos] = true;
+ replaceMposes.Add(mpos);
+ }
+ else
+ {
+ remaining[mpos] = false;
+ }
+ }
+
+ var mposes = new MPos[size.X * size.Y];
+ int mposCount;
+
+ void RefreshIndices()
+ {
+ mposCount = 0;
+ foreach (var mpos in replaceMposes)
+ if (remaining[mpos])
+ {
+ mposes[mposCount] = mpos;
+ mposCount++;
+ }
+
+ random.ShuffleInPlace(mposes, 0, mposCount);
+ }
+
+ Replaceability ReserveShape(CPos paintAt, IEnumerable shape, Replaceability contract)
+ {
+ foreach (var cvec in shape)
+ {
+ var cpos = paintAt + cvec;
+ if (!replace.Contains(cpos))
+ continue;
+ if (!remaining[cpos])
+ {
+ // Can't reserve - not the right shape
+ return Replaceability.None;
+ }
+
+ contract &= replace[cpos];
+ if (contract == Replaceability.None)
+ {
+ // Can't reserve - obstruction choice doesn't comply
+ // with replaceability of original tiles.
+ return Replaceability.None;
+ }
+ }
+
+ // Can reserve. Commit.
+ foreach (var cvec in shape)
+ {
+ var cpos = paintAt + cvec;
+ if (!replace.Contains(cpos))
+ continue;
+
+ remaining[cpos] = false;
+ }
+
+ return contract;
+ }
+
+ foreach (var brushesKv in brushesByArea)
+ {
+ var brushes = brushesKv.Value;
+ if (brushes.Count == 0)
+ continue;
+
+ var brushArea = brushes[0].Area;
+ var brushWeights = brushes.Select(o => o.Weight).ToArray();
+ var brushWeightForArea = brushWeights.Sum();
+ var remainingQuota =
+ brushArea == 1
+ ? int.MaxValue
+ : (int)Math.Ceiling(replaceMposes.Count * brushWeightForArea / brushTotalWeight);
+ RefreshIndices();
+ foreach (var mpos in mposes)
+ {
+ var brush = brushes[random.PickWeighted(brushWeights)];
+ var paintAt = mpos.ToCPos(map);
+ var contract = ReserveShape(paintAt, brush.Shape, brush.Contract());
+ if (contract != Replaceability.None)
+ brush.Paint(map, actorPlans, paintAt, contract);
+
+ remainingQuota -= brushArea;
+ if (remainingQuota <= 0)
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/OpenRA.Mods.Common/MapGenerator/NoiseUtils.cs b/OpenRA.Mods.Common/MapGenerator/NoiseUtils.cs
new file mode 100644
index 0000000000..da1b086743
--- /dev/null
+++ b/OpenRA.Mods.Common/MapGenerator/NoiseUtils.cs
@@ -0,0 +1,177 @@
+#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 OpenRA.Support;
+
+namespace OpenRA.Mods.Common.MapGenerator
+{
+ public static class NoiseUtils
+ {
+ /// Amplitude proportional to wavelength.
+ public static float PinkAmplitude(float wavelength) => wavelength;
+
+ ///
+ ///
+ /// Create noise by combining multiple layers of Perlin noise of halving wavelengths.
+ ///
+ ///
+ /// wavelengthScale defines the largest wavelength as a fraction of the largest dimension of
+ /// the output.
+ ///
+ ///
+ /// ampFunc specifies the amplitude of each wavelength. PinkAmplitude is often a suitable
+ /// choice.
+ ///
+ ///
+ public static Matrix FractalNoise(
+ MersenneTwister random,
+ int2 size,
+ float featureSize,
+ Func ampFunc)
+ {
+ var span = Math.Max(size.X, size.Y);
+ var wavelengths = new float[(int)Math.Log2(span)];
+ for (var i = 0; i < wavelengths.Length; i++)
+ wavelengths[i] = featureSize / (1 << i);
+
+ var noise = new Matrix(size);
+ foreach (var wavelength in wavelengths)
+ {
+ if (wavelength <= 0.5)
+ break;
+
+ var amps = ampFunc(wavelength);
+ var subSpan = (int)(span / wavelength) + 2;
+ var subNoise = PerlinNoise(random, subSpan);
+
+ // Offsets should align to grid.
+ // (The wavelength is divided back out later.)
+ var offsetX = (int)(random.NextFloat() * wavelength);
+ var offsetY = (int)(random.NextFloat() * wavelength);
+ for (var y = 0; y < size.Y; y++)
+ for (var x = 0; x < size.X; x++)
+ noise[y * size.X + x] +=
+ amps * MatrixUtils.Interpolate(
+ subNoise,
+ (offsetX + x) / wavelength,
+ (offsetY + y) / wavelength);
+ }
+
+ return noise;
+ }
+
+ ///
+ /// 2D Perlin Noise generator without interpolation, producing a span-by-span sized matrix.
+ ///
+ public static Matrix PerlinNoise(MersenneTwister random, int span)
+ {
+ var noise = new Matrix(span, span);
+ const float D = 0.25f;
+ for (var y = 0; y <= span; y++)
+ for (var x = 0; x <= span; x++)
+ {
+ var phase = MathF.Tau * random.NextFloatExclusive();
+ var vx = MathF.Cos(phase);
+ var vy = MathF.Sin(phase);
+ if (x > 0 && y > 0)
+ noise[x - 1, y - 1] += vx * -D + vy * -D;
+ if (x < span && y > 0)
+ noise[x, y - 1] += vx * D + vy * -D;
+ if (x > 0 && y < span)
+ noise[x - 1, y] += vx * -D + vy * D;
+ if (x < span && y < span)
+ noise[x, y] += vx * D + vy * D;
+ }
+
+ return noise;
+ }
+
+ ///
+ ///
+ /// Produce symmetric 2D noise by repeatedly applying some generated Perlin noise under
+ /// rotation and mirroring.
+ ///
+ ///
+ /// Note that the combination of multiple noise values with varying correlations creates a
+ /// noise with different properties to simple Perlin noise.
+ ///
+ ///
+ public static Matrix SymmetricFractalNoise(
+ MersenneTwister random,
+ int2 size,
+ int rotations,
+ Symmetry.Mirror mirror,
+ float featureSize,
+ Func ampFunc)
+ {
+ if (rotations < 1)
+ throw new ArgumentException("rotations must be >= 1");
+
+ // Need higher resolution due to cropping and rotation artifacts
+ var templateSpan = Math.Max(size.X, size.Y) * 2 + 2;
+ var templateSize = new int2(templateSpan, templateSpan);
+ var templateCenter = new float2(templateSpan - 1, templateSpan - 1) / 2.0f;
+ var template = FractalNoise(random, templateSize, featureSize, ampFunc);
+
+ var output = new Matrix(size);
+
+ var inclusiveOutputSize = size - new int2(1, 1);
+ var outputMid = new float2(inclusiveOutputSize) / 2.0f;
+
+ for (var y = 0; y < size.Y; y++)
+ for (var x = 0; x < size.X; x++)
+ {
+ const float Sqrt2 = 1.4142135623730951f;
+ var outputXy = new float2(x, y);
+ var outputXyFromCenter = outputXy - outputMid;
+ var templateXyFromCenter = outputXyFromCenter * Sqrt2;
+ var templateXy = templateXyFromCenter + templateCenter;
+
+ var projections = Symmetry.RotateAndMirrorPointAround(
+ templateXy, templateCenter, rotations, mirror);
+
+ foreach (var projection in projections)
+ output[x, y] +=
+ MatrixUtils.Interpolate(
+ template,
+ projection.X,
+ projection.Y);
+ }
+
+ return output;
+ }
+
+ ///
+ /// Use SymmetricFractalNoise to fill a CellLayer. The noise is aligned to the CPos
+ /// coordinate system.
+ ///
+ public static void SymmetricFractalNoiseIntoCellLayer(
+ MersenneTwister random,
+ CellLayer cellLayer,
+ int rotations,
+ Symmetry.Mirror mirror,
+ float featureSize,
+ Func ampFunc)
+ {
+ var cellBounds = CellLayerUtils.CellBounds(cellLayer);
+ var size = new int2(cellBounds.Size.Width, cellBounds.Size.Height);
+ var noise = SymmetricFractalNoise(
+ random,
+ size,
+ rotations,
+ mirror,
+ featureSize,
+ ampFunc);
+ CellLayerUtils.FromMatrix(cellLayer, noise);
+ }
+ }
+}
diff --git a/OpenRA.Mods.Common/MapGenerator/PlayableSpace.cs b/OpenRA.Mods.Common/MapGenerator/PlayableSpace.cs
new file mode 100644
index 0000000000..5b8470da69
--- /dev/null
+++ b/OpenRA.Mods.Common/MapGenerator/PlayableSpace.cs
@@ -0,0 +1,148 @@
+#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.Collections.Generic;
+
+namespace OpenRA.Mods.Common.MapGenerator
+{
+ public static class PlayableSpace
+ {
+ public enum Playability
+ {
+ /// Area is unplayable by land/naval units.
+ Unplayable = 0,
+
+ ///
+ /// Area is unplayable by land/naval units, but should count as
+ /// being "within" a playable region. This usually applies to random
+ /// rock or river tiles in largely passable templates.
+ ///
+ Partial = 1,
+
+ ///
+ /// Area is playable by either land or naval units.
+ ///
+ Playable = 2,
+ }
+
+ ///
+ /// Additional data for a region containing playable space.
+ /// The shape of a region is specified separately via a region mask.
+ ///
+ public sealed class Region
+ {
+ /// Area of playable and partially playable space.
+ public int Area;
+
+ /// Area of fully playable space.
+ public int PlayableArea;
+
+ /// Region ID.
+ public int Id;
+ }
+
+ /// Sentinel indicating a position isn't assigned to a region.
+ public const int NullRegion = -1;
+
+ ///
+ ///
+ /// Analyses a given map's tiles and ActorPlans and determines the playable space within it.
+ ///
+ ///
+ /// Requires a playabilityMap which specifies whether certain tiles are considered playable
+ /// or not. Actors are always considered partially playable.
+ ///
+ ///
+ /// RegionMap contains the mapping of map positions to Regions. If a map position is not
+ /// within a region, the value is NullRegion.
+ ///
+ ///
+ public static (Region[] Regions, CellLayer RegionMap, CellLayer Playable) FindPlayableRegions(
+ Map map,
+ List actorPlans,
+ Dictionary playabilityMap)
+ {
+ var size = map.MapSize;
+ var regions = new List();
+ var regionMap = new CellLayer(map);
+ regionMap.Clear(NullRegion);
+ var playable = new CellLayer(map);
+ playable.Clear(Playability.Unplayable);
+ foreach (var mpos in map.AllCells.MapCoords)
+ if (map.Contains(mpos))
+ playable[mpos] = playabilityMap[map.Tiles[mpos]];
+
+ foreach (var actorPlan in actorPlans)
+ foreach (var cpos in actorPlan.Footprint().Keys)
+ if (map.AllCells.Contains(cpos))
+ {
+ var mpos = cpos.ToMPos(map);
+ if (playable[mpos] == Playability.Playable)
+ playable[mpos] = Playability.Partial;
+ }
+
+ void Fill(Region region, CPos start)
+ {
+ void AddToRegion(CPos cpos, bool fullyPlayable)
+ {
+ var mpos = cpos.ToMPos(map);
+ regionMap[mpos] = region.Id;
+ region.Area++;
+ if (fullyPlayable)
+ region.PlayableArea++;
+ }
+
+ bool? Filler(CPos cpos, bool fullyPlayable)
+ {
+ var mpos = cpos.ToMPos(map);
+ if (regionMap[mpos] == NullRegion)
+ {
+ if (fullyPlayable && playable[mpos] == Playability.Playable)
+ {
+ AddToRegion(cpos, true);
+ return true;
+ }
+ else if (playable[mpos] == Playability.Partial)
+ {
+ AddToRegion(cpos, false);
+ return false;
+ }
+ }
+
+ return null;
+ }
+
+ CellLayerUtils.FloodFill(
+ playable,
+ new[] { (start, true) },
+ Filler,
+ Direction.Spread4CVec);
+ }
+
+ foreach (var mpos in map.AllCells.MapCoords)
+ if (regionMap[mpos] == NullRegion && playable[mpos] == Playability.Playable)
+ {
+ var region = new Region()
+ {
+ Area = 0,
+ PlayableArea = 0,
+ Id = regions.Count,
+ };
+
+ regions.Add(region);
+ var cpos = mpos.ToCPos(map);
+ Fill(region, cpos);
+ }
+
+ return (regions.ToArray(), regionMap, playable);
+ }
+ }
+}
diff --git a/OpenRA.Mods.Common/MapGenerator/Symmetry.cs b/OpenRA.Mods.Common/MapGenerator/Symmetry.cs
new file mode 100644
index 0000000000..7906114e29
--- /dev/null
+++ b/OpenRA.Mods.Common/MapGenerator/Symmetry.cs
@@ -0,0 +1,319 @@
+#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.Linq;
+
+namespace OpenRA.Mods.Common.MapGenerator
+{
+ public static class Symmetry
+ {
+ /// Trivial mirroring configurations defined in world space.
+ public enum Mirror
+ {
+ /// No mirror.
+ None = 0,
+
+ /// Match low X with high X in WPos space.
+ LeftMatchesRight = 1,
+
+ /// Match low X, low Y with high X, high Y in WPos space.
+ TopLeftMatchesBottomRight = 2,
+
+ /// Match low Y with high Y in WPos space.
+ TopMatchesBottom = 3,
+
+ /// Match low X, high Y with high X, low Y in WPos space.
+ TopRightMatchesBottomLeft = 4,
+ }
+
+ public static bool TryParseMirror(string s, out Mirror mirror) => Enum.TryParse(s, out mirror);
+
+ ///
+ ///
+ /// Mirrors a (zero-area) point around a given center.
+ ///
+ ///
+ /// For example, if using a center of (4.0, 4.0) a point at (0.1, 0.1) could be projected
+ /// to (0.1, 0.1), (0.1, 7.9), (7.9, 0.1), or (7.9, 7.9).
+ ///
+ ///
+ public static float2 MirrorPointAround(Mirror mirror, float2 original, float2 center)
+ {
+ switch (mirror)
+ {
+ case Mirror.None:
+ throw new ArgumentException("Mirror.None has no transformed point");
+ case Mirror.LeftMatchesRight:
+ return new float2(2.0f * center.X - original.X, original.Y);
+ case Mirror.TopLeftMatchesBottomRight:
+ return new float2(
+ center.Y - original.Y + center.X,
+ center.X - original.X + center.Y);
+ case Mirror.TopMatchesBottom:
+ return new float2(original.X, 2.0f * center.Y - original.Y);
+ case Mirror.TopRightMatchesBottomLeft:
+ return new float2(
+ center.X + original.Y - center.Y,
+ center.Y + original.X - center.X);
+ default:
+ throw new ArgumentException("Bad mirror");
+ }
+ }
+
+ public static WPos MirrorWPosAround(Mirror mirror, WPos original, WPos center)
+ {
+ switch (mirror)
+ {
+ case Mirror.None:
+ throw new ArgumentException("Mirror.None has no transformed point");
+ case Mirror.LeftMatchesRight:
+ return new WPos(2 * center.X - original.X, original.Y, original.Z);
+ case Mirror.TopLeftMatchesBottomRight:
+ return new WPos(
+ center.Y - original.Y + center.X,
+ center.X - original.X + center.Y,
+ original.Z);
+ case Mirror.TopMatchesBottom:
+ return new WPos(original.X, 2 * center.Y - original.Y, original.Z);
+ case Mirror.TopRightMatchesBottomLeft:
+ return new WPos(
+ center.X + original.Y - center.Y,
+ center.Y + original.X - center.X,
+ original.Z);
+ default:
+ throw new ArgumentException("Bad mirror");
+ }
+ }
+
+ ///
+ /// Given rotation and mirror parameters, return the total number of projected points this
+ /// would result in (including the original point).
+ ///
+ public static int RotateAndMirrorProjectionCount(int rotations, Mirror mirror)
+ => mirror == Mirror.None ? rotations : rotations * 2;
+
+ public static WPos[] RotateAndMirrorWPosAround(
+ WPos original,
+ WPos center,
+ int rotations,
+ Mirror mirror)
+ {
+ var projections = new WPos[RotateAndMirrorProjectionCount(rotations, mirror)];
+ var projectionIndex = 0;
+
+ for (var rotation = 0; rotation < rotations; rotation++)
+ {
+ // This could be made more accurate using dedicated, higher precision
+ // rotation count to cos and sin lookup tables.
+ var wangle = new WAngle(rotation * 1024 / rotations);
+ var cos1024 = wangle.Cos();
+ var sin1024 = wangle.Sin();
+ var relOrig = original - center;
+ var projX = (relOrig.X * cos1024 - relOrig.Y * sin1024) / 1024 + center.X;
+ var projY = (relOrig.X * sin1024 + relOrig.Y * cos1024) / 1024 + center.Y;
+ var projection = new WPos(projX, projY, original.Z);
+ projections[projectionIndex++] = projection;
+
+ if (mirror != Mirror.None)
+ projections[projectionIndex++] = MirrorWPosAround(mirror, projection, center);
+ }
+
+ return projections;
+ }
+
+ public static WPos[] RotateAndMirrorWPos(
+ WPos original,
+ CellLayer cellLayer,
+ int rotations,
+ Mirror mirror)
+ {
+ return RotateAndMirrorWPosAround(
+ original,
+ CellLayerUtils.Center(cellLayer),
+ rotations,
+ mirror);
+ }
+
+ public static CPos[] RotateAndMirrorCPos(
+ CPos original,
+ CellLayer cellLayer,
+ int rotations,
+ Mirror mirror)
+ {
+ var cposProjections = new CPos[RotateAndMirrorProjectionCount(rotations, mirror)];
+ var wpos = CellLayerUtils.CPosToWPos(original, cellLayer.GridType);
+ var wposProjections = RotateAndMirrorWPos(wpos, cellLayer, rotations, mirror);
+ for (var i = 0; i < wposProjections.Length; i++)
+ cposProjections[i] = CellLayerUtils.WPosToCPos(wposProjections[i], cellLayer.GridType);
+ return cposProjections;
+ }
+
+ ///
+ /// Determine the shortest distance between projected positions.
+ ///
+ public static int ProjectionProximity(int2[] projections)
+ {
+ if (projections.Length == 1)
+ return int.MaxValue;
+ var worstSpacingSq = long.MaxValue;
+ for (var i1 = 0; i1 < projections.Length; i1++)
+ for (var i2 = 0; i2 < projections.Length; i2++)
+ {
+ if (i1 == i2)
+ continue;
+ var spacingSq = (projections[i1] - projections[i2]).LengthSquared;
+ if (spacingSq < worstSpacingSq)
+ worstSpacingSq = spacingSq;
+ }
+
+ return (int)Math.Sqrt(worstSpacingSq);
+ }
+
+ ///
+ /// Determine the shortest distance between projected positions.
+ ///
+ public static int ProjectionProximity(CPos[] projections)
+ {
+ return ProjectionProximity(
+ projections.Select(cpos => new int2(cpos.X, cpos.Y)).ToArray());
+ }
+
+ ///
+ ///
+ /// Duplicate an original point into an array of projected points according to a rotation
+ /// and mirror specification.
+ ///
+ ///
+ /// Rotations use WAngel-based trigonometric math for consistency with other Symmetry
+ /// functions. This may be slightly imprecise for non-trivial rotations.
+ ///
+ ///
+ /// For example, if using a center of (4.0, 4.0) a point at (0.1, 0.1) could be projected
+ /// to (0.1, 0.1), (0.1, 7.9), (7.9, 0.1), and (7.9, 7.9).
+ ///
+ ///
+ public static float2[] RotateAndMirrorPointAround(
+ float2 original,
+ float2 center,
+ int rotations,
+ Mirror mirror)
+ {
+ var projections = new float2[RotateAndMirrorProjectionCount(rotations, mirror)];
+ var projectionIndex = 0;
+
+ for (var rotation = 0; rotation < rotations; rotation++)
+ {
+ // This could be made more accurate using dedicated, higher precision
+ // rotation count to cos and sin lookup tables.
+ var wangle = new WAngle(rotation * 1024 / rotations);
+ var cos = wangle.Cos() / 1024.0f;
+ var sin = wangle.Sin() / 1024.0f;
+ var relOrig = original - center;
+ var projX = relOrig.X * cos - relOrig.Y * sin + center.X;
+ var projY = relOrig.X * sin + relOrig.Y * cos + center.Y;
+ var projection = new float2(projX, projY);
+ projections[projectionIndex++] = projection;
+
+ if (mirror != Mirror.None)
+ projections[projectionIndex++] = MirrorPointAround(mirror, projection, center);
+ }
+
+ return projections;
+ }
+
+ ///
+ /// Rotate and mirror multiple actor plans. See RotateAndMirrorActorPlan.
+ ///
+ public static ImmutableArray RotateAndMirrorActorPlans(
+ IReadOnlyList originals,
+ int rotations,
+ Mirror mirror)
+ {
+ var projections = new List(
+ originals.Count * RotateAndMirrorProjectionCount(rotations, mirror));
+ foreach (var original in originals)
+ projections.AddRange(RotateAndMirrorActorPlan(original, rotations, mirror));
+
+ return projections.ToImmutableArray();
+ }
+
+ ///
+ /// Rotate and mirror a single actor plan, adding to an accumulator list.
+ /// Locations (CPos) are necessarily snapped to grid.
+ ///
+ public static ImmutableArray RotateAndMirrorActorPlan(
+ ActorPlan original,
+ int rotations,
+ Mirror mirror)
+ {
+ var projections = new List(RotateAndMirrorProjectionCount(rotations, mirror));
+ var points = RotateAndMirrorWPos(
+ original.WPosCenterLocation,
+ original.Map.Tiles,
+ rotations,
+ mirror);
+ foreach (var point in points)
+ {
+ var plan = original.Clone();
+ plan.WPosCenterLocation = point;
+ projections.Add(plan);
+ }
+
+ return projections.ToImmutableArray();
+ }
+
+ ///
+ /// Calls action(projections, original) over all possible original
+ /// CPos positions, where each projection in projections is a
+ /// mirrored/rotated point. For non-trivial symmetries, projections may
+ /// be outside the bounds defined by cellLayer.
+ ///
+ public static void RotateAndMirrorOverCPos(
+ CellLayer cellLayer,
+ int rotations,
+ Mirror mirror,
+ Action action)
+ {
+ var size = cellLayer.Size;
+ for (var v = 0; v < size.Height; v++)
+ for (var u = 0; u < size.Width; u++)
+ {
+ var original = new MPos(u, v).ToCPos(cellLayer.GridType);
+ var projections = RotateAndMirrorCPos(original, cellLayer, rotations, mirror);
+ action(projections, original);
+ }
+ }
+
+ ///
+ /// Returns true iff xy is within reservationRadius of the center of a given CellLayer. If
+ /// a mirroring is specified, the radius is measured from the mirror line instead of the
+ /// center point.
+ ///
+ public static bool IsCPosNearCenter(
+ CPos cpos,
+ CellLayer cellLayer,
+ float reservationRadius,
+ Mirror mirror)
+ {
+ CPos[] testPoints;
+ if (mirror == Mirror.None)
+ testPoints = RotateAndMirrorCPos(cpos, cellLayer, 2, Mirror.None);
+ else
+ testPoints = RotateAndMirrorCPos(cpos, cellLayer, 1, mirror);
+
+ var separation = (testPoints[1] - testPoints[0]).LengthSquared;
+ return separation <= reservationRadius * reservationRadius * 4.0f;
+ }
+ }
+}
diff --git a/OpenRA.Mods.Common/MapGenerator/TilingPath.cs b/OpenRA.Mods.Common/MapGenerator/TilingPath.cs
new file mode 100644
index 0000000000..714a88077a
--- /dev/null
+++ b/OpenRA.Mods.Common/MapGenerator/TilingPath.cs
@@ -0,0 +1,1195 @@
+#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.Mods.Common.Terrain;
+using OpenRA.Primitives;
+using OpenRA.Support;
+
+namespace OpenRA.Mods.Common.MapGenerator
+{
+ /// Path to be tiled onto a map using TemplateSegments.
+ 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 int? Direction;
+
+ ///
+ /// A string which can match the format used by
+ /// OpenRA.Mods.Common.Terrain.TemplateSegment's Start or End.
+ ///
+ public readonly string SegmentType
+ {
+ get
+ {
+ var direction =
+ Direction ?? throw new InvalidOperationException("Direction is null");
+ return $"{Type}.{MapGenerator.Direction.ToString(direction)}";
+ }
+ }
+
+ public Terminal(string type, int? direction)
+ {
+ Type = type;
+ Direction = direction;
+ }
+ }
+
+ ///
+ /// Describes the permitted start, middle, and end segments/templates that can be used to
+ /// tile the path.
+ ///
+ public sealed class PermittedSegments
+ {
+ public readonly ITemplatedTerrainInfo TemplatedTerrainInfo;
+ public readonly ImmutableArray Start;
+ public readonly ImmutableArray Inner;
+ public readonly ImmutableArray End;
+ public IEnumerable All => Start.Union(Inner).Union(End);
+
+ public PermittedSegments(
+ ITemplatedTerrainInfo templatedTerrainInfo,
+ IEnumerable start,
+ IEnumerable inner,
+ IEnumerable end)
+ {
+ TemplatedTerrainInfo = templatedTerrainInfo;
+ Start = start.ToImmutableArray();
+ Inner = inner.ToImmutableArray();
+ End = end.ToImmutableArray();
+ }
+
+ public PermittedSegments(
+ ITemplatedTerrainInfo templatedTerrainInfo,
+ IEnumerable all)
+ {
+ TemplatedTerrainInfo = templatedTerrainInfo;
+ var array = all.ToImmutableArray();
+ Start = array;
+ Inner = array;
+ End = array;
+ }
+
+ ///
+ /// Creates a PermittedSegments using only the given types.
+ ///
+ public static PermittedSegments FromType(
+ ITemplatedTerrainInfo templatedTerrainInfo,
+ IEnumerable types)
+ => new(templatedTerrainInfo, FindSegments(templatedTerrainInfo, types));
+
+ ///
+ /// Creates a PermittedSegments suitable for a path with given inner and terminal types
+ /// at the start and end.
+ ///
+ public static PermittedSegments FromInnerAndTerminalTypes(
+ ITemplatedTerrainInfo templatedTerrainInfo,
+ IEnumerable innerTypes,
+ IEnumerable terminalTypes)
+ {
+ var innerTypesArray = innerTypes.ToImmutableArray();
+ var terminalTypesArray = terminalTypes.ToImmutableArray();
+ return new(
+ templatedTerrainInfo,
+ FindSegments(templatedTerrainInfo, terminalTypesArray, innerTypesArray, innerTypesArray),
+ FindSegments(templatedTerrainInfo, innerTypesArray),
+ FindSegments(templatedTerrainInfo, innerTypesArray, innerTypesArray, terminalTypesArray));
+ }
+
+ ///
+ /// Equivalent to FindSegments(templatedTerrainInfo, types, types, types).
+ ///
+ public static IEnumerable FindSegments(
+ ITemplatedTerrainInfo templatedTerrainInfo,
+ IEnumerable types)
+ {
+ var array = types.ToImmutableArray();
+ return FindSegments(templatedTerrainInfo, array, array, array);
+ }
+
+ ///
+ /// Find templates that use some combination of the given start, inner, and end types.
+ ///
+ public static IEnumerable FindSegments(
+ ITemplatedTerrainInfo templatedTerrainInfo,
+ IEnumerable startTypes,
+ IEnumerable innerTypes,
+ IEnumerable endTypes)
+ {
+ var templateSegments = new List();
+ foreach (var templateInfo in templatedTerrainInfo.Templates.Values.OrderBy(tti => tti.Id))
+ foreach (var segment in templateInfo.Segments)
+ {
+ if (startTypes.Any(segment.HasStartType) &&
+ innerTypes.Any(segment.HasInnerType) &&
+ endTypes.Any(segment.HasEndType))
+ {
+ templateSegments.Add(segment);
+ }
+ }
+
+ return templateSegments.ToArray();
+ }
+
+ ///
+ /// Returns all possible templates that could be layed, ordered by template id.
+ ///
+ public IEnumerable PossibleTemplates()
+ {
+ var templates = new List();
+ var segments = Start.Union(Inner).Union(End).ToHashSet();
+ foreach (var template in TemplatedTerrainInfo.Templates.Values.OrderBy(tti => tti.Id))
+ if (template.Segments.Any(segment => segments.Contains(segment)))
+ templates.Add(template);
+ return templates;
+ }
+
+ ///
+ /// Returns all possible tiles that could be layed, ordered by template id, tile index.
+ ///
+ public IEnumerable PossibleTiles()
+ {
+ var tiles = new List();
+ foreach (var template in PossibleTemplates())
+ for (var index = 0; index < template.TilesCount; index++)
+ if (template[index] != null)
+ tiles.Add(new TerrainTile(template.Id, (byte)index));
+ return tiles;
+ }
+ }
+
+ public Map Map;
+
+ ///
+ ///
+ /// Target point sequence to fit TemplateSegments to. Whether these CPos positions
+ /// represent cell corners or cell centers is dependent on the system used by the path's
+ /// PermittedSegments' TemplateSegments.
+ ///
+ ///
+ /// 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 Chebychev distance that layed TemplateSegments 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;
+
+ ///
+ /// Stores start type and direction.
+ ///
+ public Terminal Start;
+
+ ///
+ /// Stores end type and direction.
+ ///
+ public Terminal End;
+ public PermittedSegments Segments;
+
+ /// 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;
+ Start = new Terminal(startType, null);
+ End = new Terminal(endType, null);
+ Segments = permittedTemplates;
+ }
+
+ sealed class TilingSegment
+ {
+ public readonly TerrainTemplateInfo TemplateInfo;
+ public readonly TemplateSegment TemplateSegment;
+ public readonly int StartTypeId;
+ public readonly int EndTypeId;
+ public readonly CVec Offset;
+ public readonly CVec Moves;
+ public readonly CVec[] RelativePoints;
+ public readonly int[] Directions;
+ public readonly int[] DirectionMasks;
+ public readonly int[] ReverseDirectionMasks;
+
+ public TilingSegment(TerrainTemplateInfo templateInfo, TemplateSegment templateSegment, int startId, int endId)
+ {
+ TemplateInfo = templateInfo;
+ TemplateSegment = templateSegment;
+ StartTypeId = startId;
+ EndTypeId = endId;
+ Offset = templateSegment.Points[0];
+ Moves = templateSegment.Points[^1] - Offset;
+ RelativePoints = templateSegment.Points
+ .Select(p => p - templateSegment.Points[0])
+ .ToArray();
+
+ Directions = new int[RelativePoints.Length];
+ DirectionMasks = new int[RelativePoints.Length];
+ ReverseDirectionMasks = new int[RelativePoints.Length];
+
+ // Last point has no direction.
+ Directions[^1] = Direction.None;
+ DirectionMasks[^1] = 0;
+ ReverseDirectionMasks[^1] = 0;
+ for (var i = 0; i < RelativePoints.Length - 1; i++)
+ {
+ var direction = Direction.FromCVec(RelativePoints[i + 1] - RelativePoints[i]);
+ if (direction == Direction.None)
+ throw new ArgumentException("TemplateSegment has duplicate points in sequence");
+ Directions[i] = direction;
+ DirectionMasks[i] = 1 << direction;
+ ReverseDirectionMasks[i] = 1 << Direction.Reverse(direction);
+ }
+ }
+ }
+
+ ///
+ ///
+ /// Attempt to tile the given path onto a map.
+ ///
+ ///
+ /// If the path could be tiled, returns the sequence of points actually traversed by the
+ /// chosen TemplateSegments. Returns null if the path could not be tiled within constraints.
+ ///
+ ///
+ public CPos[] 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 TemplateSegments.
+ //
+ // 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
+ // TemplateSegments within the permitted set of templates. 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
+ // template segment 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).
+ // - 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.
+ //
+ // 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 that previous node is on an
+ // optimal path towards the end node. This process repeats until the start node is
+ // reached, painting templates along the way.
+ //
+ // Note that this algorithm makes a few (reasonable) assumptions about the shapes of
+ // templates, such as that they don't individually snake around too much. The actual
+ // tiles of a template 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 ??= Direction.FromCVec(Points[1] - Points[0]);
+ end.Direction ??= Direction.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;
+
+ 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)
+ {
+ Debug.Assert(values.Count > 0, "No values");
+ if (values.Count == 1)
+ return (values[0], values[0]);
+ if (IsLoop)
+ {
+ 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();
+ for (var i = 0; i < 8; i++)
+ {
+ var offset = Direction.Spread8[i];
+ 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,
+ Direction.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 Direction.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,
+ Direction.Spread8);
+ }
+
+ var pathStart = points[0];
+ var pathEnd = points[^1];
+ var orderedPermittedSegments = Segments.All.ToImmutableArray();
+ var permittedSegments = orderedPermittedSegments.ToImmutableHashSet();
+
+ const int MaxCost = int.MaxValue;
+ var segmentTypeToId = new Dictionary();
+ var segmentsByStart = new List>();
+ var segmentsByEnd = new List>();
+ var costs = new List>();
+ {
+ void RegisterSegmentType(string type)
+ {
+ if (segmentTypeToId.ContainsKey(type))
+ return;
+ var newId = segmentTypeToId.Count;
+ segmentTypeToId.Add(type, newId);
+ segmentsByStart.Add(new List());
+ segmentsByEnd.Add(new List());
+ costs.Add(new Matrix(size).Fill(MaxCost));
+ }
+
+ foreach (var segment in orderedPermittedSegments)
+ {
+ var template = Segments.TemplatedTerrainInfo.SegmentsToTemplates[segment];
+ RegisterSegmentType(segment.Start);
+ RegisterSegmentType(segment.End);
+ 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 totalTypeIds = segmentTypeToId.Count;
+
+ var priorities = new PriorityArray