From e897258a85ad20bba59f241a72f2f720ac37df72 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 20:36:43 -0400 Subject: [PATCH] feat(cutouts): reconcile repeated frames and inserts through one solve --- .../Cutouts/CutoutPipelinePreviewTests.cs | 445 ++++++++++++++++++ .../Jobs/Cutouts/CutoutLatticeFill.cs | 5 +- .../Jobs/Cutouts/CutoutPipelinePrepass.cs | 197 +++++--- OpenNest.Engine/Jobs/NestPipeline.cs | 4 +- .../Engine/CutoutPipelinePersistenceTests.cs | 73 +++ docs/nesting-engines.md | 32 +- 6 files changed, 679 insertions(+), 77 deletions(-) create mode 100644 OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs index 7bd7e27..9a273e8 100644 --- a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs @@ -6,6 +6,10 @@ using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes; namespace OpenNest.Engine.Tests.Jobs.Cutouts; +[CollectionDefinition("Cutout pipeline registry", DisableParallelization = true)] +public sealed class CutoutPipelineRegistryCollection { } + +[Collection("Cutout pipeline registry")] public class CutoutPipelinePreviewTests { private static NestPipelineRequest Request(string engine = "Irregular") @@ -84,6 +88,8 @@ public class CutoutPipelinePreviewTests Assert.False(result.CanKeep); Assert.Empty(result.Plates); Assert.Contains(result.Violations, v => v.Contains("did not complete")); + Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 0, 1), + (f.Requested, f.Placed, f.Unplaced))); Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested); AssertCannotCommit(result, request); } @@ -289,6 +295,445 @@ public class CutoutPipelinePreviewTests result.Plates.Single().Parts[2].BaseDrawing); } + [Fact] + public void RepeatedFrameReservationsExpandOnTwoPhysicalSheets() + { + var request = Request("stub"); + request.Items[0].Quantity = 2; + request.Items[1].Quantity = 2; + request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6); + request = request with + { + Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }, + Options = new NestJobOptions(maxPlates: 2) + }; + var calls = 0; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + calls++; + var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray(); + Assert.Equal(2, proxies.Length); + Assert.Equal(2, job.Parts.Count); + var builder = new NestJobResultBuilder(job); + foreach (var proxy in proxies) + builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) }); + return builder.Build(NestJobStopReason.Completed); + }), "stub", request); + Assert.Equal(1, calls); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(2, result.Raw.Plates.Count); + Assert.All(result.Raw.Plates, plate => Assert.Equal(new[] { "part-0", "part-1" }, + plate.Placements.Select(p => p.PartId))); + Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 2, 0), + (f.Requested, f.Placed, f.Unplaced))); + Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements) + .Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex)); + } + + [Fact] + public void UnplacedFrameReleasesInsertDemandWithoutABindablePartialProposal() + { + var request = Request("stub"); + request.Items[0].Quantity = 2; + request.Items[1].Quantity = 2; + request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6); + request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) } }; + var calls = 0; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + calls++; + var first = job.Parts.Single(p => p.Id.EndsWith("-0")); + var builder = new NestJobResultBuilder(job); + builder.AddSheet(job.Plates[0], new[] { (first.Id, 11.0, 11.0, 0.0) }); + return builder.Build(NestJobStopReason.NoPlacementFound); + }), "stub", request); + Assert.Equal(1, calls); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Equal(NestJobStatus.Incomplete, result.Raw.Status); + Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 1, 1), + (f.Requested, f.Placed, f.Unplaced))); + Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId)); + AssertCannotCommit(result, request); + } + + [Fact] + public void DifferentFrameRequirementsShareScarceInsertsByIdentity() + { + var initial = Request("stub"); + var frame2 = new Drawing("second-frame", TestShapes.Ring(18, 10)); + initial.Items[1].Quantity = 2; + initial.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6); + var request = initial with + { + Items = new[] { initial.Items[0], + new NestItem { Drawing = frame2, Quantity = 1 }, initial.Items[1] }, + Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) } + }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray(); + Assert.Equal(2, proxies.Length); + Assert.Equal(2, job.Parts.Count); + var builder = new NestJobResultBuilder(job); + foreach (var proxy in proxies) + builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) }); + return builder.Build(NestJobStopReason.Completed); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(new[] { "part-0", "part-2", "part-1", "part-2" }, + result.Raw.Plates.SelectMany(p => p.Placements).Select(p => p.PartId)); + Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements) + .Where(p => p.PartId == "part-2").Select(p => p.InstanceIndex)); + } + + [Fact] + public void FixedQuarterTurnComposesLocalInsertRotationAndTranslation() + { + var frame = TestShapes.Rectangle(20, 20); + frame.MoveTo(5, 5); + frame.LineTo(5, 15); + frame.LineTo(15, 15); + frame.LineTo(15, 5); + frame.LineTo(5, 5); + var quarter = System.Math.PI / 2; + var request = new NestPipelineRequest("stub", new[] { + new NestItem { Drawing = new Drawing("frame", frame), Quantity = 1, + StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter }, + new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1, + StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter } }, + new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) }); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = Assert.Single(job.Parts); + Assert.True(proxy.Rotation.Allows(quarter)); + Assert.False(proxy.Rotation.Allows(0)); + return Result(job, new NestJobPlacement(proxy.Id, 0, 21, 1, quarter)); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + var poses = result.Raw.Plates.Single().Placements; + Assert.Equal(quarter, poses[0].Rotation, 8); + Assert.Equal(quarter, poses[1].Rotation, 8); + // Independent square-hole oracle, not the production clearance predicate. + var localX = poses[1].Y - poses[0].Y; + var localY = -(poses[1].X - poses[0].X); + foreach (var dx in new[] { 0.0, 3.0 }) + foreach (var dy in new[] { 0.0, 3.0 }) + { + Assert.InRange(localX + dx, 5.25, 14.75); + Assert.InRange(localY + dy, 5.25, 14.75); + } + } + + [Fact] + public void HeterogeneousStockUsesConservativeSpacingAndOriginalQuadrant() + { + var request = Request("stub") with + { + Stock = new[] { + new NestPlateStock("tight", new Size(22, 22), 1, 0.25), + new NestPlateStock("wide", new Size(22, 22), 1, 0.6, quadrant: 3) } + }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = Assert.Single(job.Parts); + var builder = new NestJobResultBuilder(job); + builder.AddSheet(job.Plates[1], new[] { (proxy.Id, -11.0, -11.0, 0.0) }); + return builder.Build(NestJobStopReason.Completed); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal("wide", result.Raw.Plates.Single().StockId); + Assert.Equal(3, result.Plates.Single().Stock.Quadrant); + Assert.Equal(0.6, result.Plates.Single().Stock.PartSpacing); + Assert.Equal(2, result.Plates.Single().Parts.Count); + } + + [Fact] + public void RegisteredPluginRunsTheSamePhysicalExpansionBoundary() + { + var name = "CutoutPlugin-" + Guid.NewGuid().ToString("N"); + var calls = 0; + NestingEngineRegistry.Register(name, "test plugin", () => new Stub(job => + { + calls++; + var proxy = Assert.Single(job.Parts); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0)); + })); + try + { + var result = NestPipeline.RunCutoutPreview(Request(name)); + Assert.Equal(1, calls); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(new[] { "part-0", "part-1" }, + result.Raw.Plates.Single().Placements.Select(p => p.PartId)); + } + finally + { + var registry = Assert.IsType>(NestingEngineRegistry.AvailableEngines); + Assert.Equal(1, registry.RemoveAll(info => info.Name == name)); + } + } + + [Fact] + public void TwoCutoutsReserveDifferentCopiesWithinOneFrame() + { + var program = TestShapes.Rectangle(20, 20); + foreach (var origin in new[] { 2.0, 12.0 }) + { + program.MoveTo(origin, origin); + program.LineTo(origin, origin + 6); + program.LineTo(origin + 6, origin + 6); + program.LineTo(origin + 6, origin); + program.LineTo(origin, origin); + } + var frame = new Drawing("two-hole-frame", program); + var insert = new Drawing("insert", TestShapes.Rectangle(4, 4)); + var request = new NestPipelineRequest("stub", new[] { + new NestItem { Drawing = frame, Quantity = 1 }, + new NestItem { Drawing = insert, Quantity = 2 } }, + new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) }); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = Assert.Single(job.Parts); + return Result(job, new NestJobPlacement(proxy.Id, 0, 1, 1, 0)); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + var inserts = result.Raw.Plates.Single().Placements.Skip(1).ToArray(); + Assert.Equal(2, inserts.Length); + Assert.All(inserts, p => + { + var localX = p.X - 1; + var localY = p.Y - 1; + Assert.Contains(new[] { 2.0, 12.0 }, origin => + localX >= origin + 0.25 && localX + 4 <= origin + 5.75 + && localY >= origin + 0.25 && localY + 4 <= origin + 5.75); + }); + } + + [Fact] + public void DifferentPrioritiesRemainIndependentInsteadOfElevatingAnInsert() + { + var request = Request("stub"); + request.Items[1].Priority = 3; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-")); + return Result(job); + }), "stub", request); + Assert.True(result.CanKeep); + Assert.Empty(result.Plates); + } + + [Theory] + [InlineData(1, 11.0, 11.0)] + [InlineData(2, -11.0, 11.0)] + [InlineData(3, -11.0, -11.0)] + [InlineData(4, 11.0, -11.0)] + public void AllQuadrantsKeepPhysicalStockAndExpansion(int quadrant, double x, double y) + { + var request = Request("stub") with + { + Stock = new[] { + new NestPlateStock("sheet", new Size(22, 22), 1, 0.25, + new Spacing(0.5, 0.5), quadrant) }, + Options = new NestJobOptions(maxPlates: 1) + }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, x, y, 0))), + "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(quadrant, result.Plates.Single().Stock.Quadrant); + Assert.Equal((x, y), (result.Raw.Plates.Single().Placements[0].X, + result.Raw.Plates.Single().Placements[0].Y)); + } + + [Fact] + public void CancellationAfterAnEngineIgnoresStopDiscardsEveryBundle() + { + var request = Request("stub"); + using var cts = new CancellationTokenSource(); + Assert.Throws(() => NestPipeline.RunCutoutPreview( + new Stub(job => + { + var proxy = Assert.Single(job.Parts); + cts.Cancel(); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0)); + }), "stub", request, token: cts.Token)); + Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested)); + } + + [Fact] + public void CompleteStatusWithMissingProxyIsUntrustedAndNotBindable() + { + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult( + NestJobStatus.Complete, NestJobStopReason.Completed, + Array.Empty(), + job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)), + job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)))), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("accounting")); + AssertCannotCommit(result, request); + } + + [Fact] + public void SuccessfulCompositeReportsOnlyValidatedPhysicalCommitCounts() + { + var request = Request("stub"); + var progress = new CollectProgress(); + var result = NestPipeline.RunCutoutPreview(new ReportingStub(), "stub", request, progress); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(new[] { NestJobStage.EvaluatingCandidate, NestJobStage.PlateCommitted }, + progress.Events.Select(e => e.Stage)); + Assert.Equal(2, progress.Events.Last().CommittedParts); + Assert.Equal(1, progress.Events.Last().CommittedPlates); + } + + private sealed class ReportingStub : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) + { + var proxy = Assert.Single(job.Parts); + progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, + job.Plates[0].Id, 0, 0, 0)); + progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, + job.Plates[0].Id, 0, 1, 1)); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0)); + } + } + + [Fact] + public void AutomaticBundleRetainsStockFeasibleQuarterTurn() + { + var program = TestShapes.Rectangle(20, 10); + program.MoveTo(2, 2); + program.LineTo(2, 8); + program.LineTo(18, 8); + program.LineTo(18, 2); + program.LineTo(2, 2); + var request = new NestPipelineRequest("Irregular", new[] { + new NestItem { Drawing = new Drawing("long-frame", program), Quantity = 1 }, + new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1 } }, + new[] { new NestPlateStock("narrow", new Size(22, 12), 1, 0.25) }); + var result = NestPipeline.RunCutoutPreview(request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + var frame = result.Raw.Plates.Single().Placements[0]; + Assert.Equal(System.Math.PI / 2, frame.Rotation, 7); + Assert.Equal(new[] { "part-0", "part-1" }, + result.Raw.Plates.Single().Placements.Select(p => p.PartId)); + } + + [Fact] + public void ChangedLiveInsertDuringSolveNeverBindsUncheckedComposite() + { + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + request.Items[1].Drawing.Program = TestShapes.Rectangle(40, 40); + var proxy = Assert.Single(job.Parts); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0)); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("insert")); + AssertCannotCommit(result, request); + } + + [Fact] + public void NondefaultOptionsPassUnchangedAndPlateCapRejectsOtherwiseValidOutput() + { + var request = Request("stub"); + request.Items[0].Quantity = 2; + request.Items[1].Quantity = 2; + request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6); + var options = new NestJobOptions("Strip", maxPlates: 1, + salvageRate: 0.4, minimumSalvageDimension: 2); + request = request with + { + Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }, + Options = options + }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + Assert.Same(options, job.Options); + Assert.Equal("Strip", job.Options.PlacementStrategy); + Assert.Equal(0.4, job.Options.SalvageRate); + var builder = new NestJobResultBuilder(job); + foreach (var proxy in job.Parts) + builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) }); + return builder.Build(NestJobStopReason.Completed); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("MaxPlates")); + AssertCannotCommit(result, request); + } + + [Fact] + public void ObliqueFramePreservesLegalGlobalInsertRotationAndWallClearance() + { + var program = TestShapes.Rectangle(20, 20); + program.MoveTo(5, 5); + program.LineTo(5, 15); + program.LineTo(15, 15); + program.LineTo(15, 5); + program.LineTo(5, 5); + const double angle = 0.3; + var request = new NestPipelineRequest("stub", new[] { + new NestItem { Drawing = new Drawing("frame", program), Quantity = 1, + StepAngle = 1, RotationStart = angle, RotationEnd = angle }, + new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), + Quantity = 1, StepAngle = 1, RotationStart = angle, RotationEnd = angle } }, + new[] { new NestPlateStock("sheet", new Size(40, 40), 1, 0.25) }); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, 15, 5, angle))), + "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + var poses = result.Raw.Plates.Single().Placements; + Assert.Equal(angle, poses[1].Rotation, 8); + var dx = poses[1].X - poses[0].X; + var dy = poses[1].Y - poses[0].Y; + var cosine = System.Math.Cos(angle); + var sine = System.Math.Sin(angle); + var localX = dx * cosine + dy * sine; + var localY = -dx * sine + dy * cosine; + foreach (var x in new[] { 0.0, 3.0 }) + foreach (var y in new[] { 0.0, 3.0 }) + { + Assert.InRange(localX + x, 5.25, 14.75); + Assert.InRange(localY + y, 5.25, 14.75); + } + } + + [Fact] + public void LargeDemandLeavesExcessFramesAndInsertsIndependentAfterBoundedPreparation() + { + var frame = TestShapes.Rectangle(20, 20); + frame.MoveTo(5, 5); + frame.LineTo(5, 15); + frame.LineTo(15, 15); + frame.LineTo(15, 5); + frame.LineTo(5, 5); + var request = new NestPipelineRequest("stub", new[] { + new NestItem { Drawing = new Drawing("frame", frame), Quantity = 40, + StepAngle = 1, RotationStart = 0, RotationEnd = 0 }, + new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(6, 6)), + Quantity = 40, StepAngle = 1, RotationStart = 0, RotationEnd = 0 } }, + new[] { new NestPlateStock("sheet", new Size(22, 22), 40, 0.25) }); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + Assert.Equal(32, job.Parts.Count(p => p.Id.StartsWith("__cutout-proxy-"))); + Assert.Equal(8, job.Parts.Single(p => p.Id == "part-0").Quantity); + Assert.Equal(8, job.Parts.Single(p => p.Id == "part-1").Quantity); + return Result(job); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.All(result.Raw.Fulfillment, f => Assert.Equal((40, 0, 40), + (f.Requested, f.Placed, f.Unplaced))); + } + [Fact] public void OrdinaryPipelineIsStillUnchanged() { diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs index 44b903c..c9a0c1b 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs @@ -22,8 +22,9 @@ namespace OpenNest.Engine.Jobs.Cutouts; /// /// /// Suited to many small copies in a large cutout; a few large inserts belong to NFP placement. -/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Not wired into -/// any engine or pipeline yet: placing parts in cutouts waits on containment-aware cutting order. +/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Used by the +/// internal pipeline cutout preview, but not enabled for normal whole-job calls; +/// production placement waits on cutting/post safety. /// internal static class CutoutLatticeFill { diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs index 8d8f998..f90fc48 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs @@ -3,23 +3,23 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using OpenNest.Engine.Jobs.Adapters; namespace OpenNest.Engine.Jobs.Cutouts; -/// Internal-only single-frame composite trial. No normal pipeline caller enables this. +/// Internal-only frame bundles. No normal pipeline caller enables this. internal sealed class CutoutPipelinePrepass { - private readonly string proxyId; - private readonly string frameId; - private readonly IReadOnlyList localInserts; + // 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); + private readonly IReadOnlyDictionary bundles; - private CutoutPipelinePrepass(NestJob engineJob, string proxyId, string frameId, - IReadOnlyList localInserts) + private CutoutPipelinePrepass(NestJob engineJob, IReadOnlyDictionary bundles) { EngineJob = engineJob; - this.proxyId = proxyId; - this.frameId = frameId; - this.localInserts = localInserts; + this.bundles = bundles; } internal NestJob EngineJob { get; } @@ -29,51 +29,121 @@ internal sealed class CutoutPipelinePrepass token.ThrowIfCancellationRequested(); if (original.Plates.Count == 0) return null; - // A single fixed-zero proxy is safe even when a symmetric outer perimeter causes - // an engine to deduplicate rotations. General orientation intersections are deferred. + // One reservation vector works for every offered stock. Current jobs cannot + // represent stock-conditional insert quantities; use the largest spacing. var spacing = original.Plates.Max(p => p.PartSpacing); - foreach (var frame in original.Parts.Where(p => p.Quantity == 1 && p.Rotation.Allows(0))) + var remaining = original.Parts.ToDictionary(p => p.Id, p => p.Quantity); + var geometry = original.Parts.ToDictionary(p => p.Id, p => JobPartGeometry.TryRead(p.Geometry)); + var bundles = new Dictionary(StringComparer.Ordinal); + var byFrame = new Dictionary>(StringComparer.Ordinal); + foreach (var frame in original.Parts) { - var geometry = JobPartGeometry.TryRead(frame.Geometry); - if (geometry == null || geometry.Cutouts.Count == 0) + token.ThrowIfCancellationRequested(); + if (bundles.Count >= MaxBundlesPerJob) + break; + var profile = geometry[frame.Id]; + if (profile == null || profile.Cutouts.Count == 0) continue; - var candidates = original.Parts.Where(p => p.Id != frame.Id).ToArray(); - if (candidates.Length == 0) - continue; - for (var hole = 0; hole < geometry.Cutouts.Count; hole++) + var proxies = new List(); + byFrame[frame.Id] = proxies; + for (var instance = 0; instance < frame.Quantity; instance++) { token.ThrowIfCancellationRequested(); - var poses = CutoutRouter.Fill(frame, hole, candidates, spacing, token); - if (poses.Count == 0) - continue; - var reserved = poses.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count()); - var proxyId = "__cutout-proxy-" + frame.Id; - if (original.Parts.Any(p => p.Id == proxyId)) - throw new InvalidOperationException("Cutout proxy ID collides with a requirement."); - var transformed = new List(); - foreach (var part in original.Parts) + if (bundles.Count >= MaxBundlesPerJob) + break; + IReadOnlyList? chosen = null; + double frameAngle = 0; + // Outer-perimeter symmetry cannot deduplicate asymmetric cutouts. + foreach (var angle in frame.Rotation.EnumerateAngles(maxSamples: 16)) { - if (part.Id == frame.Id) - transformed.Add(new NestJobPart(proxyId, part.Geometry, 1, part.Priority, - RotationPolicy.Fixed(0))); - else + token.ThrowIfCancellationRequested(); + if (!FitsOfferedStock(frame, angle, original.Plates)) + continue; + var local = new List(); + var available = new Dictionary(remaining); + for (var hole = 0; hole < profile.Cutouts.Count; hole++) { - var remaining = part.Quantity - reserved.GetValueOrDefault(part.Id); - if (remaining > 0) - transformed.Add(new NestJobPart(part.Id, part.Geometry, remaining, - part.Priority, part.Rotation)); + token.ThrowIfCancellationRequested(); + // Flat bundles: frames cannot simultaneously be inserts in another bundle. + var candidates = original.Parts + .Where(p => p.Id != frame.Id && available[p.Id] > 0 + && p.Priority == frame.Priority + && geometry[p.Id] is { Cutouts.Count: 0 }) + .Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id], + p.Priority, LocalRotation(p.Rotation, angle))) + .ToArray(); + if (candidates.Length == 0) + break; + foreach (var pose in CutoutRouter.Fill(frame, hole, candidates, spacing, token)) + { + token.ThrowIfCancellationRequested(); + var source = geometry[pose.PartId]!; + var policy = original.Parts.Single(p => p.Id == pose.PartId).Rotation; + if (!policy.Allows(angle + pose.Rotation) + || local.Any(other => !NestLayoutCheck.Clears( + geometry[other.PartId]!, other, source, pose, spacing))) + continue; + local.Add(pose); + available[pose.PartId]--; + } } + if (local.Count <= (chosen?.Count ?? 0)) + continue; + chosen = local.ToArray(); + frameAngle = angle; } - return new CutoutPipelinePrepass(new NestJob(transformed, original.Plates, - original.Options), proxyId, frame.Id, poses); + if (chosen == null || chosen.Count == 0) + break; + var proxyId = $"__cutout-proxy-{frame.Id}-{instance}"; + if (remaining.ContainsKey(proxyId) || bundles.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))); + remaining[frame.Id]--; + foreach (var pose in chosen) + remaining[pose.PartId]--; } } - return null; + if (bundles.Count == 0) + return null; + var transformed = new List(); + foreach (var part in original.Parts) + { + if (byFrame.TryGetValue(part.Id, out var proxies)) + transformed.AddRange(proxies); + 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); + } + + private static RotationPolicy LocalRotation(RotationPolicy original, double frameAngle) => + original.Kind switch + { + RotationPolicyKind.Automatic => RotationPolicy.Automatic, + RotationPolicyKind.Fixed => RotationPolicy.Fixed(original.Start - frameAngle, + original.Allow180Equivalent), + _ => RotationPolicy.BoundedSweep(original.Start - frameAngle, + original.End - frameAngle, original.Step, original.Allow180Equivalent), + }; + + private static bool FitsOfferedStock(NestJobPart frame, double angle, + IReadOnlyList stock) + { + var part = new Part(DrawingJobMapper.CreateDrawing(frame)); + part.Rotate(angle); + var bounds = NestLayoutCheck.MaterialBounds(part); + return stock.Any(s => s.Quantity != 0 && s.Fits(bounds.Length, bounds.Width, + NestTolerances.WorkAreaSlack)); } /// Reject untrusted accounting before expansion. A failed trial is never bindable, /// including with allowInvalid. Only actually placed proxies consume reserved inserts. - internal bool TryExpand(NestJob original, NestJobResult result, out NestJobResult? expanded, + internal bool TryExpand(NestJob original, NestJobResult result, CancellationToken token, + out NestJobResult? expanded, out string failure) { expanded = null; @@ -81,10 +151,12 @@ internal sealed class CutoutPipelinePrepass var parts = EngineJob.Parts.ToDictionary(p => p.Id); var counts = parts.ToDictionary(p => p.Key, _ => 0); var indices = parts.ToDictionary(p => p.Key, _ => new HashSet()); - var used = EngineJob.Plates.ToDictionary(s => s.Id, _ => 0); + var stockById = EngineJob.Plates.ToDictionary(s => s.Id, StringComparer.Ordinal); + var used = stockById.ToDictionary(s => s.Key, _ => 0, StringComparer.Ordinal); var expectedIndex = 0; foreach (var sheet in result.Plates) { + token.ThrowIfCancellationRequested(); if (sheet.PlateIndex != expectedIndex++ || sheet.Placements.Count == 0 || !used.ContainsKey(sheet.StockId)) return false; @@ -97,22 +169,32 @@ internal sealed class CutoutPipelinePrepass return false; } } - if (indices.Any(row => row.Value.Any(index => index >= counts[row.Key]))) - return false; + foreach (var row in indices) + { + token.ThrowIfCancellationRequested(); + if (row.Value.Any(index => index >= counts[row.Key])) + return false; + } if (result.Fulfillment.Count != parts.Count || result.StockUsage.Count != used.Count) return false; + var seenParts = new HashSet(StringComparer.Ordinal); foreach (var row in result.Fulfillment) + { + token.ThrowIfCancellationRequested(); if (row.PartId == null || !parts.TryGetValue(row.PartId, out var part) + || !seenParts.Add(row.PartId) || row.Requested != part.Quantity || row.Placed != counts[row.PartId] - || row.Unplaced != part.Quantity - counts[row.PartId] - || result.Fulfillment.Count(r => r.PartId == row.PartId) != 1) + || row.Unplaced != part.Quantity - counts[row.PartId]) return false; + } + var seenStock = new HashSet(StringComparer.Ordinal); foreach (var row in result.StockUsage) { - var stock = EngineJob.Plates.FirstOrDefault(s => s.Id == row.StockId); - if (stock == null || row.Used != used[row.StockId] - || row.Remaining != stock.Quantity - row.Used - || result.StockUsage.Count(r => r.StockId == row.StockId) != 1) + token.ThrowIfCancellationRequested(); + if (row.StockId == null || !stockById.TryGetValue(row.StockId, out var stock) + || !seenStock.Add(row.StockId) + || row.Used != used[row.StockId] + || row.Remaining != stock.Quantity - row.Used) return false; } var complete = parts.Values.All(p => counts[p.Id] == p.Quantity); @@ -121,27 +203,22 @@ internal sealed class CutoutPipelinePrepass || (!complete && result.StopReason == NestJobStopReason.Completed)) return false; - // An incomplete solve cannot silently reserve inserts or reach the commit boundary. - if (!complete) - { - failure = "Cutout engine did not complete the transformed job"; - return false; - } var builder = new NestJobResultBuilder(original); foreach (var sheet in result.Plates) { + token.ThrowIfCancellationRequested(); var poses = new List<(string PartId, double X, double Y, double Rotation)>(); foreach (var pose in sheet.Placements) { - if (pose.PartId != proxyId) + if (!bundles.TryGetValue(pose.PartId, out var bundle)) { poses.Add((pose.PartId, pose.X, pose.Y, pose.Rotation)); continue; } - poses.Add((frameId, pose.X, pose.Y, pose.Rotation)); + poses.Add((bundle.FrameId, pose.X, pose.Y, pose.Rotation)); var sine = System.Math.Sin(pose.Rotation); var cosine = System.Math.Cos(pose.Rotation); - foreach (var local in localInserts) + 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, @@ -158,12 +235,8 @@ internal sealed class CutoutPipelinePrepass return false; } } + token.ThrowIfCancellationRequested(); expanded = builder.Build(result.StopReason); - if (expanded.Status != NestJobStatus.Complete) - { - expanded = null; - return false; - } return true; } } diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs index 7e33e5e..9cc231e 100644 --- a/OpenNest.Engine/Jobs/NestPipeline.cs +++ b/OpenNest.Engine/Jobs/NestPipeline.cs @@ -181,10 +181,12 @@ public static class NestPipeline // A compound may be physically invalid even when its envelope is legal. // Refuse every unverified trial, including with explicit invalid-result consent. var failure = "Cutout engine output failed reconciliation"; - if (violations.Count == 0 && prepass.TryExpand(job, raw, out var expanded, out failure)) + if (violations.Count == 0 && prepass.TryExpand(job, raw, token, out var expanded, out failure)) { raw = expanded; violations.AddRange(NestLayoutCheck.Violations(job, raw, names, out var physicalKeep)); + if (raw.Status != NestJobStatus.Complete) + violations.Add("Cutout engine did not complete the transformed job; unplaced frames' reserved inserts remain unplaced in the original job"); canKeep = physicalKeep && violations.Count == 0; } else diff --git a/OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs b/OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs new file mode 100644 index 0000000..c62a77e --- /dev/null +++ b/OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs @@ -0,0 +1,73 @@ +using OpenNest.CNC; +using OpenNest.Engine; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Tests.Engine; + +public class CutoutPipelinePersistenceTests +{ + [Fact] + public void ExpandedMultiSheetProposalCommitsAndReloadsAsSeparateDrawings() + { + var frame = new OpenNest.Shapes.RingShape { OuterDiameter = 20, InnerDiameter = 10 } + .GetDrawing(); + frame.Name = "neutral-frame"; + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(6, 0); + program.LineTo(6, 6); + program.LineTo(0, 6); + program.LineTo(0, 0); + var insert = new Drawing("neutral-insert", program); + var request = new NestPipelineRequest("Irregular", new[] { + new NestItem { Drawing = frame, Quantity = 2 }, + new NestItem { Drawing = insert, Quantity = 2 } }, + new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }, + new NestJobOptions(maxPlates: 2)); + var ordinary = NestPipeline.Run(request); + Assert.Contains(ordinary.Raw.Fulfillment, f => f.Unplaced > 0); + var result = NestPipeline.RunCutoutPreview(request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(2, result.Raw.Plates.Count); + Assert.All(result.Raw.Plates, p => Assert.Equal(new[] { "part-0", "part-1" }, + p.Placements.Select(x => x.PartId))); + + var nest = new Nest(); + nest.Drawings.Add(frame); + nest.Drawings.Add(insert); + using (var manager = new PlateManager(nest)) + { + var committed = NestPipelineCommit.ApplyToEmptyPlates(result, manager); + Assert.Equal(2, committed.Count); + Assert.All(committed, plate => Assert.Equal(new[] { frame, insert }, + plate.Parts.Select(p => p.BaseDrawing))); + } + Assert.Equal(2, frame.Quantity.Nested); + Assert.Equal(2, insert.Quantity.Nested); + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + var loaded = new NestReader(new MemoryStream(stream.ToArray())).Read(); + Assert.Equal(2, loaded.Plates.Count); + for (var i = 0; i < loaded.Plates.Count; i++) + { + var expected = nest.Plates[i]; + var actual = loaded.Plates[i]; + Assert.Equal(expected.Size, actual.Size); + Assert.Equal(expected.PartSpacing, actual.PartSpacing); + Assert.Equal(expected.Quadrant, actual.Quadrant); + Assert.Equal(expected.EdgeSpacing, actual.EdgeSpacing); + Assert.Equal(new[] { "neutral-frame", "neutral-insert" }, + actual.Parts.Select(p => p.BaseDrawing.Name)); + for (var j = 0; j < actual.Parts.Count; j++) + { + Assert.Equal(expected.Parts[j].Location.X, actual.Parts[j].Location.X, 6); + Assert.Equal(expected.Parts[j].Location.Y, actual.Parts[j].Location.Y, 6); + Assert.Equal(expected.Parts[j].Rotation, actual.Parts[j].Rotation, 6); + } + } + Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "neutral-frame").Quantity.Nested); + Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "neutral-insert").Quantity.Nested); + } +} diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 049f068..1b95e66 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -97,18 +97,26 @@ NFP first, then Fill if demand remains, taking the higher-count valid proposal. An 0.20 NFP-only rule would lose a second 4-inch square in a 10-inch round hole. Six geometry-only, anonymized real-job probes and neutral ring fixtures informed the search-order hints; these limited cases are not a global density guarantee. -The internal `NestPipeline.RunCutoutPreview` prepares at most one quantity-one frame with one -cutout as a fixed-zero proxy. Reserved inserts are removed from the engine's independent demand; -remaining demand is offered once to the selected engine. Only after a complete, accounted-for -engine result does it expand the proxy back to original requirement IDs, validate every physical -pose, stock and quantity, then bind caller drawings. Incomplete, forged or geometrically invalid -composites return no bindable plates even with invalid-result consent. Candidate evaluation -progress is forwarded with unverified proxy commitment counts zeroed; transformed proxy commits -are hidden. Physical commits are reported only after physical validation. Frames needing other -orientations or repeated frame copies still run through the ordinary unbundled path. This is a -narrow internal integration trial, not a production setting or a cut-ready nest; multi-frame -routing, rotation compatibility, save/reload and cutting/post safety are still pending before -enablement. +The internal `NestPipeline.RunCutoutPreview` builds quantity-one proxy instances for eligible +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 +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; +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. Incomplete, forged or geometrically invalid composites return +no bindable plates even with invalid-result consent. Candidate evaluation progress is forwarded +with unverified proxy commitment counts zeroed; transformed proxy commits are hidden. Physical +commits are reported only after physical validation. Preparation is bounded to 32 bundles +and 16 base sweep-angle samples per frame (automatic quarter-turns are all retained); +remaining copies stay ordinary requirements. Fixed legal proxy orientations are a +conservative search choice, not full engine choice among all legal orientations; unsupported +bundles remain ordinary independent requirements. This is an internal pipeline path, not a +production setting or cut-ready nest. Cutting/post safety remains pending before enablement. ## Renamed engines