Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 280 additions & 0 deletions Fovero.Model/Tiling/VoronoiTiling.cs
Original file line number Diff line number Diff line change
@@ -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<ITile> 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<Site> 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<Point2D> CreatePolygon(Site site, IReadOnlyList<Site> sites)
{
var polygon = new List<Point2D>
{
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<Point2D> ClipPolygon(IReadOnlyList<Point2D> polygon, Point2D site, Point2D other)
{
var result = new List<Point2D>();

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<Point2D> 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<Point2D> 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<IEdge> CreateEdges(VoronoiTile tile, IReadOnlyList<Point2D> polygon, IReadOnlyList<VoronoiTile> 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<VoronoiTile> 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<IEdge> _edges = [];

public ushort Ordinal { get; } = ordinal;

public Point2D Center { get; } = center;

public Rectangle Bounds { get; } = bounds;

public IEnumerable<IEdge> Edges => _edges;

public void SetEdges(IReadOnlyList<IEdge> edges)
{
_edges = edges;
}

public override string ToString()
{
return Ordinal.ToString();
}
}
}
2 changes: 1 addition & 1 deletion Fovero.Tests/Fovero.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.14.0">
Expand Down
1 change: 1 addition & 0 deletions Fovero.Tests/TilingFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
14 changes: 14 additions & 0 deletions Fovero/UI/Editors/EditorTemplates.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@
</StackPanel>
</DataTemplate>

<DataTemplate DataType="{x:Type editors:VoronoiFormatEditor}">
<StackPanel Orientation="Vertical">
<HeaderedContentControl Header="Columns" HorizontalContentAlignment="Left">
<mah:NumericUpDown Value="{Binding Columns, Delay=200}" Minimum="2" Maximum="40" MaxWidth="80" />
</HeaderedContentControl>
<HeaderedContentControl Header="Rows" HorizontalContentAlignment="Left">
<mah:NumericUpDown Value="{Binding Rows, Delay=200}" Minimum="2" Maximum="40" MaxWidth="80" />
</HeaderedContentControl>
<HeaderedContentControl Header="Variation" HorizontalContentAlignment="Left">
<mah:NumericUpDown Value="{Binding Variation, Delay=200}" Minimum="1" Maximum="4" MaxWidth="80" />
</HeaderedContentControl>
</StackPanel>
</DataTemplate>

<DataTemplate DataType="{x:Type editors:CircularFormatEditor}">
<StackPanel Orientation="Vertical">
<HeaderedContentControl Header="Rings" HorizontalContentAlignment="Left">
Expand Down
34 changes: 34 additions & 0 deletions Fovero/UI/Editors/VoronoiFormatEditor.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
1 change: 1 addition & 0 deletions Fovero/UI/TilingViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down