diff --git a/src/Blocktavius.Core/AdditiveHillBuilder.cs b/src/Blocktavius.Core/AdditiveHillBuilder.cs index fe04aea..cf687ea 100644 --- a/src/Blocktavius.Core/AdditiveHillBuilder.cs +++ b/src/Blocktavius.Core/AdditiveHillBuilder.cs @@ -65,7 +65,7 @@ public I2DSampler BuildHill(Region region) cliffs.Add(BuildOutsideCorner(corner, cliffEW, cliffNS)); } - Rect fullBounds = Rect.Union([region.MaybeBetterBounds], cliffs.Select(s => s.Bounds)); + Rect fullBounds = Rect.Union([region.Bounds], cliffs.Select(s => s.Bounds)); var sampler = new MutableArray2D(fullBounds, new Elevation(-1)); foreach (var cliff in cliffs) @@ -120,11 +120,29 @@ private I2DSampler BuildOutsideCorner(Corner corner, EdgeCliff ewItem int cornerSize = Math.Max(ewItem.MainCliff.Bounds.Size.Z, nsItem.MainCliff.Bounds.Size.Z); var theSquare = new Rect(XZ.Zero, new XZ(cornerSize, cornerSize)); + // The meeting point is where edges originally met (and where the corner cliff goes) var meetingPoint = corner.MeetingPoint(); - // Determine which end of each edge this corner is at, and slice accordingly - bool ewAtStart = ewItem.Edge.Start == meetingPoint; - bool nsAtStart = nsItem.Edge.Start == meetingPoint; + // Edges may have been moved, so check against original meeting point with compensation + var ewEdge = ewItem.Edge; + var nsEdge = nsItem.Edge; + + // Determine which end of each edge is at the corner (accounting for movement) + var ewComp = ewEdge.InsideDirection switch + { + CardinalDirection.North => new XZ(0, 1), + CardinalDirection.West => new XZ(1, 0), + _ => XZ.Zero + }; + var nsComp = nsEdge.InsideDirection switch + { + CardinalDirection.North => new XZ(0, 1), + CardinalDirection.West => new XZ(1, 0), + _ => XZ.Zero + }; + + bool ewAtStart = ewEdge.Start.Add(ewComp) == meetingPoint; + bool nsAtStart = nsEdge.Start.Add(nsComp) == meetingPoint; bool ewLeft = ewItem.Edge.InsideDirection == CardinalDirection.East ? ewAtStart : !ewAtStart; bool nsLeft = nsItem.Edge.InsideDirection == CardinalDirection.North ? nsAtStart : !nsAtStart; @@ -168,9 +186,9 @@ private I2DSampler BuildOutsideCorner(Corner corner, EdgeCliff ewItem // Combine using min (lower elevation wins at outside corners) foreach (var xz in theSquare.Enumerate()) { - var elevEW = sliceEW.Sample(xz); - var elevNS = sliceNS.Sample(xz); - result.Put(xz, new Elevation(Math.Min(elevEW.Y, elevNS.Y))); + var elevEW = sliceEW.Sample(xz).Y; + var elevNS = sliceNS.Sample(xz).Y; + result.Put(xz, new Elevation(Math.Min(elevEW, elevNS))); } // Translate to final position @@ -200,18 +218,43 @@ private static I2DSampler Rotate(Edge edge, I2DSampler sampler) private static I2DSampler TransformMainCliff(EdgeCliff ec) { var (edge, mainCliff) = (ec.Edge, ec.MainCliff); + // Edges are now inside the region. Cliffs need to be placed 1 step outside (opposite to InsideDirection). + + // Step 1: Rotate the cliff to face the correct direction + // Step 2: Move it 1 step opposite to InsideDirection to place it outside + // Step 3: Adjust for the cliff's depth (rotated bounds may have shifted origin) + + XZ outsideOffset = edge.InsideDirection switch + { + CardinalDirection.North => new XZ(0, 1), // Move south (opposite of north) + CardinalDirection.South => new XZ(0, -1), // Move north (opposite of south) + CardinalDirection.East => new XZ(-1, 0), // Move west (opposite of east) + CardinalDirection.West => new XZ(1, 0), // Move east (opposite of west) + _ => throw new Exception($"Assert fail: {edge.InsideDirection}") + }; + switch (edge.InsideDirection) { - case CardinalDirection.North: - return mainCliff.TranslateTo(edge.Start); case CardinalDirection.South: - return mainCliff.Rotate(180) - .TranslateTo(edge.Start.Add(0, -mainCliff.Bounds.Size.Z)); + case CardinalDirection.North: + // For N/S edges, cliff extends in Z direction + // Inside=South (North edge): cliff extends north, needs 180° rotation and depth adjustment + // Inside=North (South edge): cliff extends south, no rotation needed + var rotation = edge.InsideDirection == CardinalDirection.South ? 180 : 0; + var zAdjust = rotation == 180 ? -(mainCliff.Bounds.Size.Z - 1) : 0; + var rotated = mainCliff.Rotate(rotation); + var target = edge.Start.Add(outsideOffset).Add(0, zAdjust); + return rotated.TranslateTo(target); case CardinalDirection.East: - return mainCliff.Rotate(90) - .TranslateTo(edge.Start.Add(-mainCliff.Bounds.Size.Z, 0)); case CardinalDirection.West: - return mainCliff.Rotate(270).TranslateTo(edge.Start); + // For E/W edges, cliff extends in X direction + // Inside=East (West edge): cliff extends west, needs 90° rotation and depth adjustment + // Inside=West (East edge): cliff extends east, needs 270° rotation + rotation = edge.InsideDirection == CardinalDirection.East ? 90 : 270; + var xAdjust = rotation == 90 ? -(mainCliff.Bounds.Size.Z - 1) : 0; + rotated = mainCliff.Rotate(rotation); + target = edge.Start.Add(outsideOffset).Add(xAdjust, 0); + return rotated.TranslateTo(target); default: throw new Exception($"Assert fail: {edge.InsideDirection}"); } diff --git a/src/Blocktavius.Core/Class1.cs b/src/Blocktavius.Core/Class1.cs index 0e6156a..51af376 100644 --- a/src/Blocktavius.Core/Class1.cs +++ b/src/Blocktavius.Core/Class1.cs @@ -130,9 +130,9 @@ private static Rect DoUnion(params IEnumerable[] seqs) public IEnumerable Enumerate() { - for (int x = start.X; x < end.X; x++) + for (int z = start.Z; z < end.Z; z++) { - for (int z = start.Z; z < end.Z; z++) + for (int x = start.X; x < end.X; x++) { yield return new XZ(x, z); } diff --git a/src/Blocktavius.Core/CliffBuilder.cs b/src/Blocktavius.Core/CliffBuilder.cs index 11690c8..4380197 100644 --- a/src/Blocktavius.Core/CliffBuilder.cs +++ b/src/Blocktavius.Core/CliffBuilder.cs @@ -33,7 +33,7 @@ interface ILayer private readonly PRNG prng; // TODO these should all be configurable: - const int minFenceLength = 1; + const int minFenceLength = 2; const int maxFenceLength = 8; const int maxNudge = 4; const int maxLaneCount = 5; diff --git a/src/Blocktavius.Core/Region2.cs b/src/Blocktavius.Core/Region2.cs new file mode 100644 index 0000000..16d0ef5 --- /dev/null +++ b/src/Blocktavius.Core/Region2.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; + +namespace Blocktavius.Core; + +sealed class Region2 +{ + sealed class EdgeInfo + { + public required XZ TileMin { get; init; } + public required XZ TileMax { get; init; } + public required Direction InsideDirection { get; init; } + + // Mutation! The following are computed after we find all the edges first. + // We only care about inside corners; outside corners just naturally work. + public EdgeInfo? InsideCornerMin { get; set; } // The edge with which TileMin forms an inside corner + public EdgeInfo? InsideCornerMax { get; set; } // The edge with which TileMax forms an inside corner + public EdgeInfo? OutsideCornerMin { get; set; } // The edge with which TileMin forms an outside corner + public EdgeInfo? OutsideCornerMax { get; set; } // The edge with which TileMax forms an outside corner + } + + I2DSampler tileGrid = null!; // TODO pass it in + List collectedEdges = new(); + + sealed class Check + { + public readonly HashSet Visited = new(); + public required Direction WalkDir { get; init; } + public required Direction InsideDir { get; init; } + } + + public void BuildRegion() + { + // First, find all the edges. + // We walk south and east, which matches the direction of the iteration. + // So as soon as we find the min (north or west) point of an edge, we immedately + // walk as far as we can go, build the complete edge, and add all the tiles involved + // to the hash set so we don't create shorter versions of the same edge in the future. + var checkW = new Check() { InsideDir = Direction.West, WalkDir = Direction.South }; + var checkE = new Check() { InsideDir = Direction.East, WalkDir = Direction.South }; + var checkN = new Check() { InsideDir = Direction.North, WalkDir = Direction.East }; + var checkS = new Check() { InsideDir = Direction.South, WalkDir = Direction.East }; + + foreach (var xz in tileGrid.Bounds.Enumerate()) + { + DoCheck(checkW, xz); + DoCheck(checkE, xz); + DoCheck(checkN, xz); + DoCheck(checkS, xz); + } + + // Second, find the inside corners. + // This method mutates the items in the list we pass here: + FindCorners(collectedEdges); + } + + private void DoCheck(Check check, XZ start) + { + var (visited, walkDir, insideDir) = (check.Visited, check.WalkDir, check.InsideDir); + var outsideStep = insideDir.Step.Scale(-1); + + if (visited.Contains(start)) + { + return; + } + + var tiles = start.Walk(walkDir, int.MaxValue) + .TakeWhile(xz => tileGrid.Sample(xz) && !tileGrid.Sample(xz.Add(outsideStep))) + .ToList(); + + if (tiles.Count > 0) + { + var edge = new EdgeInfo() + { + TileMin = tiles.First(), + TileMax = tiles.Last(), + InsideDirection = insideDir, + }; + collectedEdges.Add(edge); + foreach (var xz in tiles) + { + visited.Add(xz); + } + } + } + + private static void FindCorners(IReadOnlyList edges) + { + // We will check each vertical edge, looking for the possible horizontal partners it might have. + // This means we don't have to check horizontal edges explicitly. + var horizontalEdges = edges.Where(e => e.InsideDirection.Step.X == 0); + var verticalEdges = edges.Where(e => e.InsideDirection.Step.Z == 0); + + // The combination of (TileMin, InsideDirection) uniquely identifies an edge. + // The same is true of (TileMax, InsideDirection). + var minLookup = horizontalEdges.ToDictionary(e => (e.TileMin, e.InsideDirection)); + var maxLookup = horizontalEdges.ToDictionary(e => (e.TileMax, e.InsideDirection)); + + foreach (var vert in verticalEdges) + { + var top = vert.TileMin; + var bottom = vert.TileMax; + + if (vert.InsideDirection == Direction.West) + { + // Find inside corners, horizontal edges that start at vert.top or vert.bottom (adjusted) + if (minLookup.TryGetValue((top.Add(1, -1), Direction.North), out var topRight)) + { + vert.InsideCornerMin = topRight; + topRight.InsideCornerMin = vert; + } + if (minLookup.TryGetValue((bottom.Add(1, 1), Direction.South), out var bottomRight)) + { + vert.InsideCornerMax = bottomRight; + bottomRight.InsideCornerMin = vert; + } + + // Find outside corners, horizontal edges that start at vert.top or vert.bottom + if (maxLookup.TryGetValue((top, Direction.South), out var topLeft)) + { + vert.OutsideCornerMin = topLeft; + topLeft.OutsideCornerMax = vert; + } + if (maxLookup.TryGetValue((bottom, Direction.North), out var bottomLeft)) + { + vert.OutsideCornerMax = bottomLeft; + bottomLeft.OutsideCornerMax = vert; + } + } + if (vert.InsideDirection == Direction.East) + { + // Find inside corners, horiztonal edges that end at vert.top or vert.bottom (adjusted) + if (maxLookup.TryGetValue((top.Add(-1, -1), Direction.North), out var topLeft)) + { + vert.InsideCornerMin = topLeft; + topLeft.InsideCornerMax = vert; + } + if (maxLookup.TryGetValue((bottom.Add(-1, 1), Direction.South), out var bottomLeft)) + { + vert.InsideCornerMax = bottomLeft; + bottomLeft.InsideCornerMax = vert; + } + + // Find outside corners, horizontal edges that start at vert.top or vert.bottom + if (minLookup.TryGetValue((top, Direction.South), out var topRight)) + { + vert.OutsideCornerMin = topRight; + topRight.OutsideCornerMin = vert; + } + if (minLookup.TryGetValue((bottom, Direction.North), out var bottomRight)) + { + vert.OutsideCornerMax = bottomRight; + bottomRight.OutsideCornerMin = vert; + } + } + } + } +} diff --git a/src/Blocktavius.Core/TileTagger.cs b/src/Blocktavius.Core/TileTagger.cs index a90a984..4c12c64 100644 --- a/src/Blocktavius.Core/TileTagger.cs +++ b/src/Blocktavius.Core/TileTagger.cs @@ -15,6 +15,8 @@ public sealed record Edge public required int Length { get; init; } public XZ End => Start.Add(Direction.Parse(StepDirection).Step.Scale(Length)); + public XZ Max => Start.Add(Direction.Parse(StepDirection).Step.Scale(Length - 1)); + public XZ Min => Start; internal Edge Scale(XZ scale) { @@ -28,9 +30,25 @@ internal Edge Scale(XZ scale) lengthScale = scale.Z; } + var scaledStart = this.Start.Scale(scale); + + // Move South/East edges inside the region + // In unscaled space, South/East edges are positioned outside (exclusive) + // In scaled space, we want all edges inside (inclusive at the last row/column) + if (InsideDirection == CardinalDirection.North) + { + // South edge - move one step north to be inside + scaledStart = scaledStart.Add(0, -1); + } + else if (InsideDirection == CardinalDirection.West) + { + // East edge - move one step west to be inside + scaledStart = scaledStart.Add(-1, 0); + } + return new Edge() { - Start = this.Start.Scale(scale), + Start = scaledStart, InsideDirection = this.InsideDirection, StepDirection = this.StepDirection, Length = this.Length * lengthScale, @@ -53,15 +71,9 @@ public IEnumerable Walk() enum CornerType { Inside, Outside }; -sealed record Corner(Edge NorthOrSouthEdge, Edge EastOrWestEdge, CornerType CornerType) +sealed record Corner(Edge NorthOrSouthEdge, Edge EastOrWestEdge, CornerType CornerType, XZ OriginalMeetingPoint) { - public XZ MeetingPoint() => SameOrNull(NorthOrSouthEdge.Start, EastOrWestEdge.Start) - ?? SameOrNull(NorthOrSouthEdge.Start, EastOrWestEdge.End) - ?? SameOrNull(NorthOrSouthEdge.End, EastOrWestEdge.Start) - ?? SameOrNull(NorthOrSouthEdge.End, EastOrWestEdge.End) - ?? throw new Exception("assert fail"); - - private static XZ? SameOrNull(XZ a, XZ b) => a == b ? a : null; + public XZ MeetingPoint() => OriginalMeetingPoint; } public sealed record Region @@ -78,33 +90,37 @@ public Region(IReadOnlySet unscaledTiles, XZ scale) public required IReadOnlyList Edges { get; init; } public required Rect Bounds { get; init; } - /// - /// The bounds seem to be off by one? I think the tile tagger is to blame. - /// For example, if you have tileSize=5 and tag the (1,1) tile, the resulting - /// region bounds is Rect((5,5), (11,11)) which seems totally wrong... - /// Note that the method returns FALSE for all (x,10) and (10,z) - /// in that example so maybe it's simply that the bounds are off by one? - /// Anyway, until I figure that out, this property is a workaround. - /// - public Rect MaybeBetterBounds => Bounds with { end = Bounds.end.Add(-1, -1) }; - public bool Contains(XZ xz) => unscaledTiles.Contains(xz.Unscale(scale)); internal IReadOnlyList ComputeCorners() { - var startLookup = Edges.GroupBy(e => e.Start).ToDictionary(g => g.Key, g => g.ToList()); - var endLookup = Edges.GroupBy(e => e.End).ToDictionary(g => g.Key, g => g.ToList()); + // Helper to get original (pre-scale-adjustment) position for corner detection + XZ GetOriginalPos(Edge e, bool useEnd) + { + var pos = useEnd ? e.End : e.Start; + // South/East edges were moved -1 during scaling, compensate back to original + if (e.InsideDirection == CardinalDirection.North) + pos = pos.Add(0, 1); + else if (e.InsideDirection == CardinalDirection.West) + pos = pos.Add(1, 0); + return pos; + } + + var startLookup = Edges.GroupBy(e => GetOriginalPos(e, false)).ToDictionary(g => g.Key, g => g.ToList()); + var endLookup = Edges.GroupBy(e => GetOriginalPos(e, true)).ToDictionary(g => g.Key, g => g.ToList()); // add empty lists so we can always put Start or End into either dictionary safely foreach (var edge in Edges) { - if (!startLookup.ContainsKey(edge.End)) + var origEnd = GetOriginalPos(edge, true); + var origStart = GetOriginalPos(edge, false); + if (!startLookup.ContainsKey(origEnd)) { - startLookup[edge.End] = new List(); + startLookup[origEnd] = new List(); } - if (!endLookup.ContainsKey(edge.Start)) + if (!endLookup.ContainsKey(origStart)) { - endLookup[edge.Start] = new List(); + endLookup[origStart] = new List(); } } @@ -112,11 +128,14 @@ internal IReadOnlyList ComputeCorners() foreach (var edge in Edges) { - void maybeAdd(Edge? other, CornerType type) + var edgeOrigStart = GetOriginalPos(edge, false); + var edgeOrigEnd = GetOriginalPos(edge, true); + + void maybeAdd(Edge? other, XZ meetingPoint, CornerType type) { if (other != null) { - corners.Add(new Corner(edge, other, type)); + corners.Add(new Corner(edge, other, type, meetingPoint)); } } @@ -127,16 +146,16 @@ void maybeAdd(Edge? other, CornerType type) xxxx| ----O */ - var found = endLookup[edge.End].FirstOrDefault(e => e.InsideDirection == CardinalDirection.West); - maybeAdd(found, CornerType.Outside); + var found = endLookup[edgeOrigEnd].FirstOrDefault(e => e.InsideDirection == CardinalDirection.West); + maybeAdd(found, edgeOrigEnd, CornerType.Outside); /* |xxxx |xxxx O---- */ - found = endLookup[edge.Start].FirstOrDefault(x => x.InsideDirection == CardinalDirection.East); - maybeAdd(found, CornerType.Outside); + found = endLookup[edgeOrigStart].FirstOrDefault(x => x.InsideDirection == CardinalDirection.East); + maybeAdd(found, edgeOrigStart, CornerType.Outside); /* xxxxxx @@ -145,8 +164,8 @@ void maybeAdd(Edge? other, CornerType type) |xx |xx */ - found = startLookup[edge.End].FirstOrDefault(x => x.InsideDirection == CardinalDirection.East); - maybeAdd(found, CornerType.Inside); + found = startLookup[edgeOrigEnd].FirstOrDefault(x => x.InsideDirection == CardinalDirection.East); + maybeAdd(found, edgeOrigEnd, CornerType.Inside); /* xxxxxx @@ -155,8 +174,8 @@ void maybeAdd(Edge? other, CornerType type) xx| xx| */ - found = startLookup[edge.Start].FirstOrDefault(x => x.InsideDirection == CardinalDirection.West); - maybeAdd(found, CornerType.Inside); + found = startLookup[edgeOrigStart].FirstOrDefault(x => x.InsideDirection == CardinalDirection.West); + maybeAdd(found, edgeOrigStart, CornerType.Inside); } else if (edge.InsideDirection == CardinalDirection.South) { @@ -165,16 +184,16 @@ void maybeAdd(Edge? other, CornerType type) |xxxx |xxxx */ - var found = startLookup[edge.Start].FirstOrDefault(e => e.InsideDirection == CardinalDirection.East); - maybeAdd(found, CornerType.Outside); + var found = startLookup[edgeOrigStart].FirstOrDefault(e => e.InsideDirection == CardinalDirection.East); + maybeAdd(found, edgeOrigStart, CornerType.Outside); /* ----O xxxx| xxxx| */ - found = startLookup[edge.End].FirstOrDefault(e => e.InsideDirection == CardinalDirection.West); - maybeAdd(found, CornerType.Outside); + found = startLookup[edgeOrigEnd].FirstOrDefault(e => e.InsideDirection == CardinalDirection.West); + maybeAdd(found, edgeOrigEnd, CornerType.Outside); /* |xx @@ -183,8 +202,8 @@ void maybeAdd(Edge? other, CornerType type) xxxxxx xxxxxx */ - found = endLookup[edge.End].FirstOrDefault(e => e.InsideDirection == CardinalDirection.East); - maybeAdd(found, CornerType.Inside); + found = endLookup[edgeOrigEnd].FirstOrDefault(e => e.InsideDirection == CardinalDirection.East); + maybeAdd(found, edgeOrigEnd, CornerType.Inside); /* xx| @@ -193,8 +212,8 @@ void maybeAdd(Edge? other, CornerType type) xxxxxx xxxxxx */ - found = endLookup[edge.Start].FirstOrDefault(e => e.InsideDirection == CardinalDirection.West); - maybeAdd(found, CornerType.Inside); + found = endLookup[edgeOrigStart].FirstOrDefault(e => e.InsideDirection == CardinalDirection.West); + maybeAdd(found, edgeOrigStart, CornerType.Inside); } } @@ -297,7 +316,15 @@ private static Region BuildRegion(IReadOnlySet unscaledTiles, XZ scale) var scaledEdges = CombineEdges(unscaledSegments) .Select(edge => edge.Scale(scale)) .ToList(); - var scaledBounds = Rect.GetBounds(scaledEdges.Select(e => e.End).Concat(scaledEdges.Select(e => e.Start))); + + // All edges are now inside the region, so use Min/Max for inclusive bounds + var allPoints = scaledEdges.Select(e => e.Min).Concat(scaledEdges.Select(e => e.Max)); + int minX = allPoints.Min(p => p.X); + int minZ = allPoints.Min(p => p.Z); + int maxX = allPoints.Max(p => p.X); + int maxZ = allPoints.Max(p => p.Z); + // Rect end is exclusive, so add 1 to max values + var scaledBounds = new Rect(new XZ(minX, minZ), new XZ(maxX + 1, maxZ + 1)); return new Region(unscaledTiles, scale) { @@ -335,7 +362,7 @@ void TestAndAdd(Direction direction, Edge edge) Length = 1, }); - // South empty? Then go SW -> SE + // South empty? Then go SW -> SE (positioned outside in unscaled space) TestAndAdd(Direction.South, new Edge() { InsideDirection = CardinalDirection.North, @@ -353,7 +380,7 @@ void TestAndAdd(Direction direction, Edge edge) Length = 1, }); - // East empty? Then go NE -> SE + // East empty? Then go NE -> SE (positioned outside in unscaled space) TestAndAdd(Direction.East, new Edge() { InsideDirection = CardinalDirection.West, diff --git a/src/Blocktavius.Tests/PlainHillTests.cs b/src/Blocktavius.Tests/PlainHillTests.cs index aef7e8f..2809d4d 100644 --- a/src/Blocktavius.Tests/PlainHillTests.cs +++ b/src/Blocktavius.Tests/PlainHillTests.cs @@ -105,13 +105,7 @@ public void test_when_min_equals_max() var start = new XZ(scale, scale); var end = new XZ(scale * 2, scale * 2); - // This decision by the tile tagger is questionable... - const int YUCK = 1; - Assert.AreEqual(new Rect(start, end.Add(YUCK, YUCK)), region.Bounds, - "If this assertion fails, the Tile Tagger has likely been improved! You can now remove the workaround in the Additive Hill!"); - - // The additive hill works around the tile tagger's decision. - // This isn't very visible unless minElevation=maxElevation (which is what I wanted to test anyway). + Assert.AreEqual(new Rect(start, end), region.Bounds); const int elevation = 42; var settings = new PlainHill.Settings { diff --git a/src/Blocktavius.Tests/TileTaggerTests.cs b/src/Blocktavius.Tests/TileTaggerTests.cs new file mode 100644 index 0000000..76fae5c --- /dev/null +++ b/src/Blocktavius.Tests/TileTaggerTests.cs @@ -0,0 +1,291 @@ +using Blocktavius.Core; + +namespace Blocktavius.Tests; + +[TestClass] +public class TileTaggerTests +{ + [TestMethod] + public void single_tile_produces_four_edges() + { + var tagger = new TileTagger(unscaledSize: new XZ(3, 3), scale: new XZ(5, 5)); + tagger.AddTag(new XZ(1, 1), true); + var region = tagger.GetRegions(true).Single(); + + Assert.AreEqual(4, region.Edges.Count); + } + + [TestMethod] + public void single_tile_edges_are_inclusive() + { + var tagger = new TileTagger(unscaledSize: new XZ(3, 3), scale: new XZ(5, 5)); + tagger.AddTag(new XZ(1, 1), true); + var region = tagger.GetRegions(true).Single(); + + // Region should be (5,5) to (10,10) - a 5x5 square + Assert.AreEqual(new Rect(new XZ(5, 5), new XZ(10, 10)), region.Bounds); + + // All edges should be inside the region + foreach (var edge in region.Edges) + { + foreach (var point in edge.Walk()) + { + Assert.IsTrue(region.Contains(point), + $"Edge point {point} should be inside region bounds {region.Bounds}"); + } + } + } + + [TestMethod] + public void single_tile_edge_properties() + { + const int scale = 5; + var tagger = new TileTagger(unscaledSize: new XZ(3, 3), scale: new XZ(scale, scale)); + tagger.AddTag(new XZ(1, 1), true); + var region = tagger.GetRegions(true).Single(); + + // Find each edge by its inside direction + var northEdge = region.Edges.Single(e => e.InsideDirection == CardinalDirection.South); + var southEdge = region.Edges.Single(e => e.InsideDirection == CardinalDirection.North); + var westEdge = region.Edges.Single(e => e.InsideDirection == CardinalDirection.East); + var eastEdge = region.Edges.Single(e => e.InsideDirection == CardinalDirection.West); + + // North edge (top of region, inside=south) + Assert.AreEqual(new XZ(5, 5), northEdge.Start); + Assert.AreEqual(new XZ(10, 5), northEdge.End); + Assert.AreEqual(CardinalDirection.East, northEdge.StepDirection); + Assert.AreEqual(5, northEdge.Length); + + // South edge (bottom of region, inside=north) + Assert.AreEqual(new XZ(5, 9), southEdge.Start); + Assert.AreEqual(new XZ(10, 9), southEdge.End); + Assert.AreEqual(CardinalDirection.East, southEdge.StepDirection); + Assert.AreEqual(5, southEdge.Length); + + // West edge (left of region, inside=east) + Assert.AreEqual(new XZ(5, 5), westEdge.Start); + Assert.AreEqual(new XZ(5, 10), westEdge.End); + Assert.AreEqual(CardinalDirection.South, westEdge.StepDirection); + Assert.AreEqual(5, westEdge.Length); + + // East edge (right of region, inside=west) + Assert.AreEqual(new XZ(9, 5), eastEdge.Start); + Assert.AreEqual(new XZ(9, 10), eastEdge.End); + Assert.AreEqual(CardinalDirection.South, eastEdge.StepDirection); + Assert.AreEqual(5, eastEdge.Length); + } + + [TestMethod] + public void stepping_outside_from_edge_leaves_region() + { + var tagger = new TileTagger(unscaledSize: new XZ(3, 3), scale: new XZ(5, 5)); + tagger.AddTag(new XZ(1, 1), true); + var region = tagger.GetRegions(true).Single(); + + foreach (var edge in region.Edges) + { + var oppositeDirection = edge.InsideDirection switch + { + CardinalDirection.North => Direction.South, + CardinalDirection.South => Direction.North, + CardinalDirection.East => Direction.West, + CardinalDirection.West => Direction.East, + _ => throw new Exception("Invalid direction") + }; + + foreach (var point in edge.Walk()) + { + var outsidePoint = point.Step(oppositeDirection); + Assert.IsFalse(region.Contains(outsidePoint), + $"Point {outsidePoint} (one step {oppositeDirection} from edge point {point}) should be outside region"); + } + } + } + + [TestMethod] + public void two_horizontal_tiles_produce_shared_edges() + { + var tagger = new TileTagger(unscaledSize: new XZ(4, 3), scale: new XZ(5, 5)); + tagger.AddTag(new XZ(1, 1), true); + tagger.AddTag(new XZ(2, 1), true); + var region = tagger.GetRegions(true).Single(); + + // Should have 4 edges (shared internal edge gets combined) + Assert.AreEqual(4, region.Edges.Count); + + // North and south edges should span both tiles + var northEdge = region.Edges.Single(e => e.InsideDirection == CardinalDirection.South); + var southEdge = region.Edges.Single(e => e.InsideDirection == CardinalDirection.North); + + Assert.AreEqual(10, northEdge.Length, "North edge should span 2 tiles"); + Assert.AreEqual(10, southEdge.Length, "South edge should span 2 tiles"); + } + + [TestMethod] + public void two_vertical_tiles_produce_shared_edges() + { + var tagger = new TileTagger(unscaledSize: new XZ(3, 4), scale: new XZ(5, 5)); + tagger.AddTag(new XZ(1, 1), true); + tagger.AddTag(new XZ(1, 2), true); + var region = tagger.GetRegions(true).Single(); + + // Should have 4 edges (shared internal edge gets combined) + Assert.AreEqual(4, region.Edges.Count); + + // West and east edges should span both tiles + var westEdge = region.Edges.Single(e => e.InsideDirection == CardinalDirection.East); + var eastEdge = region.Edges.Single(e => e.InsideDirection == CardinalDirection.West); + + Assert.AreEqual(10, westEdge.Length, "West edge should span 2 tiles"); + Assert.AreEqual(10, eastEdge.Length, "East edge should span 2 tiles"); + } + + [TestMethod] + public void l_shape_produces_correct_edge_count() + { + var tagger = new TileTagger(unscaledSize: new XZ(4, 4), scale: new XZ(5, 5)); + // Create an L shape: + // X + // XX + tagger.AddTag(new XZ(1, 1), true); + tagger.AddTag(new XZ(1, 2), true); + tagger.AddTag(new XZ(2, 2), true); + var region = tagger.GetRegions(true).Single(); + + // L shape has 10 edge segments that combine into 6 edges + // (shared edges along west and south get combined) + Assert.AreEqual(6, region.Edges.Count); + + // Verify we have both inside and outside corners + var corners = region.ComputeCorners(); + var outsideCorners = corners.Where(c => c.CornerType == CornerType.Outside).ToList(); + var insideCorners = corners.Where(c => c.CornerType == CornerType.Inside).ToList(); + + Assert.IsTrue(outsideCorners.Count > 0, "L-shape should have outside corners"); + Assert.IsTrue(insideCorners.Count > 0, "L-shape should have inside corners"); + } + + [TestMethod] + public void edge_min_max_properties() + { + var tagger = new TileTagger(unscaledSize: new XZ(3, 3), scale: new XZ(5, 5)); + tagger.AddTag(new XZ(1, 1), true); + var region = tagger.GetRegions(true).Single(); + + foreach (var edge in region.Edges) + { + // Min should be the smaller coordinate + Assert.IsTrue(edge.Min.X <= edge.Max.X); + Assert.IsTrue(edge.Min.Z <= edge.Max.Z); + + // End should be one step beyond Max (exclusive) + if (edge.StepDirection == CardinalDirection.East || edge.StepDirection == CardinalDirection.West) + { + Assert.AreEqual(edge.Max.X + 1, edge.End.X); + } + else + { + Assert.AreEqual(edge.Max.Z + 1, edge.End.Z); + } + + // Length should match the distance + if (edge.StepDirection == CardinalDirection.East || edge.StepDirection == CardinalDirection.West) + { + Assert.AreEqual(edge.Length, edge.End.X - edge.Start.X); + } + else + { + Assert.AreEqual(edge.Length, edge.End.Z - edge.Start.Z); + } + } + } + + [TestMethod] + public void multiple_separate_regions() + { + var tagger = new TileTagger(unscaledSize: new XZ(5, 5), scale: new XZ(3, 3)); + // Create two separate regions: + // X...X + // ..... + // ..... + tagger.AddTag(new XZ(0, 0), true); + tagger.AddTag(new XZ(4, 0), true); + + var regions = tagger.GetRegions(true); + Assert.AreEqual(2, regions.Count); + + // Each region should be a single tile with 4 edges + foreach (var region in regions) + { + Assert.AreEqual(4, region.Edges.Count); + } + } + + [TestMethod] + public void edge_walk_covers_all_points() + { + var tagger = new TileTagger(unscaledSize: new XZ(3, 3), scale: new XZ(7, 7)); + tagger.AddTag(new XZ(1, 1), true); + var region = tagger.GetRegions(true).Single(); + + foreach (var edge in region.Edges) + { + var points = edge.Walk().ToList(); + + // Should have exactly Length points + Assert.AreEqual(edge.Length, points.Count); + + // First point should be Start + Assert.AreEqual(edge.Start, points[0]); + + // Last point should be Max (one before End) + Assert.AreEqual(edge.Max, points[^1]); + + // All points should be consecutive + for (int i = 1; i < points.Count; i++) + { + var step = points[i].Subtract(points[i - 1]); + var expectedStep = Direction.Parse(edge.StepDirection).Step; + Assert.AreEqual(expectedStep, step); + } + } + } + + [TestMethod] + public void l_shape_inside_corner_gap_analysis() + { + var tagger = new TileTagger(unscaledSize: new XZ(4, 4), scale: new XZ(5, 5)); + // Create an L shape: + // X + // XX + tagger.AddTag(new XZ(1, 1), true); + tagger.AddTag(new XZ(1, 2), true); + tagger.AddTag(new XZ(2, 2), true); + var region = tagger.GetRegions(true).Single(); + + // Find the edges that meet at the inside corner + // The inside corner is where tile (2,2) meets tiles (1,1) and (1,2) + // This happens at the original meeting point (10,10) + + // East edge of top tile (inside=West): should end near (10,10) + var eastEdge = region.Edges.Where(e => e.InsideDirection == CardinalDirection.West && e.Start.Z == 5).Single(); + + // North edge of bottom-right tile (inside=South): should start near (10,10) + var northEdge = region.Edges.Where(e => e.InsideDirection == CardinalDirection.South && e.Start.Z == 10).Single(); + + Console.WriteLine($"East edge: Start={eastEdge.Start}, Max={eastEdge.Max}, End={eastEdge.End}"); + Console.WriteLine($"North edge: Start={northEdge.Start}, Max={northEdge.Max}, End={northEdge.End}"); + + // The issue: East edge Max should connect to North edge Start, but there's a gap + // East edge Max: (9,9) + // North edge Start: (10,10) + Assert.AreEqual(new XZ(9, 9), eastEdge.Max); + Assert.AreEqual(new XZ(10, 10), northEdge.Start); + + // There's a 1-unit diagonal gap between them! + // This is the fundamental problem with inclusive edges at inside corners + var gap = northEdge.Start.Subtract(eastEdge.Max); + Console.WriteLine($"Gap between edges: {gap}"); + Assert.AreEqual(new XZ(1, 1), gap); // Diagonal gap of (1,1) + } +}