diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularEdgeFirstTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularEdgeFirstTests.cs new file mode 100644 index 00000000..a14c733a --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularEdgeFirstTests.cs @@ -0,0 +1,106 @@ +using System.Threading; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.NestingEngines.Irregular; +using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; +using static OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.NestingEngines; + +public class IrregularEdgeFirstTests +{ + private static readonly IReadOnlyDictionary> NoPairs = + new Dictionary>(); + + private static EdgeFirstFill Packer(IReadOnlyList types, NestPlateStock stock) + { + var nfps = new NoFitCache(stock.PartSpacing); + var counter = new WorkCounter(); + var next = 1_000_000; + return new EdgeFirstFill(types, nfps, stock, null, counter, + () => new FrontierPacker(types, nfps, NoPairs, stock, PackAxis.X, 1, counter), + (type, angle) => PartCatalog.CreateOrientation(type, next++, angle)); + } + + private static NestJobResult Result(NestJob job, IReadOnlyList types, SheetFill fill) + { + var builder = new NestJobResultBuilder(job); + builder.AddSheet(fill.Stock, fill.Parts.Select(p => (types[p.Orientation.TypeIndex].Part.Id, p.X, p.Y, p.Orientation.Rotation))); + return builder.Build(NestJobStopReason.Completed); + } + + [Fact] + public void ContactsTurnAPartThatNoRightAngleFits() + { + // 61 long on a 60-long sheet: only a turn off the axes fits it, and the catalog has none. + var stock = Stock("sheet", 20, 60, spacing: 0.25); + var job = Job(new[] { Part("bar", Rectangle(61, 2), 1, RotationPolicy.Automatic) }, new[] { stock }); + var types = PartCatalog.Build(job); + Assert.DoesNotContain(types[0].Orientations, o => o.Width <= 60 && o.Height <= 20); + + var fill = Packer(types, stock).Fill(new[] { 1 }, CancellationToken.None); + + Assert.NotNull(fill); + var rotation = Assert.Single(fill!.Parts).Orientation.Rotation; + Assert.NotEqual(0, System.Math.IEEERemainder(rotation, System.Math.PI / 2), 3); + LayoutAssert.Valid(job, Result(job, types, fill)); + } + + [Fact] + public void ALockedRotationIsNeverTurned() + { + var stock = Stock("sheet", 20, 60, spacing: 0.25); + var job = Job(new[] { Part("bar", Rectangle(61, 2), 1, RotationPolicy.Fixed(0)) }, new[] { stock }); + var types = PartCatalog.Build(job); + + Assert.Null(Packer(types, stock).Fill(new[] { 1 }, CancellationToken.None)); + } + + [Fact] + public void EdgePartsTakeOppositeCornersAndTheKeyFillsBetween() + { + var stock = Stock("sheet", 20, 60, spacing: 0.25); + var job = Job(new[] + { + Part("a", Rectangle(20, 8), 1, RotationPolicy.Automatic), + Part("b", Rectangle(20, 8), 1, RotationPolicy.Automatic), + Part("key", Rectangle(19, 19), 1, RotationPolicy.Automatic), + }, new[] { stock }); + var types = PartCatalog.Build(job); + + var fill = Packer(types, stock).Fill(new[] { 1, 1, 1 }, CancellationToken.None); + + Assert.NotNull(fill); + Assert.Equal(3, fill!.Parts.Count); + LayoutAssert.Valid(job, Result(job, types, fill)); + } + + [Fact] + public void ReductionMergesTwoSheetsThatFitOne() + { + var stock = Stock("sheet", 20, 60, spacing: 0.25); + var job = Job(new[] { Part("box", Rectangle(10, 10), 2, RotationPolicy.Automatic) }, new[] { stock }); + var types = PartCatalog.Build(job); + var pose = types[0].Orientations[0]; + var sheet = new SheetFill(stock, new[] { new Placed(pose, -pose.MinX, -pose.MinY) }, types[0].Area); + + var result = new IrregularNestingEngine().ReduceSheetsForTesting(job, new[] { sheet, sheet }); + + Assert.Single(result.Plates); + LayoutAssert.Valid(job, result); + } + + [Fact] + public void ReductionKeepsAPlanItCannotShorten() + { + var stock = Stock("sheet", 20, 60, spacing: 0.25); + var job = Job(new[] { Part("box", Rectangle(50, 15), 2, RotationPolicy.Automatic) }, new[] { stock }); + var types = PartCatalog.Build(job); + var pose = types[0].Orientations[0]; + var sheet = new SheetFill(stock, new[] { new Placed(pose, -pose.MinX, -pose.MinY) }, types[0].Area); + + var result = new IrregularNestingEngine().ReduceSheetsForTesting(job, new[] { sheet, sheet }); + + Assert.Equal(2, result.Plates.Count); + LayoutAssert.Valid(job, result); + } +} diff --git a/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs b/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs index 4b369ef1..2d320f50 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs @@ -55,6 +55,9 @@ internal sealed class ClampObstacles public int Count => rectangles.Length; + /// The keep-out rectangle, clearance included: a part's outline must stay outside it. + public PathD Outline(int index) => rectangles[index].Outline; + // No additional physical spacing. NoFitCache retains the established arc/grid safety // margin and covered-hole certification for the moving orientation and rectangle. public Nfp Get(int index, Orientation moving) => nfps.Get(rectangles[index], moving); diff --git a/OpenNest.Engine/NestingEngines/Irregular/ContactFit.cs b/OpenNest.Engine/NestingEngines/Irregular/ContactFit.cs new file mode 100644 index 00000000..1c83bf75 --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Irregular/ContactFit.cs @@ -0,0 +1,243 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Clipper2Lib; +using OpenNest.Geometry; + +namespace OpenNest.Engine.NestingEngines.Irregular; + +/// +/// Finds a free rotation for a part that fits no sampled orientation, from the contacts it makes +/// rather than from more sampled angles. +/// +/// A part on a sheet moves three ways: x, y and rotation. Its pose starts at a catalog +/// orientation with its center on the largest free pocket. Each pass collects its contacts: +/// every footprint vertex inside or near a neighbor's footprint, every neighbor vertex inside or +/// near its own, and every outline vertex at or past the work area. A contact is a linear +/// constraint on the small move (dx, dy, d-theta) that clears it with its normal, and nearby +/// clear contacts keep the move from opening new overlaps. Cyclic projection finds the least move +/// meeting them all; the pose takes it (at most one unit) and the pass repeats until nothing +/// overlaps. Three contacts then hold the part, so its angle is set by the neighbors around it. +/// +/// The solver only proposes a pose. The caller checks it on the exact free region of an +/// orientation flattened at that angle, the same no-fit polygons every other placement uses, +/// so spacing and validation are unchanged. Deterministic: no clocks or randomness. +/// +internal static class ContactFit +{ + /// Most passes from one starting pose. + private const int MaxPasses = 400; + + /// Projection sweeps over the contacts of one pass. + private const int Sweeps = 60; + + /// Clear contacts nearer than this still constrain the move. + private const double Margin = 0.3; + + /// Penetration still counted as touching. + private const double Touch = 1e-4; + + /// Passes without a smaller worst overlap before the move is halved. + private const int StallPasses = 8; + + /// Shortest move cap before the solve gives up. + private const double MinStep = 1e-3; + + /// Worst overlap, as a share of the part's half size, of a pose still handed back + /// for the exact check. + private const double NearlyClear = 0.01; + + /// Largest move of one pass, in job units (rotation measured at the part's half size). + private const double MaxStep = 1.0; + + /// An obstacle polygon and whether the moving footprint (true) or bare outline + /// (false) must clear it: placed parts are footprints, clamp keep-outs already hold their + /// clearance. + internal readonly record struct Obstacle(PathD Polygon, bool AgainstFootprint); + + /// A solved pose: the absolute rotation and the translation of the snapshot origin. + internal readonly record struct Pose(double Rotation, double X, double Y); + + /// + /// Solves from at the given center; + /// belongs to . Returns the clear pose, else the least-overlapping one + /// when its worst overlap is under one percent of the part's half size, else null. + /// + public static Pose? Solve( + Orientation start, + PathD footprint, + IReadOnlyList obstacles, + Box work, + double centerX, + double centerY, + WorkCounter counter) + { + var bounds = Clipper.GetBounds(start.Outline); + var pivotX = (bounds.left + bounds.right) / 2; + var pivotY = (bounds.top + bounds.bottom) / 2; + var radius = System.Math.Max(1e-6, System.Math.Max(bounds.right - bounds.left, bounds.bottom - bounds.top) / 2); + var foot = Local(footprint, pivotX, pivotY); + var line = Local(start.Outline, pivotX, pivotY); + var near = obstacles.Select(o => (o.Polygon, o.AgainstFootprint, Bounds: Clipper.GetBounds(o.Polygon))).ToArray(); + + double theta = 0, x = centerX, y = centerY; + Pose At() => new(start.Rotation + theta, + x - (System.Math.Cos(theta) * pivotX - System.Math.Sin(theta) * pivotY), + y - (System.Math.Sin(theta) * pivotX + System.Math.Cos(theta) * pivotY)); + Pose? best = null; + var bestWorst = double.PositiveInfinity; + var cap = MaxStep; + var stalled = 0; + var constraints = new List<(double Gx, double Gy, double Gt, double Depth)>(); + for (var pass = 0; pass < MaxPasses; pass++) + { + var movingFoot = Place(foot, theta, x, y); + var movingLine = Place(line, theta, x, y); + constraints.Clear(); + void Add(PointD at, double nx, double ny, double depth) + { + var rx = at.x - x; + var ry = at.y - y; + constraints.Add((nx, ny, (rx * ny - ry * nx) / radius, depth)); + } + + var footBounds = Clipper.GetBounds(movingFoot); + var lineBounds = Clipper.GetBounds(movingLine); + foreach (var (polygon, againstFoot, box) in near) + { + var moving = againstFoot ? movingFoot : movingLine; + var movingBox = againstFoot ? footBounds : lineBounds; + if (box.left > movingBox.right + Margin || box.right < movingBox.left - Margin + || box.top > movingBox.bottom + Margin || box.bottom < movingBox.top - Margin) + continue; + foreach (var q in moving) + { + if (q.x < box.left - Margin || q.x > box.right + Margin || q.y < box.top - Margin || q.y > box.bottom + Margin) + continue; + var (distance, nx, ny) = Nearest(polygon, q); + if (Clipper.PointInPolygon(q, polygon) == PointInPolygonResult.IsInside) + Add(q, nx, ny, distance); + else if (distance < Margin) + Add(q, -nx, -ny, -distance); + } + foreach (var w in polygon) + { + if (w.x < movingBox.left - Margin || w.x > movingBox.right + Margin + || w.y < movingBox.top - Margin || w.y > movingBox.bottom + Margin) + continue; + var (distance, nx, ny) = Nearest(moving, w); + if (Clipper.PointInPolygon(w, moving) == PointInPolygonResult.IsInside) + Add(w, -nx, -ny, distance); + else if (distance < Margin) + Add(w, nx, ny, -distance); + } + counter.Add(1); + } + foreach (var v in movingLine) + { + if (v.x - work.Left < Margin) + Add(v, 1, 0, work.Left - v.x); + if (work.Right - v.x < Margin) + Add(v, -1, 0, v.x - work.Right); + if (v.y - work.Bottom < Margin) + Add(v, 0, 1, work.Bottom - v.y); + if (work.Top - v.y < Margin) + Add(v, 0, -1, v.y - work.Top); + } + + var worst = constraints.Count == 0 ? 0 : constraints.Max(c => c.Depth); + if (worst <= Touch) + return At(); + if (worst < bestWorst - Touch) + { + best = At(); + bestWorst = worst; + stalled = 0; + } + else if (++stalled >= StallPasses) + { + // Linearized contacts overshoot near the end; settle with shorter moves. + cap /= 2; + stalled = 0; + if (cap < MinStep) + break; + } + + double dx = 0, dy = 0, du = 0; + for (var sweep = 0; sweep < Sweeps; sweep++) + foreach (var (gx, gy, gt, depth) in constraints) + { + var need = depth + Touch; + var have = gx * dx + gy * dy + gt * du; + if (have >= need) + continue; + var scale = (need - have) / (gx * gx + gy * gy + gt * gt); + dx += scale * gx; + dy += scale * gy; + du += scale * gt; + } + var step = System.Math.Sqrt(dx * dx + dy * dy + du * du); + if (step < 1e-9) + break; // The contacts pull against each other: no move clears them. + if (step > cap) + { + dx *= cap / step; + dy *= cap / step; + du *= cap / step; + } + x += dx; + y += dy; + theta += du / radius; + } + // Nearly clear: the angle is what the caller needs, and its exact free region finds the + // position, so a pose still overlapping slightly is worth checking. + return bestWorst <= NearlyClear * radius ? best : null; + } + + private static PathD Local(PathD path, double pivotX, double pivotY) + { + var result = new PathD(path.Count); + foreach (var p in path) + result.Add(new PointD(p.x - pivotX, p.y - pivotY)); + return result; + } + + private static PathD Place(PathD local, double theta, double x, double y) + { + var cos = System.Math.Cos(theta); + var sin = System.Math.Sin(theta); + var result = new PathD(local.Count); + foreach (var p in local) + result.Add(new PointD(cos * p.x - sin * p.y + x, sin * p.x + cos * p.y + y)); + return result; + } + + /// Distance from to the nearest edge of a CCW polygon, with + /// that edge's outward normal. + private static (double Distance, double Nx, double Ny) Nearest(PathD polygon, PointD q) + { + double best = double.PositiveInfinity, bestNx = 0, bestNy = 0; + for (var i = 0; i < polygon.Count; i++) + { + var a = polygon[i]; + var b = polygon[(i + 1) % polygon.Count]; + var ex = b.x - a.x; + var ey = b.y - a.y; + var lengthSquared = ex * ex + ey * ey; + if (lengthSquared < 1e-18) + continue; + var t = System.Math.Clamp(((q.x - a.x) * ex + (q.y - a.y) * ey) / lengthSquared, 0, 1); + var px = a.x + t * ex - q.x; + var py = a.y + t * ey - q.y; + var distance = System.Math.Sqrt(px * px + py * py); + if (distance >= best) + continue; + var length = System.Math.Sqrt(lengthSquared); + best = distance; + bestNx = ey / length; + bestNy = -ex / length; + } + return (best, bestNx, bestNy); + } +} diff --git a/OpenNest.Engine/NestingEngines/Irregular/EdgeFirstFill.cs b/OpenNest.Engine/NestingEngines/Irregular/EdgeFirstFill.cs new file mode 100644 index 00000000..58a53568 --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Irregular/EdgeFirstFill.cs @@ -0,0 +1,416 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Clipper2Lib; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.NestingEngines.Irregular; + +/// +/// Packs one sheet's given parts edge first: the large parts go to the sheet's corners and edges, +/// one large part (the key) fits the pocket they leave in the middle, and the small parts fill the +/// gaps last. All or nothing: returns null unless every part is placed. +/// +/// The frontier packer grows a layout from one side, so parts that only fit together when the +/// outer ones hug opposite edges never meet. Here each edge part takes the corner where it sits +/// flush, preferring the pose with the most outline on the sheet boundary and then the corner +/// farthest from the parts already placed, so the free room collects in the middle. The key part +/// takes any sampled orientation that fits there; failing that, turns it +/// against its neighbors to an angle no catalog holds. Each large part is tried as the key in +/// turn, largest first. +/// +/// Every position comes from an exact free region (inner-fit rectangle less the no-fit polygons +/// of clamps and placed parts), as in . Deterministic. +/// +internal sealed class EdgeFirstFill( + IReadOnlyList types, + NoFitCache nfps, + NestPlateStock stock, + ClampObstacles? clamps, + WorkCounter counter, + Func fillers, + Func orient) +{ + /// Parts below this share of the largest part's area fill gaps after the key. + internal const double FillerShare = 0.15; + + /// Corner poses tried per edge part, best first. + internal const int Branches = 2; + + /// Most complete edge arrangements tried per key. + internal const int MaxLeaves = 8; + + /// Most part types tried as the key. + internal const int MaxKeys = 4; + + /// Corner distances within this count as equally flush. + private const double Flush = 0.05; + + /// Outline edges this close to a work-area side lie on it. + private const double OnEdge = 0.02; + + /// Most pockets the key part is started in. + private const int MaxPockets = 2; + + /// Angle step, in radians (0.1 degree), of the exact checks around a solved angle. + private const double RefineStep = System.Math.PI / 1800; + + /// Exact checks each side of a solved angle (two degrees). + private const int RefineSteps = 20; + + /// Same slack as the frontier packer's inner-fit rectangle. + private const double FitSlack = 2e-4; + + /// + /// The work area this fill packs: the stock's, with each side that carries clamps moved in + /// just past their keep-outs, as a larger edge spacing would. Packing the clear rectangle + /// above the clamps keeps parts flush with one straight edge instead of stepping between + /// clamps. A clamp off every side stays an obstacle instead. + /// + private readonly Box work = ClearOfClamps(stock.WorkArea, clamps).Area; + + /// Clamps not cleared by moving a side: obstacles inside the work area. + private readonly ClampObstacles? obstacles = ClearOfClamps(stock.WorkArea, clamps).OnEdges ? null : clamps; + + private static (Box Area, bool OnEdges) ClearOfClamps(Box area, ClampObstacles? clamps) + { + var allOnEdges = true; + double left = area.Left, right = area.Right, bottom = area.Bottom, top = area.Top; + for (var i = 0; clamps != null && i < clamps.Count; i++) + { + var box = Clipper.GetBounds(clamps.Outline(i)); + if (box.right <= area.Left || box.left >= area.Right || box.bottom <= area.Bottom || box.top >= area.Top) + continue; // Outside the work area: nothing to clear. + if (box.left <= area.Left + OnEdge) + left = System.Math.Max(left, box.right); + else if (box.right >= area.Right - OnEdge) + right = System.Math.Min(right, box.left); + else if (box.top <= area.Bottom + OnEdge) // Clipper RectD: top is the minimum Y. + bottom = System.Math.Max(bottom, box.bottom); + else if (box.bottom >= area.Top - OnEdge) + top = System.Math.Min(top, box.top); + else + allOnEdges = false; + } + if (!allOnEdges || right <= left || top <= bottom) + return (area, false); + return (new Box(left, bottom, right - left, top - bottom), true); + } + + public SheetFill? Fill(IReadOnlyList demand, CancellationToken token) + { + var instances = new List(); + foreach (var type in types) + for (var i = 0; i < demand[type.Index]; i++) + instances.Add(type.Index); + if (instances.Count == 0 || instances.Any(t => types[t].Orientations.Count == 0)) + return null; + var largest = instances.Max(t => types[t].Area); + var majors = instances.Where(t => types[t].Area >= FillerShare * largest) + .OrderByDescending(t => types[t].Area).ThenBy(t => t).ToList(); + var small = new int[types.Count]; + foreach (var t in instances.Where(t => types[t].Area < FillerShare * largest)) + small[t]++; + var smallCount = small.Sum(); + + foreach (var key in majors.Distinct().Take(MaxKeys)) + { + token.ThrowIfCancellationRequested(); + var edges = majors.ToList(); + edges.Remove(key); + var placed = new List(); + var leaves = 0; + if (!Arrange(edges, 0, types[key], placed, small, smallCount, ref leaves, token)) + continue; + return new SheetFill(stock, placed, placed.Sum(p => types[p.Orientation.TypeIndex].Area)); + } + return null; + } + + /// + /// Places the edge parts from on, each in one of its best + /// corner poses, depth first, then the key and the small parts; at most + /// complete edge arrangements per key. Leaves + /// holding the sheet on success. + /// + private bool Arrange(List edges, int next, PartType key, List placed, int[] small, int smallCount, + ref int leaves, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + if (next == edges.Count) + { + if (leaves++ >= MaxLeaves) + return false; + var middle = Key(key, placed); + if (middle == null) + return false; + placed.Add(middle); + if (smallCount > 0) + { + var gaps = fillers().Fill(small, token, placed); + if (gaps.Parts.Count != smallCount) + { + placed.RemoveAt(placed.Count - 1); + return false; + } + placed.AddRange(gaps.Parts); + } + return true; + } + foreach (var pose in Corners(types[edges[next]], placed).Take(Branches)) + { + placed.Add(pose); + if (Arrange(edges, next + 1, key, placed, small, smallCount, ref leaves, token)) + return true; + placed.RemoveAt(placed.Count - 1); + if (leaves >= MaxLeaves) + return false; + } + return false; + } + + /// The flush corner pose with the most outline on the sheet edges, then the one + /// thinnest across the sheet (hugging its long edges), then the one farthest from the parts + /// placed. + private List Corners(PartType type, IReadOnlyList placed) + { + var ranked = new List<(Placed Pose, double Gap, double Contact, double Across, double Spread)>(); + var lengthwise = work.Length >= work.Width; + foreach (var o in type.Orientations) + { + var free = Free(o, placed, out var minX, out var minY, out var maxX, out var maxY); + if (free.Count == 0) + continue; + for (var corner = 0; corner < 4; corner++) + { + var right = corner is 1 or 2; + var top = corner is 2 or 3; + double cornerGap = double.PositiveInfinity, x = 0, y = 0; + foreach (var path in free) + foreach (var raw in path) + { + var px = System.Math.Clamp(raw.x, minX, maxX); + var py = System.Math.Clamp(raw.y, minY, maxY); + var gap = (right ? work.Right - (px + o.MaxX) : px + o.MinX - work.Left) + + (top ? work.Top - (py + o.MaxY) : py + o.MinY - work.Bottom); + if (gap < cornerGap - 1e-9) + { + cornerGap = gap; + x = px; + y = py; + } + } + if (double.IsPositiveInfinity(cornerGap)) + continue; + var candidate = new Placed(o, x, y); + if (ranked.Any(r => ReferenceEquals(r.Pose.Orientation, o) && System.Math.Abs(r.Pose.X - x) < 1e-6 + && System.Math.Abs(r.Pose.Y - y) < 1e-6)) + continue; + ranked.Add((candidate, System.Math.Floor(cornerGap / Flush), Contact(candidate), + lengthwise ? o.Height : o.Width, Spread(candidate, placed))); + } + } + // Flush first, then most outline on the edges, thinnest across the sheet, farthest out; + // a stable sort keeps orientation and corner order on ties. + return ranked + .OrderBy(r => r.Gap) + .ThenByDescending(r => System.Math.Round(r.Contact, 4)) + .ThenBy(r => System.Math.Round(r.Across, 4)) + .ThenByDescending(r => System.Math.Round(r.Spread, 4)) + .Select(r => r.Pose) + .ToList(); + } + + /// The key part: a sampled orientation if one fits, else a contact-solved angle. + private Placed? Key(PartType type, IReadOnlyList placed) + { + var pockets = Pockets(placed, type.Area); + foreach (var o in type.Orientations) + { + var free = Free(o, placed, out var minX, out var minY, out var maxX, out var maxY); + if (free.Count > 0) + return Nearest(o, free, minX, minY, maxX, maxY, pockets.FirstOrDefault()); + } + if (type.Part.Rotation.Kind != RotationPolicyKind.Automatic || pockets.Count == 0) + return null; + + var contacts = new List(); + foreach (var part in placed) + contacts.Add(new(Clipper.TranslatePath(nfps.Footprint(part.Orientation), part.X, part.Y), true)); + for (var i = 0; obstacles != null && i < obstacles.Count; i++) + contacts.Add(new(obstacles.Outline(i), false)); + foreach (var pocket in pockets) + foreach (var start in Starts(type)) + { + var pose = ContactFit.Solve(start, nfps.Footprint(start), contacts, work, pocket.x, pocket.y, counter); + if (pose is not { } solved) + continue; + // The contacts bring the part to the edge of its fit; the exact free region + // decides, from the solved angle outward until one fits. + for (var step = 0; step <= 2 * RefineSteps; step++) + { + var offset = (step + 1) / 2 * RefineStep * (step % 2 == 0 ? 1 : -1); + var angle = solved.Rotation + offset; + if (!type.Part.Rotation.Allows(angle)) + continue; + var o = orient(type, angle); + if (o == null) + continue; + var free = Free(o, placed, out var minX, out var minY, out var maxX, out var maxY); + if (free.Count > 0) + return Nearest(o, free, minX, minY, maxX, maxY, null, solved.X, solved.Y); + } + } + return null; + } + + /// Starting poses for the contact solve: the orientations whose box fits the work + /// area, or all of them when none does (only a turn can fit the part then). + private IEnumerable Starts(PartType type) + { + var fitting = type.Orientations.Where(o => o.Width <= work.Length + 1e-9 && o.Height <= work.Width + 1e-9).ToList(); + return fitting.Count > 0 ? fitting : type.Orientations; + } + + /// Centers of the largest free pockets that could hold . + private List Pockets(IReadOnlyList placed, double area) + { + var room = new PathsD { Rectangle(work.Left, work.Bottom, work.Right, work.Top) }; + var taken = new PathsD(placed.Select(p => Clipper.TranslatePath(nfps.Footprint(p.Orientation), p.X, p.Y))); + for (var i = 0; obstacles != null && i < obstacles.Count; i++) + taken.Add(obstacles.Outline(i)); + room = Clipper.Difference(room, taken, FillRule.NonZero, NoFitCache.Precision); + return room.Where(p => Clipper.IsPositive(p) && Clipper.Area(p) >= area) + .OrderByDescending(p => Clipper.Area(p)) + .Take(MaxPockets) + .Select(Centroid) + .ToList(); + } + + private static PointD Centroid(PathD path) + { + double a = 0, cx = 0, cy = 0; + for (var i = 0; i < path.Count; i++) + { + var p = path[i]; + var q = path[(i + 1) % path.Count]; + var cross = p.x * q.y - q.x * p.y; + a += cross; + cx += (p.x + q.x) * cross; + cy += (p.y + q.y) * cross; + } + return System.Math.Abs(a) < 1e-12 ? path[0] : new PointD(cx / (3 * a), cy / (3 * a)); + } + + /// The free vertex whose part center (or reference, with ) + /// lies nearest the target. + private static Placed Nearest(Orientation o, PathsD free, double minX, double minY, double maxX, double maxY, + PointD? center, double? refX = null, double? refY = null) + { + Placed? best = null; + var bestDistance = double.PositiveInfinity; + foreach (var path in free) + foreach (var raw in path) + { + var x = System.Math.Clamp(raw.x, minX, maxX); + var y = System.Math.Clamp(raw.y, minY, maxY); + double dx, dy; + if (refX is double rx && refY is double ry) + { + dx = x - rx; + dy = y - ry; + } + else if (center is { } c) + { + dx = x + (o.MinX + o.MaxX) / 2 - c.x; + dy = y + (o.MinY + o.MaxY) / 2 - c.y; + } + else + { + dx = x; + dy = y; + } + var distance = dx * dx + dy * dy; + if (distance < bestDistance - 1e-12) + { + bestDistance = distance; + best = new Placed(o, x, y); + } + } + return best!; + } + + /// Length of outline lying on the work area's sides. + private double Contact(Placed part) + { + var outline = part.Orientation.Outline; + // Arc outlines keep flattening padding in their bounds, so they stop that far short. + var near = OnEdge + part.Orientation.Tolerance; + bool On(double a, double b, double side) => System.Math.Abs(a - side) < near && System.Math.Abs(b - side) < near; + var total = 0.0; + for (var i = 0; i < outline.Count; i++) + { + var a = outline[i]; + var b = outline[(i + 1) % outline.Count]; + double ax = a.x + part.X, ay = a.y + part.Y, bx = b.x + part.X, by = b.y + part.Y; + var along = On(ax, bx, work.Left) || On(ax, bx, work.Right) || On(ay, by, work.Bottom) || On(ay, by, work.Top); + if (along) + total += System.Math.Sqrt((bx - ax) * (bx - ax) + (by - ay) * (by - ay)); + } + return total; + } + + private static double Spread(Placed part, IReadOnlyList placed) + { + if (placed.Count == 0) + return 0; + var cx = (part.Left + part.Right) / 2; + var cy = (part.Bottom + part.Top) / 2; + return placed.Min(p => + { + var dx = (p.Left + p.Right) / 2 - cx; + var dy = (p.Bottom + p.Top) / 2 - cy; + return System.Math.Sqrt(dx * dx + dy * dy); + }); + } + + /// Legal reference points of : the inner-fit rectangle less the + /// no-fit polygons of clamps and placed parts. Empty when it does not fit. + private PathsD Free(Orientation o, IReadOnlyList placed, + out double minX, out double minY, out double maxX, out double maxY) + { + minX = work.Left - o.MinX; + maxX = work.Right - o.MaxX; + minY = work.Bottom - o.MinY; + maxY = work.Top - o.MaxY; + if (o.Width > work.Length + 1e-9 || o.Height > work.Width + 1e-9) + return new PathsD(); + maxX = System.Math.Max(minX, maxX); + maxY = System.Math.Max(minY, maxY); + var free = new PathsD { Rectangle(minX - FitSlack, minY - FitSlack, maxX + FitSlack, maxY + FitSlack) }; + for (var i = 0; obstacles != null && i < obstacles.Count && free.Count > 0; i++) + free = Subtract(free, obstacles.Get(i, o), 0, 0); + foreach (var part in placed) + { + if (free.Count == 0) + break; + free = Subtract(free, nfps.Get(part.Orientation, o), part.X, part.Y); + } + return free; + } + + private PathsD Subtract(PathsD free, Nfp nfp, double dx, double dy) + { + counter.Add(1); + var result = Clipper.Difference(free, Clipper.TranslatePaths(nfp.Region, dx, dy), FillRule.NonZero, + NoFitCache.Precision); + result.RemoveAll(p => p.Count < 3); + return result; + } + + private static PathD Rectangle(double left, double bottom, double right, double top) => + new() { new(left, bottom), new(right, bottom), new(right, top), new(left, top) }; +} diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs index 80a7b815..dddd4915 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs @@ -56,6 +56,12 @@ public sealed class IrregularNestingEngine : INestingEngine /// Whether the grouped variants run; off only in tests of the ungrouped search. internal bool GroupLikeParts { get; init; } = true; + /// Whether the sheet-reduction pass runs; off only in tests of the search before it. + internal bool ReduceSheets { get; init; } = true; + + /// Most parts a plan may hold for the sheet-reduction pass to try it. + internal const int MaxReduceParts = 40; + /// /// Deterministic work budget, in free-region subtractions, after which no further variant /// starts. Keeps big jobs well inside benchmark timeouts without consulting a clock. @@ -145,6 +151,17 @@ public sealed class IrregularNestingEngine : INestingEngine if (!ReferenceEquals(improved, plan)) ReportPlan(job, types, improved, progress, token); } + // Then try to drop a sheet: split the parts over one sheet fewer and pack each sheet edge + // first, the key part turned by its contacts. Replaces the result only by beating it. + if (ReduceSheets) + { + var reduced = solver.ReduceSheets(improved); + if (!ReferenceEquals(reduced, improved)) + { + improved = reduced; + ReportPlan(job, types, improved, progress, token); + } + } // More searching can only end in cancellation, so it needs a listener for its plans, and // it needs demand and two or more types, or every weighted decode repeats a variant. if (job.Options.SearchUntilCancelled && token.CanBeCanceled && progress != null @@ -222,6 +239,16 @@ public sealed class IrregularNestingEngine : INestingEngine return BuildResult(job, types, solver.ImproveTail(plan, WorkBudget * 2), null, default); } + /// Runs the sheet-reduction pass on a test-owned complete plan. + internal NestJobResult ReduceSheetsForTesting(NestJob job, IReadOnlyList physicalPlan) + { + var types = PartCatalog.Build(job); + using var solver = new Solver(job, types, null, default, WorkBudget, DrawingFor, SheetPacked, BatchSelected, TailReplaced); + var plan = new Plan(physicalPlan, physicalPlan.Sum(s => NetCost(job.Options, s)), 0, NestJobStopReason.Completed, + NestJobCost.UsesExplicitCosts(job), job.Options); + return BuildResult(job, types, solver.ReduceSheets(plan), null, default); + } + /// Decodes one variant with the given type weights, whole demand placeable; for /// tests of the weighted placement rule only. internal NestJobResult PlanForTesting(NestJob job, int variant, IReadOnlyList? weights) @@ -352,6 +379,92 @@ public sealed class IrregularNestingEngine : INestingEngine return plan; } + /// + /// Drops sheets while it can: the parts of a complete plan on one stock are split over + /// one sheet fewer (by area, largest first onto the lightest sheet, then dealt in a snake) + /// and each sheet packed by . A split that packs every sheet + /// replaces the plan when it beats it. + /// + public Plan ReduceSheets(Plan plan) + { + while (plan.Unplaced == 0 && plan.Sheets.Count >= 2) + { + token.ThrowIfCancellationRequested(); + var stock = plan.Sheets[0].Stock; + if (stock.Outline != null || plan.Sheets.Any(s => !ReferenceEquals(s.Stock, stock))) + break; + var instances = plan.Sheets.SelectMany(s => s.Parts).Select(p => p.Orientation.TypeIndex) + .OrderByDescending(t => types[t].Area).ThenBy(t => t).ToList(); + var target = plan.Sheets.Count - 1; + var room = stock.WorkArea.Length * stock.WorkArea.Width; + if (instances.Count > MaxReduceParts || instances.Sum(t => types[t].Area) > target * room) + break; + + Plan? found = null; + foreach (var bins in Splits(instances, target)) + { + var sheets = new List(); + foreach (var bin in bins) + { + var fill = new EdgeFirstFill(types, CacheFor(stock), stock, ClampsFor(stock), Work, + () => new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, PackAxis.X, 1.0, Work, + null, ClampsFor(stock)), + Orient).Fill(bin, token); + if (fill == null) + break; + sheets.Add(fill); + } + if (sheets.Count < target) + continue; + var candidate = new Plan(sheets, NestJobCost.RequireFinite(sheets.Sum(s => NetCost(job.Options, s))), 0, + NestJobStopReason.Completed, NestJobCost.UsesExplicitCosts(job), job.Options); + if (candidate.IsBetterThan(plan)) + found = candidate; + break; + } + if (found == null) + break; + plan = found; + } + return plan; + } + + /// Per-sheet demands for sheets: largest part first onto + /// the lightest sheet, then the same parts dealt out in a snake. + private IEnumerable Splits(IReadOnlyList instances, int count) + { + var greedy = Enumerable.Range(0, count).Select(_ => new int[types.Count]).ToArray(); + var load = new double[count]; + foreach (var t in instances) + { + var lightest = 0; + for (var i = 1; i < count; i++) + if (load[i] < load[lightest] - 1e-9) + lightest = i; + greedy[lightest][t]++; + load[lightest] += types[t].Area; + } + yield return greedy; + if (count < 2) + yield break; + var snake = Enumerable.Range(0, count).Select(_ => new int[types.Count]).ToArray(); + for (var i = 0; i < instances.Count; i++) + { + var round = i / count; + var slot = i % count; + snake[round % 2 == 0 ? slot : count - 1 - slot][instances[i]]++; + } + if (!snake.Zip(greedy).All(p => p.First.SequenceEqual(p.Second))) + yield return snake; + } + + /// Next private orientation index: far above the catalog's, pair and block poses. + private int solvedOrientations = 1_000_000; + + /// A pose of at a solved angle, with its own NFP cache key. + private Orientation? Orient(PartType type, double angle) => + PartCatalog.CreateOrientation(type, solvedOrientations++, angle); + private NoFitCache CacheFor(NestPlateStock stock) { var clearance = System.Math.Max(0, stock.PartSpacing); diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 36abf8c5..29fdfaaf 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -14,7 +14,7 @@ in `OpenNest.Engine/NestingEngines//`, its tests in `OpenNest.Engine.Tests | Default | Any job; used when no engine is named | Runs Irregular, Rectangles and Circles, checks each layout with the layout check and keeps the best: valid first, then fewest unplaced parts, then lowest salvage-credited cost, then the largest compactness offcut; remaining ties keep the engine listed first here | | Rectangles | Plain and near-rectangular plates | Each part packed as the box of its material at its minimum-area rotation, using a maximal-rectangles free list; stock chosen sheet by sheet by salvage-credited look-ahead cost | | Circles | Round parts: blanks, washers, flanges, gears by their tip circle | Each part packed as its minimum enclosing circle at centers tangent to two of walls, placed discs and clamp keep-outs, with hexagonal and square lattice seeds; bids only when every part is round; stock chosen as Rectangles does, plus whole-job runs preferring each stock and a tail re-plan | -| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan, then grouped variants that pack like parts together; under a time limit it keeps trying seeded variants until the limit | +| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan, then grouped variants that pack like parts together, then a sheet-reduction pass that packs edge parts first and turns a key part by its contacts; under a time limit it keeps trying seeded variants until the limit | | FlatBar | Flat bar: parts about as wide as the bar | Each stock read as a bar and cut in one row: the CutList cut-list packer and a best-fill search on part lengths less their self-nesting gain, then a slide-left pass that nests shaped ends and refills freed length; stock chosen bar by bar by whole-plan cost | | Lattice, Strip, Vertical Remnant, Horizontal Remnant | Many copies of a few parts | The fixed placement strategies behind interactive fill; Lattice is the multi-phase lattice fill (linear, pairs, rectangle best-fit, remainder), and the remnant strategies run it ranked to keep the largest drop. In whole jobs, all four place a plate with any fixed or bounded part rotation by the rotation-aware ordered placer instead | @@ -234,6 +234,37 @@ re-plan. The search until cancelled takes grouped decodes in turn with the varia same seeded weights. On the help tutorial's parts (30 gussets, 20 ells, 10 plates, 12 flanges on 60 x 120 at 0.3 spacing) the layout ends at 34.9 rather than 38.1. +### Sheet reduction + +Last, Irregular tries to drop a sheet from a complete plan of at most 40 parts on one rectangular +stock. It splits the parts over one sheet fewer (by area, largest first onto the lightest sheet, +then the same parts dealt out in a snake) and packs each sheet edge first (`EdgeFirstFill`); a +split that packs every sheet replaces the plan when it beats it, and the pass repeats while it +can. The frontier packer grows a layout from one side, so parts that fit only when the outer ones +hug opposite edges never meet there. + +- Parts under 15% of the largest part's area wait for the gaps. Each large part is tried as the + key, largest first (at most four); the others go to the corners, each in one of its two best + corner poses, depth first, at most eight arrangements per key. A pose ranks by its gap to the + corner, then the length of outline on the sheet edges, then how thin it is across the sheet, + then its distance from the parts already placed. +- Clamps along a side move that side in past their keep-outs, as a larger edge spacing would, so + edge parts sit flush with one straight line; a clamp off every side stays an obstacle. The + small parts fill around the real clamps. +- The key takes any sampled orientation that fits. Failing that, `ContactFit` starts it in the + largest free pocket and turns it by its contacts: every footprint vertex inside or near a + neighbor, every neighbor vertex inside or near it and every outline vertex at the work-area + edge is a linear constraint on a small move (dx, dy, rotation), and the least move meeting them + all is taken until nothing overlaps. Three contacts then hold the part, so the neighbors set its + angle; no angle list is sampled. Rotation-locked parts are never turned. +- The solver only proposes the angle. Orientations flattened at it, then 0.1 degree steps out to + two degrees either side, are checked on the exact free region (no-fit polygons, unchanged + spacing) until one fits. + +On a job of ten one-off brackets (four 112-inch tapered bars, four gussets, two small plates) on +60 x 120 with a clamp rail, the bars stacked three to a sheet and the plan took three sheets; the +pass packs two bars, two gussets and a plate per sheet, the middle bar turned 4.4 degrees, on two. + ## Flat bar FlatBar treats each stock's long work-area axis as the bar's length and lays parts in a single