#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Engine.RectanglePacking;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Jobs.Cutouts;
///
/// Frame-local proposal for a rectangular cutout, solved the way a person would: a small knapsack
/// over which inserts to take, packed as boxes. A counted beam search over maximal free rectangles
/// picks insert copies at their minimum-area rotation and its quarter turn, plus a part and its half
/// turn slid together when that box is clearly denser, to maximize seated outline area.
/// then drops the remaining demand into what is left. Every pose
/// is certified with . Deterministic; never changes a job.
///
internal static class CutoutBoxProposal
{
private const double MaxSpacing = 100;
/// Hole area over bounding-box area below which a hole is not a rectangle.
private const double MinRectangularity = 0.97;
/// Inscribed rectangle area over hole area below which a notch has ruined it.
private const double MinInscribed = 0.8;
/// Points per edge when finding what intrudes on the rectangle; catches a chamfer.
private const int EdgeSamples = 16;
private const double InsideTolerance = 1e-9;
private const int BeamWidth = 150;
/// Search states expanded per pass: the bound on the search, independent of time.
private const int NodesPerPass = 6000;
/// Best distinct insert selections certified and gap-filled.
private const int PlansTried = 3;
/// A part denser than this in its box is not worth pairing.
private const double MaxPairedDensity = 0.8;
private const double MinPairGain = 0.05;
private const int SlideSteps = 12;
/// One way of searching: anchor order, and how much of the free space the ranking
/// expects to fill. A lean guess favors dense early choices, a full one keeps options open.
private static readonly (bool Columns, double Density)[] Passes =
{
(false, 0.85), (true, 0.85), (false, 0.6), (true, 0.6),
};
/// A box the search can place: one copy or a pair. Width and Height carry the part
/// spacing; each member's pose is relative to the box's lower-left corner.
private sealed record Item(int Type, int Copies, double Width, double Height, double Area,
(double X, double Y, double Rotation)[] Members);
private sealed record State(IReadOnlyList Free, int[] Used, double Seated, double Rank,
State? Parent, Item? Item, double X, double Y);
/// Poses in the frame's own coordinates clearing the frame and each other; empty when
/// the hole is not a rectangle. Instance indices count per requirement.
internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList inserts, double spacing, CutoutMemo? memo = null, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(inserts);
token.ThrowIfCancellationRequested();
if (inserts.Count == 0 || !double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing)
return Array.Empty();
var frameGeometry = memo?.Read(frame.Geometry) ?? JobPartGeometry.TryRead(frame.Geometry);
if (frameGeometry == null || !CutoutCoordinates.WithinRange(frameGeometry.Bounds))
return Array.Empty();
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
var cutout = frameGeometry.Cutouts[cutoutIndex];
if (!cutout.IsClosed() || !CutoutCoordinates.WithinRange(cutout.BoundingBox))
return Array.Empty();
var hole = ClipperBridge.ToPath(
ClipperBridge.Flatten(cutout, CutoutLatticeFill.FlattenTolerance, circumscribe: false), new Vector());
if (InscribedRectangle(hole) is not { } rectangle || spacing * 2 >= System.Math.Min(rectangle.W, rectangle.H))
return Array.Empty();
var geometries = new Dictionary(StringComparer.Ordinal);
foreach (var insert in inserts)
{
var geometry = memo?.Read(insert.Geometry) ?? JobPartGeometry.TryRead(insert.Geometry);
if (geometry == null || !CutoutCoordinates.WithinRange(geometry.Bounds))
return Array.Empty();
geometries[insert.Id] = geometry;
}
// Boxes grow by the spacing on their right and top, and the bin by one spacing: touching
// boxes are one spacing apart and the last may reach the wall's inset, as on a sheet.
var binWidth = rectangle.W - spacing;
var binHeight = rectangle.H - spacing;
var stock = new NestPlateStock("cutout", new Size(rectangle.H, rectangle.W), 1, spacing);
var types = BoxCatalog.Build(new NestJob(inserts, new[] { stock }));
var items = Items(types, geometries, spacing, binWidth, binHeight, memo, token);
if (items.Count == 0)
return Array.Empty();
var demand = inserts.Select(p => p.Quantity).ToArray();
var plans = new Dictionary();
foreach (var (columns, density) in Passes)
Search(items, demand, binWidth, binHeight, columns, density, plans, token);
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
var preparedFrame = NestLayoutCheck.Prepare(frameGeometry, framePose, spacing);
List? best = null;
var bestArea = 0.0;
foreach (var plan in plans.Values.OrderByDescending(p => p.Seated).Take(PlansTried))
{
token.ThrowIfCancellationRequested();
var poses = new List();
foreach (var (item, x, y) in Placements(plan))
{
var kept = new List();
foreach (var (dx, dy, rotation) in item.Members)
{
var part = inserts[item.Type];
var pose = new NestJobPlacement(part.Id, 0,
System.Math.Round(rectangle.X + spacing + x + dx, 8),
System.Math.Round(rectangle.Y + spacing + y + dy, 8), rotation);
if (Clears(pose, geometries, preparedFrame, poses.Concat(kept), spacing, memo, token))
kept.Add(pose);
}
poses.AddRange(kept);
}
if (poses.Count == 0)
continue;
poses.AddRange(GapFill(frame, cutoutIndex, inserts, poses, geometries, rectangle, spacing, memo, token));
var area = OutlineArea(poses, id => geometries[id]);
if (area > bestArea)
{
best = poses;
bestArea = area;
}
}
if (best == null)
return Array.Empty();
var counts = new Dictionary(StringComparer.Ordinal);
return best.Select(p =>
{
var index = counts.GetValueOrDefault(p.PartId);
counts[p.PartId] = index + 1;
return p with { InstanceIndex = index };
}).ToArray();
}
/// Outline area of the placed parts, the measure this proposal maximizes.
internal static double OutlineArea(IEnumerable poses, Func geometry) =>
poses.Sum(p => System.Math.Abs(geometry(p.PartId).Perimeter.Area()));
/// The axis-aligned rectangle a nearly rectangular hole holds, or null: starts from
/// the bounds and moves a side past whatever intrudes deepest, so rounded corners cost a side
/// about 0.3 of their radius. It may still clip a corner by a chord; certification decides.
internal static Rect? InscribedRectangle(PathD hole)
{
if (hole.Count < 3)
return null;
var left = hole.Min(p => p.x);
var right = hole.Max(p => p.x);
var bottom = hole.Min(p => p.y);
var top = hole.Max(p => p.y);
var bounds = (right - left) * (top - bottom);
var area = System.Math.Abs(Clipper.Area(hole));
if (!(bounds > 0) || area < MinRectangularity * bounds)
return null;
var points = new List(hole.Count * EdgeSamples);
for (var i = 0; i < hole.Count; i++)
{
var a = hole[i];
var b = hole[(i + 1) % hole.Count];
for (var s = 0; s < EdgeSamples; s++)
{
var t = s / (double)EdgeSamples;
points.Add(new PointD(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t));
}
}
for (var moves = 0; moves < points.Count; moves++)
{
var depth = InsideTolerance;
PointD? deepest = null;
foreach (var p in points)
{
var d = System.Math.Min(System.Math.Min(p.x - left, right - p.x),
System.Math.Min(p.y - bottom, top - p.y));
if (d > depth)
{
depth = d;
deepest = p;
}
}
if (deepest is not { } q)
break;
if (q.x - left == depth)
left = q.x;
else if (right - q.x == depth)
right = q.x;
else if (q.y - bottom == depth)
bottom = q.y;
else
top = q.y;
}
var width = right - left;
var height = top - bottom;
return width > 0 && height > 0 && width * height >= MinInscribed * area
? new Rect(left, bottom, width, height)
: null;
}
private static List- Items(IReadOnlyList types, Dictionary geometries,
double spacing, double binWidth, double binHeight, CutoutMemo? memo, CancellationToken token)
{
var items = new List
- ();
foreach (var type in types)
{
var geometry = geometries[type.Id];
var area = System.Math.Abs(geometry.Perimeter.Area());
foreach (var orientation in type.Orientations)
{
token.ThrowIfCancellationRequested();
Add(new Item(type.Index, 1, orientation.Width + spacing, orientation.Height + spacing, area,
new[] { (-orientation.OffsetX, -orientation.OffsetY, orientation.Angle) }));
var density = area / (orientation.Width * orientation.Height);
if (type.Part.Quantity < 2 || density >= MaxPairedDensity)
continue;
foreach (var pair in Pairs(type, orientation, geometry, area, density, spacing, memo, token))
Add(pair);
}
}
return items;
void Add(Item item)
{
if (item.Width <= binWidth + MaxRectsSheet.Eps && item.Height <= binHeight + MaxRectsSheet.Eps)
items.Add(item);
}
}
/// A copy and its half turn slid together along each axis: two triangles close into
/// a rectangle. No best-fit search, so this adds a dozen clearance checks per slide.
private static IEnumerable
- Pairs(BoxType type, BoxOrientation orientation, JobPartGeometry geometry,
double area, double density, double spacing, CutoutMemo? memo, CancellationToken token)
{
var turned = Angle.NormalizeRad(orientation.Angle + System.Math.PI);
if (!type.Part.Rotation.Allows(turned)
|| BoxCatalog.RotatedMaterialBounds(type.Part.Geometry, turned) is not { } bounds)
yield break;
var first = new NestJobPlacement(type.Id, 0, -orientation.OffsetX, -orientation.OffsetY, orientation.Angle);
foreach (var along in new[] { true, false })
{
var clear = (along ? orientation.Width : orientation.Height) + spacing;
NestJobPlacement Second(double slide) => new(type.Id, 1,
(along ? slide : 0) - bounds.Left, (along ? 0 : slide) - bounds.Bottom, turned);
bool Free(double slide) => memo?.Clears(geometry, first, geometry, Second(slide), spacing)
?? NestLayoutCheck.Clears(geometry, first, geometry, Second(slide), spacing);
token.ThrowIfCancellationRequested();
if (!Free(clear))
continue;
var low = 0.0;
for (var step = 0; step < SlideSteps; step++)
{
token.ThrowIfCancellationRequested();
var middle = (low + clear) / 2;
if (Free(middle))
clear = middle;
else
low = middle;
}
var width = System.Math.Max(orientation.Width, (along ? clear : 0) + bounds.Width);
var height = System.Math.Max(orientation.Height, (along ? 0 : clear) + bounds.Height);
if (2 * area / (width * height) < density + MinPairGain)
continue;
yield return new Item(type.Index, 2, width + spacing, height + spacing, 2 * area, new[]
{
(first.X, first.Y, first.Rotation),
((along ? clear : 0) - bounds.Left, (along ? 0 : clear) - bounds.Bottom, turned),
});
}
}
/// Beam search over the lowest-leftmost (or leftmost-lowest) free corner: each level
/// puts a box there or gives the corner up. Adds its best distinct selections to
/// , which keeps one layout per selection.
private static void Search(IReadOnlyList
- items, int[] demand, double binWidth, double binHeight,
bool columns, double density, Dictionary plans, CancellationToken token)
{
var used = new int[demand.Length];
var free = Usable(new[] { new Rect(0, 0, binWidth, binHeight) }, items, used, demand);
var beam = new List { new(free, used, 0, 0, null, null, 0, 0) };
var nodes = 0;
while (beam.Count > 0 && nodes < NodesPerPass)
{
token.ThrowIfCancellationRequested();
var next = new List();
foreach (var state in beam)
{
Keep(plans, state);
if (state.Free.Count == 0 || nodes++ >= NodesPerPass)
continue;
var anchor = state.Free[0];
foreach (var candidate in state.Free)
if (columns
? (candidate.X, candidate.Y).CompareTo((anchor.X, anchor.Y)) < 0
: (candidate.Y, candidate.X).CompareTo((anchor.Y, anchor.X)) < 0)
anchor = candidate;
foreach (var item in items)
{
if (state.Used[item.Type] + item.Copies > demand[item.Type]
|| item.Width > anchor.W + MaxRectsSheet.Eps || item.Height > anchor.H + MaxRectsSheet.Eps)
continue;
var taken = (int[])state.Used.Clone();
taken[item.Type] += item.Copies;
var box = new Rect(anchor.X, anchor.Y, item.Width, item.Height);
next.Add(Child(Usable(MaxRectsSheet.Carve(state.Free, box), items, taken, demand),
taken, state.Seated + item.Area, state, item, anchor.X, anchor.Y));
}
// Giving the corner up drops only this rectangle; overlapping ones stay.
var rest = new List(state.Free.Count);
foreach (var candidate in state.Free)
if (candidate != anchor)
rest.Add(candidate);
next.Add(Child(rest, state.Used, state.Seated, state.Parent, state.Item, state.X, state.Y));
}
var seen = new HashSet<(long, long)>();
beam = next.OrderByDescending(s => s.Rank)
.Where(s => seen.Add((Selection(s.Used), (long)System.Math.Round(s.Rank * 1e6))))
.Take(BeamWidth)
.ToList();
}
foreach (var state in beam)
Keep(plans, state);
State Child(IReadOnlyList rest, int[] taken, double seated, State? parent, Item? item, double x, double y) =>
new(rest, taken, seated, seated + density * UnionArea(rest), parent, item, x, y);
}
private static void Keep(Dictionary plans, State state)
{
if (state.Seated > 0)
plans.TryAdd(Selection(state.Used), state);
}
/// FNV-1a over the copy counts: stable across processes, unlike HashCode.
private static long Selection(int[] used)
{
var hash = unchecked((long)0xcbf29ce484222325);
foreach (var count in used)
hash = unchecked((hash ^ count) * 0x100000001b3);
return hash;
}
/// Free rectangles some remaining box still fits; the rest is dead space.
private static List Usable(IReadOnlyList free, IReadOnlyList
- items, int[] used, int[] demand)
{
var usable = new List(free.Count);
foreach (var rect in free)
foreach (var item in items)
if (used[item.Type] + item.Copies <= demand[item.Type]
&& item.Width <= rect.W + MaxRectsSheet.Eps && item.Height <= rect.H + MaxRectsSheet.Eps)
{
usable.Add(rect);
break;
}
return usable;
}
private static double UnionArea(IReadOnlyList rects)
{
if (rects.Count == 0)
return 0;
var edges = new double[rects.Count * 2];
for (var i = 0; i < rects.Count; i++)
{
edges[2 * i] = rects[i].X;
edges[2 * i + 1] = rects[i].Right;
}
Array.Sort(edges);
var spans = new List<(double Low, double High)>(rects.Count);
var area = 0.0;
for (var i = 1; i < edges.Length; i++)
{
var width = edges[i] - edges[i - 1];
if (width <= 0)
continue;
spans.Clear();
foreach (var rect in rects)
if (rect.X <= edges[i - 1] && rect.Right >= edges[i])
spans.Add((rect.Y, rect.Top));
spans.Sort();
var reach = double.NegativeInfinity;
var height = 0.0;
foreach (var (low, high) in spans)
{
height += System.Math.Max(0, high - System.Math.Max(low, reach));
reach = System.Math.Max(reach, high);
}
area += width * height;
}
return area;
}
private static List<(Item Item, double X, double Y)> Placements(State plan)
{
var placed = new List<(Item, double, double)>();
for (var state = plan; state?.Item != null; state = state.Parent)
placed.Add((state.Item, state.X, state.Y));
placed.Reverse();
return placed;
}
/// The remaining demand dropped into what the boxes left, by the frontier packer,
/// which certifies its own poses against the frame and the boxes.
private static IReadOnlyList GapFill(NestJobPart frame, int cutoutIndex,
IReadOnlyList inserts, List poses,
Dictionary geometries, Rect rectangle, double spacing, CutoutMemo? memo,
CancellationToken token)
{
var left = rectangle.W * rectangle.H - OutlineArea(poses, id => geometries[id]);
var rest = new List();
foreach (var part in inserts)
{
var quantity = part.Quantity - poses.Count(p => p.PartId == part.Id);
if (quantity > 0 && System.Math.Abs(geometries[part.Id].Perimeter.Area()) <= left)
rest.Add(new NestJobPart(part.Id, part.Geometry, quantity, part.Priority, part.Rotation));
}
if (rest.Count == 0)
return Array.Empty();
var occupied = poses.Select(p => (geometries[p.PartId], p)).ToArray();
return CutoutPackerProposal.Fill(frame, cutoutIndex, rest, spacing, occupied, memo, token);
}
private static bool Clears(NestJobPlacement pose, Dictionary geometries,
NestLayoutCheck.PreparedPlacement frame, IEnumerable accepted, double spacing,
CutoutMemo? memo, CancellationToken token)
{
var geometry = geometries[pose.PartId];
foreach (var other in accepted)
{
token.ThrowIfCancellationRequested();
var otherGeometry = geometries[other.PartId];
var clears = memo?.Clears(otherGeometry, other, geometry, pose, spacing)
?? NestLayoutCheck.Clears(otherGeometry, other, geometry, pose, spacing);
if (!clears)
return false;
}
token.ThrowIfCancellationRequested();
return NestLayoutCheck.Clears(frame, geometry, pose);
}
}