using System.Collections.Concurrent;
using Clipper2Lib;
namespace OpenNest.Engine.Opus55;
///
/// Spacing-inflated footprints and the no-fit polygons between them, for one clearance value.
///
/// Every placed part owns a footprint: its outline grown by half the required clearance
/// (plus its own chord tolerance). Two parts respect the clearance exactly when their
/// footprints do not overlap, so the whole spacing rule reduces to NFP containment.
/// NFPs are translation-invariant, so each (orientation, orientation) pair is computed once
/// per job and reused by every sheet, stock trial and strategy variant.
///
internal sealed class NoFitCache
{
/// Clipper decimal precision; 1e-4 job units is far below any margin we keep.
public const int Precision = 4;
private readonly double halfClearance;
private readonly ConcurrentDictionary<(int, int), PathD> footprints = new();
private readonly ConcurrentDictionary<(int, int, int, int), Lazy> nfps = new();
public NoFitCache(double clearance)
{
halfClearance = clearance / 2;
}
public PathD Footprint(Orientation o) =>
footprints.GetOrAdd((o.TypeIndex, o.Index), _ => BuildFootprint(o));
/// NFP of around placed at the origin.
public Nfp Get(Orientation fixedPart, Orientation moving) =>
nfps.GetOrAdd(
(fixedPart.TypeIndex, fixedPart.Index, moving.TypeIndex, moving.Index),
_ => new Lazy(() => Build(fixedPart, moving), LazyThreadSafetyMode.ExecutionAndPublication)
)
.Value;
private PathD BuildFootprint(Orientation o)
{
// Miter joins (squared past the limit) always contain the exact round offset, so the
// footprint is a superset of "every point within the clearance of the outline".
var inflated = Clipper.InflatePaths(
new PathsD { o.Outline },
halfClearance + o.Tolerance,
JoinType.Miter,
EndType.Polygon,
2.0,
Precision,
0.0
);
var best = inflated.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).First();
if (!Clipper.IsPositive(best))
best.Reverse();
return best;
}
private Nfp Build(Orientation fixedPart, Orientation moving)
{
var a = Footprint(fixedPart);
var b = Footprint(moving);
var negB = new PathD(b.Count);
foreach (var p in b)
negB.Add(new PointD(-p.x, -p.y));
PathsD region;
if (IsConvex(a) && IsConvex(b))
{
region = new PathsD { ConvexSum(a, negB) };
}
else
{
// A (+) P, with P = -B: a reference point the boundary sweep misses puts the moving
// copy of B clear of A's boundary, so that copy is inside A, contains A, or misses it.
// (A + p0) covers "B inside A" and (P + a0) covers "B swallows A"; both are needed.
var sweep = Minkowski.Sum(negB, a, true, Precision);
sweep.Add(Clipper.TranslatePath(a, negB[0].x, negB[0].y));
sweep.Add(Clipper.TranslatePath(negB, a[0].x, a[0].y));
region = Clipper.Union(sweep, new PathsD(), FillRule.NonZero, Precision);
}
return new Nfp(region, Clipper.GetBounds(region));
}
/// Minkowski sum of two convex CCW polygons by merging edges in angle order.
private static PathD ConvexSum(PathD a, PathD b)
{
var ia = LowestIndex(a);
var ib = LowestIndex(b);
var result = new PathD(a.Count + b.Count);
var current = new PointD(a[ia].x + b[ib].x, a[ia].y + b[ib].y);
int i = 0, j = 0;
while (i < a.Count || j < b.Count)
{
result.Add(current);
var ea = i < a.Count ? Edge(a, ia + i) : default;
var eb = j < b.Count ? Edge(b, ib + j) : default;
// Both edge sequences start at the lowest vertex, so their angles rise through [0, 2pi).
double order;
if (i >= a.Count)
order = -1;
else if (j >= b.Count)
order = 1;
else
{
var difference = EdgeAngle(eb) - EdgeAngle(ea);
order = System.Math.Abs(difference) < 1e-12 ? 0 : difference;
}
if (order > 0)
{
current = new PointD(current.x + ea.x, current.y + ea.y);
i++;
}
else if (order < 0)
{
current = new PointD(current.x + eb.x, current.y + eb.y);
j++;
}
else
{
current = new PointD(current.x + ea.x + eb.x, current.y + ea.y + eb.y);
i++;
j++;
}
}
return result;
}
private static double EdgeAngle(PointD edge)
{
var angle = System.Math.Atan2(edge.y, edge.x);
return angle < 0 ? angle + System.Math.PI * 2 : angle;
}
private static PointD Edge(PathD path, int index)
{
var from = path[index % path.Count];
var to = path[(index + 1) % path.Count];
return new PointD(to.x - from.x, to.y - from.y);
}
/// Lowest (then leftmost) vertex: the start of a CCW edge sequence sorted by angle.
private static int LowestIndex(PathD path)
{
var best = 0;
for (var i = 1; i < path.Count; i++)
if (path[i].y < path[best].y || (path[i].y == path[best].y && path[i].x < path[best].x))
best = i;
return best;
}
private static bool IsConvex(PathD path)
{
var n = path.Count;
if (n < 3)
return false;
for (var i = 0; i < n; i++)
{
var a = path[i];
var b = path[(i + 1) % n];
var c = path[(i + 2) % n];
var cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
if (cross < -1e-12)
return false;
}
return true;
}
}
/// Forbidden reference-point region (interior = overlap, boundary = touching) and its bounds.
internal sealed record Nfp(PathsD Region, RectD Bounds);