diff --git a/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs index 6f0b6f5..5b31db5 100644 --- a/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs +++ b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs @@ -1,4 +1,5 @@ using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.NestingEngines.Rectangles; using OpenNest.Engine.Tests.NestingEngines; using OpenNest.Geometry; @@ -144,4 +145,39 @@ public class ClampRemovalRepairTests Assert.DoesNotContain(sheet.Stock.ClampZones, z => z.Id == "clamp-1"); Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2"); } + // PM c5984c6e (C10): one lane's failure to place is not proof the part cannot fit. The + // fixed-rotation L below fits at the origin with its notch over the clamp (an explicit + // witness passes the layout check), but Rectangles packs its 10 x 10 bounding box and + // cannot place it; no clamp may be removed for it. + [Fact] + public void PartThatFitsClampedInSomeLaneNeverRemovesAClamp() + { + var program = new OpenNest.CNC.Program(); + program.MoveTo(0, 0); + program.LineTo(2, 0); + program.LineTo(2, 8); + program.LineTo(10, 8); + program.LineTo(10, 10); + program.LineTo(0, 10); + program.LineTo(0, 0); + var rail = new ClampRail(1.5, 0.625, new[] { 5.75 }); // clamp-1 spans X [5, 6.5] + var size = new Size(10, 10); + var stock = new NestPlateStock("sheet", size, null, 0, default, 1, null, rail.ResolveFor(size, 1)) + { ClampRail = rail }; + var drawing = new Drawing("notched", program); + + var witnessJob = new NestJob(new[] { DrawingJobMapper.FromItem("p", new NestItem { Drawing = drawing, Quantity = 1 }) }, + new[] { stock }); + var witness = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("p", 0, 0, 0, 0) }) }, + Array.Empty(), Array.Empty()); + Assert.Empty(NestLayoutCheck.Violations(witnessJob, witness)); // fits with the clamp in place + + var result = NestPipeline.Run(new NestPipelineRequest("Rectangles", + new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, + new[] { stock })); + + Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id)); + Assert.All(result.Raw.Plates, sheet => Assert.Single(sheet.Stock.ClampZones)); + } } diff --git a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs index 252ee61..00da7e7 100644 --- a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs +++ b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs @@ -69,9 +69,11 @@ internal static class ClampRemovalRecovery token.ThrowIfCancellationRequested(); - // C10: only a part that fits nowhere with every clamp in place is "blocked". + // C10: only a part that fits nowhere with every clamp in place is "blocked". One + // lane's failure is not proof: a part any built-in lane can place clamped is not. + var lanes = FeasibilityLanes(engine); var blocked = remaining - .Where(x => !FitsAnywhere(x.Part, availableStock, job.Options, engine, token)) + .Where(x => !lanes.Any(lane => FitsAnywhere(x.Part, availableStock, job.Options, lane, token))) .Select(x => x.Part) .ToList(); if (blocked.Count == 0) @@ -131,6 +133,23 @@ internal static class ClampRemovalRecovery return merged; } + /// + /// Engines whose placement of a lone part proves it fits (C10): the caller's own engine, + /// plus the built-in Irregular (NFP, true material) and Rectangles lanes. Default already + /// runs both, so it is enough on its own. + /// + private static IReadOnlyList FeasibilityLanes(INestingEngine engine) + { + if (engine is NestingEngines.Default.DefaultNestingEngine) + return new[] { engine }; + var lanes = new List { engine }; + if (engine is not NestingEngines.Irregular.IrregularNestingEngine) + lanes.Add(new NestingEngines.Irregular.IrregularNestingEngine()); + if (engine is not NestingEngines.Rectangles.RectanglesNestingEngine) + lanes.Add(new NestingEngines.Rectangles.RectanglesNestingEngine()); + return lanes; + } + /// Same stock, quantity reduced by what phase 1 already used (unlimited stays /// unlimited); id/zones unchanged. Used for stock phase 2 may still draw from. private static NestPlateStock ResidualCopy(NestPlateStock stock, IReadOnlyDictionary usedByStock) =>