#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.CirclePacking;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Geometry;
namespace OpenNest.Engine.NestingEngines.Circles;
/// A requested part packed as its enclosing circle at one allowed rotation.
internal sealed record DiscType(int Index, NestJobPart Part, Item Item, double Rotation, double MaterialArea)
{
public string Id => Part.Id;
public int Priority => Part.Priority;
public double Radius => Item.Radius;
}
/// Which way a sheet fills: rows from the bottom edge, or columns from the left edge.
internal enum DiscOrder
{
BottomLeft,
LeftBottom,
}
/// The lattice one type is tiled in before the greedy pass, if any. Staggered rows are
/// densest on a large sheet; a square grid wins when only a few discs span the sheet.
internal enum DiscSeed
{
None,
Hexagonal,
Square,
}
///
/// Packs discs onto one sheet. Each disc goes to the best feasible center tangent to two of
/// {work-area walls, placed discs, clamp keep-outs}, the exact candidate set for circles, so no
/// no-fit polygons are needed. Discs go lower priority number first, then largest first, so small
/// discs land in the gaps between large ones. An optional hexagonal or square lattice of one type
/// seeds the sheet before the greedy pass fills the rest.
///
/// Every disc keeps the part spacing plus from every other disc, and
/// its radius from the work-area edges and from every clamp zone grown by the bottom-edge
/// clearance. Radii are , which covers the layout check's outward flattening,
/// so packing is never more lenient than the check. Deterministic.
internal static class DiscPacker
{
/// Extra gap beyond the spacing between discs, as in .
public const double Margin = CutoutCirclePacker.Margin;
private const double Epsilon = 1e-9;
public static SheetLayout Pack(IReadOnlyList types, IReadOnlyList remaining,
NestPlateStock stock, DiscOrder order, DiscSeed seed, CancellationToken token)
{
var left = remaining.ToArray();
var fits = types.Where(t => left[t.Index] > 0 && Fits(stock, t)).ToList();
var sheet = new Sheet(stock, types.Where(t => Fits(stock, t)).Select(t => t.Radius).DefaultIfEmpty(0).Max(), order);
if (seed != DiscSeed.None && LatticeType(fits, left) is { } lattice)
{
foreach (var center in Lattice(stock.WorkArea, lattice.Radius, sheet.Gap, order, seed))
{
token.ThrowIfCancellationRequested();
if (left[lattice.Index] == 0)
break;
if (!sheet.Feasible(center, lattice.Radius))
continue;
sheet.Place(lattice, center);
left[lattice.Index]--;
}
}
// Stable: equal radii keep requirement order.
foreach (var type in fits.OrderBy(t => t.Priority).ThenByDescending(t => t.Radius))
{
while (left[type.Index] > 0)
{
token.ThrowIfCancellationRequested();
// Space only shrinks, so a copy that finds no center leaves none for later copies.
if (sheet.Best(type.Radius) is not { } center)
break;
sheet.Place(type, center);
left[type.Index]--;
}
}
return sheet.Layout(types);
}
/// True when the disc fits the work area in both directions.
public static bool Fits(NestPlateStock stock, DiscType type)
{
var work = stock.WorkArea;
return 2 * type.Radius <= work.Right - work.Left + Epsilon
&& 2 * type.Radius <= work.Top - work.Bottom + Epsilon;
}
/// The top-priority type with the most remaining area, when it has copies to tile.
private static DiscType? LatticeType(IReadOnlyList fits, int[] left)
{
if (fits.Count == 0)
return null;
var top = fits.Min(t => t.Priority);
var best = fits.Where(t => t.Priority == top)
.OrderByDescending(t => left[t.Index] * t.Radius * t.Radius)
.First();
return left[best.Index] >= 2 ? best : null;
}
/// Lattice centers in fill order: rows along X for bottom-left, columns along Y for
/// left-bottom. Neighbors sit exactly one pitch apart.
private static IEnumerable Lattice(Box work, double r, double gap, DiscOrder order, DiscSeed seed)
{
var pitch = 2 * r + gap;
var hexagonal = seed == DiscSeed.Hexagonal;
var step = hexagonal ? pitch * System.Math.Sqrt(3) / 2 : pitch;
var rows = order == DiscOrder.BottomLeft;
var (along0, along1) = rows ? (work.Left + r, work.Right - r) : (work.Bottom + r, work.Top - r);
var (across0, across1) = rows ? (work.Bottom + r, work.Top - r) : (work.Left + r, work.Right - r);
for (var line = 0; across0 + line * step <= across1 + Epsilon; line++)
{
var across = across0 + line * step;
var shift = hexagonal && line % 2 == 1 ? pitch / 2 : 0;
for (var along = along0 + shift; along <= along1 + Epsilon; along += pitch)
yield return rows ? new Vector(along, across) : new Vector(across, along);
}
}
private sealed class Sheet
{
private readonly NestPlateStock stock;
private readonly Box work;
private readonly Box[] clamps;
private readonly double cell;
private readonly DiscOrder order;
private readonly Dictionary<(long, long), List> grid = new();
private readonly List<(DiscType Type, Vector Center)> placed = new();
// A disc with no feasible tangent center for radius r has none for any larger radius:
// shrinking a feasible larger disc toward the same tangent point keeps it feasible.
private readonly List deadFrom = new();
public Sheet(NestPlateStock stock, double maxRadius, DiscOrder order)
{
this.stock = stock;
work = stock.WorkArea;
// The layout check grows each zone by the bottom-edge clearance as a box, square
// corners included, so discs keep their radius from that grown box.
var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom);
clamps = stock.ClampZones.Select(z => z.Bounds.Offset(clearance)).ToArray();
Gap = stock.PartSpacing + Margin;
// Any disc within reach of a new one lies in the adjacent cells.
cell = System.Math.Max(2 * maxRadius + Gap, 1e-6);
this.order = order;
}
public double Gap { get; }
public void Place(DiscType type, Vector center)
{
var key = Key(center);
if (!grid.TryGetValue(key, out var list))
grid[key] = list = new List();
list.Add(placed.Count);
placed.Add((type, center));
deadFrom.Add(double.PositiveInfinity);
}
public bool Feasible(Vector p, double r)
{
if (p.X < work.Left + r - Epsilon || p.X > work.Right - r + Epsilon
|| p.Y < work.Bottom + r - Epsilon || p.Y > work.Top - r + Epsilon)
return false;
foreach (var zone in clamps)
if (Distance(p, zone) < r - Epsilon)
return false;
foreach (var j in Near(p, 1))
if (placed[j].Center.DistanceTo(p) < placed[j].Type.Radius + r + Gap - Epsilon)
return false;
return true;
}
/// The best feasible center for a disc of radius , or null.
public Vector? Best(double r)
{
var vertical = new List { work.Left + r, work.Right - r };
var horizontal = new List { work.Bottom + r, work.Top - r };
var corners = new List();
foreach (var zone in clamps)
{
vertical.Add(zone.Left - r);
vertical.Add(zone.Right + r);
horizontal.Add(zone.Bottom - r);
horizontal.Add(zone.Top + r);
corners.Add(new Circle(new Vector(zone.Left, zone.Bottom), r));
corners.Add(new Circle(new Vector(zone.Right, zone.Bottom), r));
corners.Add(new Circle(new Vector(zone.Left, zone.Top), r));
corners.Add(new Circle(new Vector(zone.Right, zone.Top), r));
}
Vector? best = null;
void Consider(Vector p)
{
if ((best is not { } current || Better(p, current)) && Feasible(p, r))
best = p;
}
foreach (var x in vertical)
foreach (var y in horizontal)
Consider(new Vector(x, y));
for (var a = 0; a < corners.Count; a++)
{
foreach (var p in Lines(corners[a], vertical, horizontal))
Consider(p);
for (var b = a + 1; b < corners.Count; b++)
foreach (var p in Intersections(corners[a], corners[b]))
Consider(p);
}
for (var i = 0; i < placed.Count; i++)
{
if (r >= deadFrom[i] - Epsilon)
continue;
var around = new Circle(placed[i].Center, placed[i].Type.Radius + r + Gap);
var generated = false;
var feasible = false;
void Tangent(Vector p)
{
generated = true;
if (!Feasible(p, r))
return;
feasible = true;
if (best is not { } current || Better(p, current))
best = p;
}
foreach (var p in Lines(around, vertical, horizontal))
Tangent(p);
foreach (var corner in corners)
foreach (var p in Intersections(around, corner))
Tangent(p);
foreach (var j in Near(around.Center, 2))
{
if (j <= i || r >= deadFrom[j] - Epsilon)
continue;
foreach (var p in Intersections(around,
new Circle(placed[j].Center, placed[j].Type.Radius + r + Gap)))
Tangent(p);
}
// A tangent circle that crosses nothing is either wholly free or wholly blocked;
// only one that crosses constraints yet keeps no feasible point is surely closed.
if (generated && !feasible)
deadFrom[i] = r;
}
return best;
}
public SheetLayout Layout(IReadOnlyList types)
{
var counts = new int[types.Count];
var poses = new List<(string PartId, double X, double Y, double Rotation)>(placed.Count);
Box? envelope = null;
foreach (var (type, center) in placed)
{
counts[type.Index]++;
var origin = center + Rotate(type.Item.PartOffset, type.Rotation);
poses.Add((type.Id, origin.X, origin.Y, type.Rotation));
var box = new Box(center.X - type.Radius, center.Y - type.Radius, 2 * type.Radius, 2 * type.Radius);
envelope = envelope == null ? box : Union(envelope, box);
}
// Summed per type in type order, the form of the stock loop's demand area.
var area = types.Sum(t => counts[t.Index] * t.MaterialArea);
return new SheetLayout(stock, poses, area, envelope);
}
private bool Better(Vector candidate, Vector current)
{
var (a, b) = order == DiscOrder.BottomLeft
? (candidate.Y - current.Y, candidate.X - current.X)
: (candidate.X - current.X, candidate.Y - current.Y);
if (System.Math.Abs(a) > Epsilon)
return a < 0;
return b < -Epsilon;
}
private (long, long) Key(Vector p) =>
((long)System.Math.Floor((p.X - work.Left) / cell), (long)System.Math.Floor((p.Y - work.Bottom) / cell));
private IEnumerable Near(Vector p, int cells)
{
var (kx, ky) = Key(p);
for (var dx = -cells; dx <= cells; dx++)
for (var dy = -cells; dy <= cells; dy++)
if (grid.TryGetValue((kx + dx, ky + dy), out var list))
foreach (var j in list)
yield return j;
}
private static double Distance(Vector p, Box box)
{
var dx = System.Math.Max(0, System.Math.Max(box.Left - p.X, p.X - box.Right));
var dy = System.Math.Max(0, System.Math.Max(box.Bottom - p.Y, p.Y - box.Top));
return System.Math.Sqrt(dx * dx + dy * dy);
}
}
private static IEnumerable Lines(Circle circle, List vertical, List horizontal)
{
var c = circle.Center;
var r = circle.Radius;
foreach (var x in vertical)
{
var d = x - c.X;
if (System.Math.Abs(d) > r)
continue;
var h = System.Math.Sqrt(System.Math.Max(0, r * r - d * d));
yield return new Vector(x, c.Y - h);
if (h > Epsilon)
yield return new Vector(x, c.Y + h);
}
foreach (var y in horizontal)
{
var d = y - c.Y;
if (System.Math.Abs(d) > r)
continue;
var h = System.Math.Sqrt(System.Math.Max(0, r * r - d * d));
yield return new Vector(c.X - h, y);
if (h > Epsilon)
yield return new Vector(c.X + h, y);
}
}
private static IEnumerable Intersections(Circle a, Circle b)
{
var d = a.Center.DistanceTo(b.Center);
if (d < Epsilon || d > a.Radius + b.Radius || d < System.Math.Abs(a.Radius - b.Radius))
yield break;
var along = (a.Radius * a.Radius - b.Radius * b.Radius + d * d) / (2 * d);
var across = System.Math.Sqrt(System.Math.Max(0, a.Radius * a.Radius - along * along));
var ux = (b.Center.X - a.Center.X) / d;
var uy = (b.Center.Y - a.Center.Y) / d;
var mx = a.Center.X + ux * along;
var my = a.Center.Y + uy * along;
yield return new Vector(mx - uy * across, my + ux * across);
if (across > Epsilon)
yield return new Vector(mx + uy * across, my - ux * across);
}
private static Vector Rotate(Vector v, double angle)
{
var sine = System.Math.Sin(angle);
var cosine = System.Math.Cos(angle);
return new Vector(v.X * cosine - v.Y * sine, v.X * sine + v.Y * cosine);
}
private static Box Union(Box a, Box b)
{
var l = System.Math.Min(a.Left, b.Left);
var bo = System.Math.Min(a.Bottom, b.Bottom);
var r = System.Math.Max(a.Right, b.Right);
var t = System.Math.Max(a.Top, b.Top);
return new Box(l, bo, r - l, t - bo);
}
}