From 23ea2794bdf73aac02cda20dfa510a7ea69475ee Mon Sep 17 00:00:00 2001 From: Antony Titsas Date: Sat, 15 Aug 2026 08:10:46 +0200 Subject: [PATCH 1/2] Update package reference --- Fovero.Tests/Fovero.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Fovero.Tests/Fovero.Tests.csproj b/Fovero.Tests/Fovero.Tests.csproj index 1a5a058..d9eda37 100644 --- a/Fovero.Tests/Fovero.Tests.csproj +++ b/Fovero.Tests/Fovero.Tests.csproj @@ -10,7 +10,7 @@ - + From a91dc77dde17cd6d90453f0cf3667d0ab575d6ba Mon Sep 17 00:00:00 2001 From: Antony Titsas Date: Sat, 15 Aug 2026 08:11:00 +0200 Subject: [PATCH 2/2] Add Voronoi tiling --- Fovero.Model/Tiling/VoronoiTiling.cs | 280 +++++++++++++++++++++++ Fovero.Tests/TilingFixture.cs | 1 + Fovero/UI/Editors/EditorTemplates.xaml | 14 ++ Fovero/UI/Editors/VoronoiFormatEditor.cs | 34 +++ Fovero/UI/TilingViewModel.cs | 1 + 5 files changed, 330 insertions(+) create mode 100644 Fovero.Model/Tiling/VoronoiTiling.cs create mode 100644 Fovero/UI/Editors/VoronoiFormatEditor.cs diff --git a/Fovero.Model/Tiling/VoronoiTiling.cs b/Fovero.Model/Tiling/VoronoiTiling.cs new file mode 100644 index 0000000..ebf6c72 --- /dev/null +++ b/Fovero.Model/Tiling/VoronoiTiling.cs @@ -0,0 +1,280 @@ +using Fovero.Model.Geometry; + +namespace Fovero.Model.Tiling; + +public sealed class VoronoiTiling : ITiling +{ + private const float MaxJitter = 0.35f; + private const float Tolerance = 0.01f; + private const float NeighborTolerance = 0.25f; + + public VoronoiTiling(ushort columns, ushort rows, ushort variation = 1) + { + Columns = columns; + Rows = rows; + Variation = Math.Max((ushort)1, variation); + Bounds = new Rectangle(0, 0, Columns, Rows).ToScaledUnits(); + } + + public ushort Columns { get; } + + public ushort Rows { get; } + + public ushort Variation { get; } + + public Rectangle Bounds { get; } + + public IEnumerable Generate() + { + var sites = CreateSites().ToArray(); + var polygons = sites.Select(site => CreatePolygon(site, sites)).ToArray(); + + var tiles = polygons + .Select((polygon, n) => new VoronoiTile(sites[n].Ordinal, sites[n].Center, CreateBounds(polygon))) + .ToArray(); + + foreach (var (tile, polygon) in tiles.Zip(polygons)) + { + tile.SetEdges(CreateEdges(tile, polygon, tiles).ToArray()); + } + + return tiles; + } + + private IEnumerable CreateSites() + { + ushort ordinal = 0; + + for (var row = 0; row < Rows; row++) + { + for (var column = 0; column < Columns; column++) + { + yield return new Site(ordinal++, CreateCenter(column, row)); + } + } + } + + private Point2D CreateCenter(int column, int row) + { + var jitter = MaxJitter * Variation; + var x = (column + 0.5f + Jitter(column, row, 0x9E3779B9u) * jitter) * Scaling.Unit; + var y = (row + 0.5f + Jitter(column, row, 0x85EBCA77u) * jitter) * Scaling.Unit; + return new Point2D(x, y); + } + + private static float Jitter(int column, int row, uint salt) + { + var value = (uint)(column + 1) * 374761393u + (uint)(row + 1) * 668265263u + salt; + value = (value ^ (value >> 13)) * 1274126177u; + value ^= value >> 16; + return value / (float)uint.MaxValue - 0.5f; + } + + private List CreatePolygon(Site site, IReadOnlyList sites) + { + var polygon = new List + { + Bounds.TopLeft, + new(Bounds.Right, Bounds.Top), + Bounds.BottomRight, + new(Bounds.Left, Bounds.Bottom) + }; + + foreach (var other in sites) + { + if (other.Ordinal == site.Ordinal) + { + continue; + } + + polygon = ClipPolygon(polygon, site.Center, other.Center); + + if (polygon.Count < 3) + { + break; + } + } + + return polygon; + } + + private static List ClipPolygon(IReadOnlyList polygon, Point2D site, Point2D other) + { + var result = new List(); + + if (polygon.Count == 0) + { + return result; + } + + for (var index = 0; index < polygon.Count; index++) + { + var start = polygon[index]; + var end = polygon[(index + 1) % polygon.Count]; + var startInside = Evaluate(start, site, other) <= Tolerance; + var endInside = Evaluate(end, site, other) <= Tolerance; + + if (startInside && endInside) + { + AddPoint(result, end); + continue; + } + + if (startInside) + { + AddPoint(result, FindIntersection(start, end, site, other)); + continue; + } + + if (endInside) + { + AddPoint(result, FindIntersection(start, end, site, other)); + AddPoint(result, end); + } + } + + if (result.Count > 1 && AreClose(result[0], result[^1])) + { + result.RemoveAt(result.Count - 1); + } + + return result; + } + + private static float Evaluate(Point2D point, Point2D site, Point2D other) + { + var midpoint = site.MidPointTo(other); + var dx = other.X - site.X; + var dy = other.Y - site.Y; + return ((point.X - midpoint.X) * dx) + ((point.Y - midpoint.Y) * dy); + } + + private static Point2D FindIntersection(Point2D start, Point2D end, Point2D site, Point2D other) + { + var startValue = Evaluate(start, site, other); + var endValue = Evaluate(end, site, other); + var offset = startValue / (startValue - endValue); + return new Point2D(start.X + ((end.X - start.X) * offset), start.Y + ((end.Y - start.Y) * offset)); + } + + private static void AddPoint(ICollection polygon, Point2D point) + { + if (polygon.Count == 0) + { + polygon.Add(point); + return; + } + + if (polygon.Last() is var previous && !AreClose(previous, point)) + { + polygon.Add(point); + } + } + + private static bool AreClose(Point2D first, Point2D second) + { + return MathF.Abs(first.X - second.X) <= Tolerance && MathF.Abs(first.Y - second.Y) <= Tolerance; + } + + private static Rectangle CreateBounds(IReadOnlyList polygon) + { + var left = polygon[0].X; + var top = polygon[0].Y; + var right = polygon[0].X; + var bottom = polygon[0].Y; + + foreach (var point in polygon.Skip(1)) + { + left = MathF.Min(left, point.X); + top = MathF.Min(top, point.Y); + right = MathF.Max(right, point.X); + bottom = MathF.Max(bottom, point.Y); + } + + return new Rectangle(left, top, right - left, bottom - top); + } + + private IEnumerable CreateEdges(VoronoiTile tile, IReadOnlyList polygon, IReadOnlyList tiles) + { + for (var index = 0; index < polygon.Count; index++) + { + var start = polygon[index]; + var end = polygon[(index + 1) % polygon.Count]; + + if (IsBoundaryEdge(start, end)) + { + yield return Edge.CreateBorder(start, end, tile); + continue; + } + + var neighbor = SelectNeighbor(tile, start, end, tiles); + yield return neighbor is null + ? Edge.CreateBorder(start, end, tile) + : Edge.CreateShared(start, end, tile, neighbor); + } + } + + private bool IsBoundaryEdge(Point2D start, Point2D end) + { + return IsOn(start.X, Bounds.Left) && IsOn(end.X, Bounds.Left) + || IsOn(start.X, Bounds.Right) && IsOn(end.X, Bounds.Right) + || IsOn(start.Y, Bounds.Top) && IsOn(end.Y, Bounds.Top) + || IsOn(start.Y, Bounds.Bottom) && IsOn(end.Y, Bounds.Bottom); + } + + private static bool IsOn(float value, float expected) + { + return MathF.Abs(value - expected) <= Tolerance; + } + + private static VoronoiTile? SelectNeighbor(VoronoiTile tile, Point2D start, Point2D end, IReadOnlyList tiles) + { + var midpoint = start.MidPointTo(end); + var currentDistance = midpoint.SquaredDistanceTo(tile.Center); + VoronoiTile? neighbor = null; + var smallestDifference = float.MaxValue; + + foreach (var other in tiles) + { + if (other.Ordinal == tile.Ordinal) + { + continue; + } + + var difference = MathF.Abs(midpoint.SquaredDistanceTo(other.Center) - currentDistance); + + if (difference < smallestDifference) + { + smallestDifference = difference; + neighbor = other; + } + } + + return smallestDifference <= NeighborTolerance ? neighbor : null; + } + + private sealed record Site(ushort Ordinal, Point2D Center); + + private sealed class VoronoiTile(ushort ordinal, Point2D center, Rectangle bounds) : ITile + { + private IReadOnlyList _edges = []; + + public ushort Ordinal { get; } = ordinal; + + public Point2D Center { get; } = center; + + public Rectangle Bounds { get; } = bounds; + + public IEnumerable Edges => _edges; + + public void SetEdges(IReadOnlyList edges) + { + _edges = edges; + } + + public override string ToString() + { + return Ordinal.ToString(); + } + } +} diff --git a/Fovero.Tests/TilingFixture.cs b/Fovero.Tests/TilingFixture.cs index 398bf43..72e966f 100644 --- a/Fovero.Tests/TilingFixture.cs +++ b/Fovero.Tests/TilingFixture.cs @@ -56,6 +56,7 @@ public static IEnumerable Tilings yield return new TestCaseData(new DijonTiling(size, size)); yield return new TestCaseData(new PalazzoTiling(size, size)); yield return new TestCaseData(new LatticeTiling(size, size)); + yield return new TestCaseData(new VoronoiTiling(size, size)); yield return new TestCaseData(new SquareTiling(size, size)); yield return new TestCaseData(new HexagonalTiling(size, size)); yield return new TestCaseData(new QuarteredHexTiling(size, size)); diff --git a/Fovero/UI/Editors/EditorTemplates.xaml b/Fovero/UI/Editors/EditorTemplates.xaml index 5c7da57..7c77d74 100644 --- a/Fovero/UI/Editors/EditorTemplates.xaml +++ b/Fovero/UI/Editors/EditorTemplates.xaml @@ -49,6 +49,20 @@ + + + + + + + + + + + + + + diff --git a/Fovero/UI/Editors/VoronoiFormatEditor.cs b/Fovero/UI/Editors/VoronoiFormatEditor.cs new file mode 100644 index 0000000..f94bf9a --- /dev/null +++ b/Fovero/UI/Editors/VoronoiFormatEditor.cs @@ -0,0 +1,34 @@ +using Fovero.Model.Presentation; +using Fovero.Model.Tiling; + +namespace Fovero.UI.Editors; + +public sealed class VoronoiFormatEditor : FormatEditor +{ + public VoronoiFormatEditor() : base("Voronoi") + { + } + + public int Columns + { + get; + set => SetFormat(ref field, value); + } + + public int Rows + { + get; + set => SetFormat(ref field, value); + } + + public int Variation + { + get; + set => SetFormat(ref field, value); + } + + public override Maze CreateLayout() + { + return new Maze(new VoronoiTiling((ushort)Columns, (ushort)Rows, (ushort)Variation)); + } +} diff --git a/Fovero/UI/TilingViewModel.cs b/Fovero/UI/TilingViewModel.cs index 7a4acc3..d30cafd 100644 --- a/Fovero/UI/TilingViewModel.cs +++ b/Fovero/UI/TilingViewModel.cs @@ -33,6 +33,7 @@ public TilingViewModel() new DijonFormatEditor { Columns = 16, Rows = 10 }, new PalazzoFormatEditor { Columns = 16, Rows = 10 }, new LatticeFormatEditor { Columns = 16, Rows = 12 }, + new VoronoiFormatEditor { Columns = 20, Rows = 12 }, new RegularFormatEditor("Truncated Square Tile", (c, r) => new TruncatedSquareTiling(c, r)) { Columns = 17, Rows = 17 }, new RegularFormatEditor("Hexagonal", (c, r) => new HexagonalTiling(c, r)) { Columns = 23, Rows = 23 }, new RegularFormatEditor("Quartered Hex", (c, r) => new QuarteredHexTiling(c, r)) { Columns = 32, Rows = 20 },