DiamondOutline.cs
using System;
using System.Collections.Generic;
using static Diamonds.DiamondBoard;

namespace Diamonds;

/// <summary>Exposed perimeter edges of actual bonded groups, in board coordinates.</summary>
public static class DiamondOutline
{
	public readonly record struct Edge( float X1, float Y1, float X2, float Y2, int ColorIndex );

	/// <summary>Whether a traced perimeter can be drawn as a closed polygon.</summary>
	public static bool IsClosed( IReadOnlyList<Edge> contour ) => contour.Count >= 3
		&& MathF.Abs( contour[^1].X2 - contour[0].X1 ) <= 0.01f
		&& MathF.Abs( contour[^1].Y2 - contour[0].Y1 ) <= 0.01f;

	/// <summary>Order one group's perimeter into contours, including holes.
	/// Animated positions can leave open chains; use IsClosed before closing a path.
	/// At a shared tip, follow the rightmost turn so touching loops stay separate.</summary>
	public static List<List<Edge>> Contours( IReadOnlyList<Edge> edges )
	{
		var contours = new List<List<Edge>>();
		var used = new bool[edges.Count];
		for ( int start = 0; start < edges.Count; start++ )
		{
			if ( used[start] ) continue;
			var contour = new List<Edge>();
			int current = start;
			while ( current >= 0 && !used[current] )
			{
				var edge = edges[current];
				used[current] = true;
				contour.Add( edge );
				int next = -1;
				float bestTurn = float.NegativeInfinity;
				for ( int candidate = 0; candidate < edges.Count; candidate++ )
				{
					var other = edges[candidate];
					if ( (used[candidate] && candidate != start) || other.ColorIndex != edge.ColorIndex ||
						MathF.Abs( other.X1 - edge.X2 ) > 0.01f || MathF.Abs( other.Y1 - edge.Y2 ) > 0.01f ) continue;
					float dx = edge.X2 - edge.X1, dy = edge.Y2 - edge.Y1;
					float ox = other.X2 - other.X1, oy = other.Y2 - other.Y1;
					float turn = MathF.Atan2( dx * oy - dy * ox, dx * ox + dy * oy );
					if ( turn > bestTurn ) { next = candidate; bestTurn = turn; }
				}
				current = next;
			}
			contours.Add( contour );
		}
		return contours;
	}

	public static List<Edge> Build( IReadOnlyList<Diamond> cells, IReadOnlyList<IReadOnlyList<int>> groups )
	{
		var edges = new List<Edge>();
		foreach ( var group in groups )
		foreach ( int index in group )
		{
			var cell = cells[index];
			int shared = 0;
			foreach ( int otherIndex in group )
			{
				if ( index == otherIndex ) continue;
				var other = cells[otherIndex];
				if ( cell.ColorIndex != other.ColorIndex || !DiamondMatching.Touching( cell, other ) ) continue;
				int side = other.Lane > cell.Lane ? (other.Y < cell.Y ? 0 : 1) : (other.Y > cell.Y ? 2 : 3);
				shared |= 1 << side;
			}
			float x = LaneX( cell.Lane ), y = cell.Y;
			float w = Width * 0.5f, h = Height * 0.5f;
			if ( (shared & 1) == 0 ) edges.Add( new( x, y - h, x + w, y, cell.ColorIndex ) );
			if ( (shared & 2) == 0 ) edges.Add( new( x + w, y, x, y + h, cell.ColorIndex ) );
			if ( (shared & 4) == 0 ) edges.Add( new( x, y + h, x - w, y, cell.ColorIndex ) );
			if ( (shared & 8) == 0 ) edges.Add( new( x - w, y, x, y - h, cell.ColorIndex ) );
		}
		return edges;
	}
}