diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelineChainTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelineChainTests.cs new file mode 100644 index 00000000..c6e3a96a --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelineChainTests.cs @@ -0,0 +1,137 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Cutouts; +using OpenNest.Geometry; +using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.Jobs.Cutouts; + +/// Round frames nest inside larger round frames: a flange's bore holds a washer whose +/// bore holds a smaller washer, and so on, as one bundle the engine places as its outer frame. +[Collection("Cutout pipeline registry")] +public class CutoutPipelineChainTests +{ + private const double Sheet = 42; + + private sealed class Stub(Func solve) : INestingEngine + { + public List Jobs { get; } = new(); + + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) + { + Jobs.Add(job); + return solve(job); + } + } + + // Each ring's bore holds the next part with room for the 0.25 spacing. + private static NestPipelineRequest Request(params Program[] programs) => + new("stub", + programs.Select((p, i) => new NestItem { Drawing = new Drawing($"d{i}", p), Quantity = 1 }).ToArray(), + new[] { new NestPlateStock("sheet", new Size(Sheet, Sheet), 1, 0.25) }); + + private static readonly Program[] Chain = + { + TestShapes.Ring(40, 30), TestShapes.Ring(28, 18), TestShapes.Ring(16, 8), TestShapes.Disc(3), + }; + + private static NestJobResult PlaceProxies(NestJob job) + { + var builder = new NestJobResultBuilder(job); + builder.AddSheet(job.Plates[0], job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")) + .Select(p => (p.Id, Sheet / 2, Sheet / 2, p.Rotation.EnumerateAngles(1)[0]))); + var complete = job.Parts.All(p => p.Id.StartsWith("__cutout-proxy-")); + return builder.Build(complete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound); + } + + [Fact] + public void FourLevelChainIsOneBundleThatCutsInnermostFirst() + { + var request = Request(Chain); + var engine = new Stub(PlaceProxies); + var result = NestPipeline.RunCutoutTrial(engine, "stub", request); + + var proxy = Assert.Single(Assert.Single(engine.Jobs).Parts); + Assert.Equal("__cutout-proxy-part-0-0", proxy.Id); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(new[] { "part-3", "part-2", "part-1", "part-0" }, + Assert.Single(result.Raw.Plates).Placements.Select(p => p.PartId)); + // A single insert sits concentric at every level. + Assert.All(result.Raw.Plates[0].Placements, p => + Assert.True(new Vector(p.X, p.Y).DistanceTo(new Vector(Sheet / 2, Sheet / 2)) < 1e-6)); + } + + [Fact] + public void RegisteredEngineNestsTheWholeChainOnOneSheet() + { + var request = Request(Chain) with { EngineName = "Irregular" }; + var result = NestPipeline.Run(request); + + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(request.Items.Reverse().Select(i => i.Drawing), + Assert.Single(result.Plates).Parts.Select(p => p.BaseDrawing)); + } + + [Fact] + public void RingWithAnEmptyBoreIsAnInsert() + { + var engine = new Stub(PlaceProxies); + var result = NestPipeline.RunCutoutTrial(engine, "stub", Request(Chain[0], Chain[1])); + + Assert.Single(Assert.Single(engine.Jobs).Parts); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(new[] { "part-1", "part-0" }, + Assert.Single(result.Raw.Plates).Placements.Select(p => p.PartId)); + } + + [Fact] + public void UnplacedOuterFrameReleasesItsWholeChain() + { + var request = Request(Chain); + var result = NestPipeline.RunCutoutTrial(new Stub(job => + new NestJobResultBuilder(job).Build(NestJobStopReason.NoPlacementFound)), "stub", request); + + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 0, 1), (f.Requested, f.Placed, f.Unplaced))); + Assert.Equal(new[] { + ("part-0", NestUnplacedReason.NoPlacementFound), + ("part-1", NestUnplacedReason.FrameUnplaced), + ("part-2", NestUnplacedReason.FrameUnplaced), + ("part-3", NestUnplacedReason.FrameUnplaced) }, + result.Unplaced.Select(u => (u.PartId, u.Reason)).OrderBy(u => u.PartId)); + } + + [Fact] + public void ChainsStopAtTheLevelLimit() + { + // Five levels: the outermost flange cannot take the four-level bundle, so it stays + // independent and the engine places both. + var programs = new[] { TestShapes.Ring(40, 34) }.Concat(new[] { + TestShapes.Ring(33, 26), TestShapes.Ring(25, 18), TestShapes.Ring(17, 10), TestShapes.Disc(4.5) }).ToArray(); + var engine = new Stub(job => new NestJobResultBuilder(job).Build(NestJobStopReason.NoPlacementFound)); + var request = Request(programs) with + { + Stock = new[] { new NestPlateStock("sheet", new Size(Sheet, Sheet), 2, 0.25) }, + }; + NestPipeline.RunCutoutTrial(engine, "stub", request); + + var job = Assert.Single(engine.Jobs); + Assert.Equal(new[] { "part-0", "__cutout-proxy-part-1-0" }, job.Parts.Select(p => p.Id)); + Assert.Equal(CutoutPipelinePrepass.MaxLevels, programs.Length - 1); + } + + [Fact] + public void DifferentPriorityStaysIndependentAtEveryLevel() + { + var request = Request(Chain); + request.Items[3].Priority = 1; + var engine = new Stub(job => new NestJobResultBuilder(job).Build(NestJobStopReason.NoPlacementFound)); + NestPipeline.RunCutoutTrial(engine, "stub", request); + + Assert.Equal(new[] { "__cutout-proxy-part-0-0", "part-3" }, + Assert.Single(engine.Jobs).Parts.Select(p => p.Id)); + } +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs index 12e2c2e3..2a777b4b 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using OpenNest.Engine.CirclePacking; using OpenNest.Engine.Jobs.Adapters; namespace OpenNest.Engine.Jobs.Cutouts; @@ -10,12 +11,23 @@ namespace OpenNest.Engine.Jobs.Cutouts; /// Frame bundles prepared by before every whole-job solve: /// engines place a frame proxy and never see its reserved inserts. Only actually placed /// proxies expand, and the expanded physical layout is validated again. +/// Bundles may nest: smaller frames are bundled first, so a larger round cutout can +/// take a ring part or an earlier bundle (a ring holding its own inserts) as one round insert. +/// Those go only through , which keeps every disc clear of the +/// others' whole enclosing circles; lattice and NFP placement could seat a part in a ring's +/// empty bore, or over a nested bundle's reserved inserts. internal sealed class CutoutPipelinePrepass { // A job with thousands of repeats still offers the residual as ordinary demand; // do not multiply expensive Clipper/Fill preparation by every physical instance. private const int MaxBundlesPerJob = 32; - private sealed record Bundle(string FrameId, IReadOnlyList LocalInserts); + + /// Most parts on one frame-in-frame chain: flange, washer, smaller washer, slug. + internal const int MaxLevels = 4; + + // Levels: parts on the deepest chain through the frame; Parts: every physical part. + private sealed record Bundle(string FrameId, IReadOnlyList LocalInserts, + int Levels, int Parts); private readonly IReadOnlyDictionary bundles; private CutoutPipelinePrepass(NestJob engineJob, IReadOnlyDictionary bundles) @@ -36,16 +48,24 @@ internal sealed class CutoutPipelinePrepass var spacing = original.Plates.Max(p => p.PartSpacing); var remaining = original.Parts.ToDictionary(p => p.Id, p => p.Quantity); var geometry = original.Parts.ToDictionary(p => p.Id, p => JobPartGeometry.TryRead(p.Geometry)); + // Proxies join these as they are built, so a larger frame can take one as an insert. + var offers = original.Parts.ToList(); + var requirements = original.Parts.ToDictionary(p => p.Id); + var round = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g + && Item.Create(g).FillRatio >= CutoutCirclePacker.MinFillRatio); var bundles = new Dictionary(StringComparer.Ordinal); var byFrame = new Dictionary>(StringComparer.Ordinal); - foreach (var frame in original.Parts) + // Smallest first: a frame can only hold smaller parts, so its candidate inner bundles + // already exist, and small holes take small inserts before large holes are tried. + var frames = original.Parts.Where(p => geometry[p.Id] is { Cutouts.Count: > 0 }) + .OrderBy(p => System.Math.Abs(geometry[p.Id]!.Perimeter.Area())) + .ToArray(); + foreach (var frame in frames) { token.ThrowIfCancellationRequested(); if (bundles.Count >= MaxBundlesPerJob) break; - var profile = geometry[frame.Id]; - if (profile == null || profile.Cutouts.Count == 0) - continue; + var profile = geometry[frame.Id]!; var proxies = new List(); byFrame[frame.Id] = proxies; for (var instance = 0; instance < frame.Quantity; instance++) @@ -66,22 +86,25 @@ internal sealed class CutoutPipelinePrepass for (var hole = 0; hole < profile.Cutouts.Count; hole++) { token.ThrowIfCancellationRequested(); - // Flat bundles: frames cannot simultaneously be inserts in another bundle. - var candidates = original.Parts + var eligible = offers .Where(p => p.Id != frame.Id && available[p.Id] > 0 && p.Priority == frame.Priority - && geometry[p.Id] is { Cutouts.Count: 0 }) + && (!bundles.TryGetValue(p.Id, out var b) + || (b.FrameId != frame.Id && b.Levels < MaxLevels))) .Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id], p.Priority, LocalRotation(p.Rotation, angle))) .ToArray(); - if (candidates.Length == 0) + var flat = eligible.Where(p => geometry[p.Id] is { Cutouts.Count: 0 }).ToArray(); + var nested = CutoutCirclePacker.RoundHole(profile.Cutouts[hole]) == null + ? Array.Empty() + : eligible.Where(p => round[p.Id] && geometry[p.Id]!.Cutouts.Count > 0).ToArray(); + if (flat.Length == 0 && nested.Length == 0) break; - foreach (var pose in CutoutRouter.Fill(frame, hole, candidates, spacing, token)) + foreach (var pose in Propose(frame, hole, flat, nested)) { token.ThrowIfCancellationRequested(); var source = geometry[pose.PartId]!; - var policy = original.Parts.Single(p => p.Id == pose.PartId).Rotation; - if (!policy.Allows(angle + pose.Rotation) + if (!requirements[pose.PartId].Rotation.Allows(angle + pose.Rotation) || local.Any(other => !NestLayoutCheck.Clears( geometry[other.PartId]!, other, source, pose, spacing))) continue; @@ -89,7 +112,7 @@ internal sealed class CutoutPipelinePrepass available[pose.PartId]--; } } - if (local.Count <= (chosen?.Count ?? 0)) + if (Weight(local) <= Weight(chosen ?? Array.Empty())) continue; chosen = local.ToArray(); frameAngle = angle; @@ -97,14 +120,21 @@ internal sealed class CutoutPipelinePrepass if (chosen == null || chosen.Count == 0) break; var proxyId = $"__cutout-proxy-{frame.Id}-{instance}"; - if (remaining.ContainsKey(proxyId) || bundles.ContainsKey(proxyId)) + if (requirements.ContainsKey(proxyId)) throw new InvalidOperationException("Cutout proxy ID collides with a requirement."); - bundles.Add(proxyId, new Bundle(frame.Id, chosen)); - proxies.Add(new NestJobPart(proxyId, frame.Geometry, 1, frame.Priority, - RotationPolicy.Fixed(frameAngle))); + var levels = 1 + chosen.Max(p => bundles.TryGetValue(p.PartId, out var b) ? b.Levels : 1); + bundles.Add(proxyId, new Bundle(frame.Id, chosen, levels, 1 + Weight(chosen))); + var proxy = new NestJobPart(proxyId, frame.Geometry, 1, frame.Priority, + RotationPolicy.Fixed(frameAngle)); + proxies.Add(proxy); remaining[frame.Id]--; foreach (var pose in chosen) remaining[pose.PartId]--; + offers.Add(proxy); + requirements.Add(proxyId, proxy); + geometry.Add(proxyId, profile); + round.Add(proxyId, round[frame.Id]); + remaining.Add(proxyId, 1); } } if (bundles.Count == 0) @@ -112,14 +142,36 @@ internal sealed class CutoutPipelinePrepass var transformed = new List(); foreach (var part in original.Parts) { + // A proxy another frame took as an insert is reserved inside that frame's bundle. if (byFrame.TryGetValue(part.Id, out var proxies)) - transformed.AddRange(proxies); + transformed.AddRange(proxies.Where(p => remaining[p.Id] > 0)); if (remaining[part.Id] > 0) transformed.Add(new NestJobPart(part.Id, part.Geometry, remaining[part.Id], part.Priority, part.Rotation)); } return new CutoutPipelinePrepass(new NestJob(transformed, original.Plates, original.Options), bundles); + + // Ties keep the flat router's proposal, which predates nesting. + IReadOnlyList Propose(NestJobPart frame, int hole, + NestJobPart[] flat, NestJobPart[] nested) + { + IReadOnlyList poses = Array.Empty(); + if (nested.Length > 0) + poses = CutoutCirclePacker.Fill(frame, hole, + flat.Where(p => round[p.Id]).Concat(nested).ToArray(), spacing, token); + // Seating every flat copy as a disc leaves the slower proposals nothing to beat. + var flatIds = flat.Select(p => p.Id).ToHashSet(StringComparer.Ordinal); + if (flat.Length == 0 + || poses.Count(p => flatIds.Contains(p.PartId)) == flat.Sum(p => (long)p.Quantity)) + return poses; + var router = CutoutRouter.Fill(frame, hole, flat, spacing, token); + return Weight(router) >= Weight(poses) ? router : poses; + } + + // Physical parts a proposal places: a nested bundle counts its frame and contents. + int Weight(IReadOnlyList poses) => + poses.Sum(p => bundles.TryGetValue(p.PartId, out var b) ? b.Parts : 1); } private static RotationPolicy LocalRotation(RotationPolicy original, double frameAngle) => @@ -139,11 +191,25 @@ internal sealed class CutoutPipelinePrepass var placed = engineResult.Plates.SelectMany(p => p.Placements) .Select(p => p.PartId).ToHashSet(StringComparer.Ordinal); var released = new Dictionary(StringComparer.Ordinal); - foreach (var (proxyId, bundle) in bundles) - if (!placed.Contains(proxyId)) - foreach (var insert in bundle.LocalInserts) - released[insert.PartId] = released.GetValueOrDefault(insert.PartId) + 1; + // Nested proxies are never offered to the engine; their outermost frame releases them. + foreach (var part in EngineJob.Parts) + if (bundles.TryGetValue(part.Id, out var bundle) && !placed.Contains(part.Id)) + Release(bundle); return released; + + void Release(Bundle bundle) + { + foreach (var insert in bundle.LocalInserts) + { + if (bundles.TryGetValue(insert.PartId, out var nested)) + { + released[nested.FrameId] = released.GetValueOrDefault(nested.FrameId) + 1; + Release(nested); + } + else + released[insert.PartId] = released.GetValueOrDefault(insert.PartId) + 1; + } + } } /// Reject untrusted accounting before expansion. A failed trial is never bindable, @@ -222,16 +288,7 @@ internal sealed class CutoutPipelinePrepass poses.Add((pose.PartId, pose.X, pose.Y, pose.Rotation)); continue; } - // Inserts precede their frame: cutting the frame's cutout releases the slug - // each insert is cut from, so placed order is the safe cutting order. - var sine = System.Math.Sin(pose.Rotation); - var cosine = System.Math.Cos(pose.Rotation); - foreach (var local in bundle.LocalInserts) - poses.Add((local.PartId, - pose.X + local.X * cosine - local.Y * sine, - pose.Y + local.X * sine + local.Y * cosine, - pose.Rotation + local.Rotation)); - poses.Add((bundle.FrameId, pose.X, pose.Y, pose.Rotation)); + Expand(bundle, pose.X, pose.Y, pose.Rotation, poses); } // Canonical stock reference, not an engine-supplied object with altered settings. var stock = original.Plates.Single(s => s.Id == sheet.StockId); @@ -248,4 +305,23 @@ internal sealed class CutoutPipelinePrepass expanded = builder.Build(result.StopReason); return true; } + + // Inserts precede their frame at every level: cutting a frame's cutout releases the slug + // each insert is cut from, so placed order (innermost first) is the safe cutting order. + private void Expand(Bundle bundle, double x, double y, double rotation, + List<(string PartId, double X, double Y, double Rotation)> poses) + { + var sine = System.Math.Sin(rotation); + var cosine = System.Math.Cos(rotation); + foreach (var local in bundle.LocalInserts) + { + var insertX = x + local.X * cosine - local.Y * sine; + var insertY = y + local.X * sine + local.Y * cosine; + if (bundles.TryGetValue(local.PartId, out var nested)) + Expand(nested, insertX, insertY, rotation + local.Rotation, poses); + else + poses.Add((local.PartId, insertX, insertY, rotation + local.Rotation)); + } + poses.Add((bundle.FrameId, x, y, rotation)); + } } diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 166968cf..ec714700 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -210,11 +210,18 @@ proposals win ties. closed-cutout frames. It considers legal frame rotations without outer-perimeter symmetry reduction, shifts insert rotation policies into the local frame, and reserves certified router poses by original requirement ID. It can fill multiple holes and repeated/different frame -requirements; inserts with a different priority or their own cutouts stay independent, so -bundles remain flat. It uses the largest offered spacing for every bundle because the current +requirements; inserts with a different priority stay independent. Frames are bundled smallest +first, so a larger frame's round cutout can take, through `CutoutCirclePacker` only, a round part +with its own cutouts (a washer with an empty bore) or an earlier bundle as one insert: a flange +holds a washer that holds a smaller washer that holds a slug. Chains stop at four parts deep +(`CutoutPipelinePrepass.MaxLevels`). Lattice and NFP proposals never take such inserts, since they +could seat a part in a ring's bore or over a nested bundle's reserved inserts; non-round frames +with cutouts stay independent. A frame's proposals are compared by physical parts placed, a nested +bundle counting its frame and contents, and ties keep the flat router's proposal. It uses the largest offered spacing for every bundle because the current job contract cannot condition insert demand on the stock selected later. Remaining demand is -offered to the selected engine once. Only actually placed proxies expand to original frame and -insert identities. An unplaced proxy's reservations return to the original unplaced demand; +offered to the selected engine once. Only actually placed proxies expand, recursively, to original frame and +insert identities. An unplaced proxy's reservations, including every nested frame and insert, +return to the original unplaced demand; they cannot be packed retroactively in that same solve. Engine indices and metadata are checked, then every physical pose, stock, rotation, quantity and fulfillment is independently validated before binding caller drawings. Forged or geometrically invalid composites return no bindable @@ -239,7 +246,8 @@ offer) and returns: So a job with cutouts may take up to two engine solves; a job without eligible frames takes one, exactly as before. Expanded sheets list each frame's inserts immediately before the frame, -so the committed part order cuts every insert before the cutout that would release its slug. +at every level (innermost first), so the committed part order cuts every insert before the cutout +that would release its slug. Leads are not changed: pre-post verification still warns when a host's hole lead or an insert's lead meets the other part, or when a later reorder (by hand or Auto Sequence) puts the host first (see [pre-post verification](post-verification.md)); Plan Cutting cuts each insert before