diff --git a/OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs b/OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs index f78917b6..bd708182 100644 --- a/OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs +++ b/OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs @@ -20,8 +20,14 @@ public class ClampRemovalRecoveryTests private static ClampZone Clamp1() => new("clamp-1", new Box(5.25, 0, 1.5, 0.625)); private static ClampZone Clamp2() => new("clamp-2", new Box(20.25, 0, 1.5, 0.625)); + // The machine rail those zones resolve from: only rail-backed clamps can be removed. + private static readonly ClampRail Rail = new(1.5, 0.625, new[] { 6.0, 21.0 }); + private static NestPlateStock ClampedSheet(params ClampZone[] zones) => - JobBuilder.Stock("sheet", width: 3, length: 23, clampZones: zones); + RailSheet(null, zones); + + private static NestPlateStock RailSheet(int? quantity, params ClampZone[] zones) => + new("sheet", new Size(3, 23), quantity, 0, default, 1, null, zones) { ClampRail = Rail }; private static readonly RectanglesNestingEngine Engine = new(); @@ -101,8 +107,7 @@ public class ClampRemovalRecoveryTests // The part fits a fresh clamped sheet on its own; only a single-sheet cap stops the // second copy. That is a capacity limit, not a clamp block, so nothing is removed. var part = JobBuilder.Rectangle("p", w: 12, h: 3, count: 2); - var stock = JobBuilder.Stock("sheet", width: 3, length: 23, quantity: 1, - clampZones: new[] { Clamp1(), Clamp2() }); + var stock = RailSheet(1, Clamp1(), Clamp2()); var job = JobBuilder.Job(new[] { part }, new[] { stock }); var raw = Engine.Solve(job); diff --git a/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs index 07b5177b..6f0b6f59 100644 --- a/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs +++ b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs @@ -20,8 +20,15 @@ public class ClampRemovalRepairTests new ClampZone("clamp-2", new Box(20.25, 0, 1.5, 0.625)), }; + private static readonly ClampRail Rail = new(1.5, 0.625, new[] { 6.0, 21.0 }); + + /// Stock resolved from the machine rail (as NestStockBuilder builds it). private static NestPlateStock Stock(int? quantity = null) => - JobBuilder.Stock("sheet", width: 3, length: 23, quantity: quantity, clampZones: Zones()); + new("sheet", new Size(3, 23), quantity, 0, default, 1, null, Zones()) { ClampRail = Rail }; + + /// The same zones given explicitly, with no rail behind them. + private static NestPlateStock ExplicitStock() => + JobBuilder.Stock("sheet", width: 3, length: 23, clampZones: Zones()); private static NestItem Item(string name, double w, double h, int quantity) => new() { Drawing = new Drawing(name, TestDrawingFactory.Rectangle(w, h)), Quantity = quantity }; @@ -84,4 +91,57 @@ public class ClampRemovalRepairTests Assert.ThrowsAny(() => NestPipeline.Run(new CancelOnSecondCallEngine(source), "cancel", request, token: source.Token)); } + // PM 346d46ed: explicit zones have no rail to name a removed clamp or its time. + [Fact] + public void ExplicitZonesWithoutARailAreNeverRemoved() + { + var result = NestPipeline.Run(Request(ExplicitStock(), Item("wide", 14, 3, 1))); + + Assert.NotEqual(NestJobStatus.Complete, result.Status); + Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id)); + } + + [Fact] + public void RailStockRemovalIsNamedOnACommittedPlateWithoutARail() + { + // The stock comes from a rail-following template; the commit target is an empty + // sheet holding the same zones explicitly. The removal must still be recorded. + var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3)); + var template = new Plate(new Size(3, 23)) { EdgeSpacing = new Spacing(0, 0, 0, 0) }; + template.SetClampRail(Rail); + var nest = new Nest(); + nest.Drawings.Add(drawing); + nest.PlateDefaults.Size = new Size(3, 23); + nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0); + var target = nest.CreatePlate(); + foreach (var zone in Zones()) + target.ClampZones.Add(zone); + Assert.Null(target.ClampRail); + using var manager = new PlateManager(nest); + + var result = NestPipeline.Run(new NestPipelineRequest("Rectangles", + new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, NestStockBuilder.SinglePlate(template))); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + var plate = Assert.Single(NestPipelineCommit.ApplyToEmptyPlates(result, manager)); + + Assert.Equal(new[] { "clamp-1" }, plate.RemovedClamps); + Assert.NotNull(plate.ClampRail); + Assert.Equal(ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes); + } + // PM 6ded8ac4 (d): the nearest-origin tie-break follows distance along the edge, not + // the order the zones happen to be listed in. + [Fact] + public void TieBreakPrefersTheClampNearestTheOriginWhateverTheZoneOrder() + { + var reversed = new NestPlateStock("sheet", new Size(3, 23), null, 0, default, 1, null, + Zones().Reverse().ToArray()) + { ClampRail = Rail }; + + var result = NestPipeline.Run(Request(reversed, Item("wide", 14, 3, 1))); + + Assert.Equal(NestJobStatus.Complete, result.Status); + var sheet = Assert.Single(result.Raw.Plates); + Assert.DoesNotContain(sheet.Stock.ClampZones, z => z.Id == "clamp-1"); + Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2"); + } } diff --git a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs index 6a55dad2..182e6291 100644 --- a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs +++ b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs @@ -58,7 +58,8 @@ public static class DrawingJobMapper plate.Quadrant, cost: null, clampZones: new List(plate.ClampZones) - ); + ) + { ClampRail = plate.ClampRail }; } public static Program ToProgram(PartGeometrySnapshot geometry) diff --git a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs index ed6ddc9a..252ee616 100644 --- a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs +++ b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs @@ -56,7 +56,7 @@ internal static class ClampRemovalRecovery return null; var availableStock = job.Plates.Where(s => s.Quantity != 0).ToList(); - if (!availableStock.Any(s => s.ClampZones.Count > 0)) + if (!availableStock.Any(CanRemoveClamps)) return null; var partsById = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal); @@ -153,7 +153,7 @@ internal static class ClampRemovalRecovery Removal best = null; foreach (var stock in stocks) { - if (stock.ClampZones.Count == 0) + if (!CanRemoveClamps(stock)) continue; token.ThrowIfCancellationRequested(); var found = MinimalRemovalForStock(part, stock, options, engine, token); @@ -165,10 +165,16 @@ internal static class ClampRemovalRecovery return best; } + /// Only clamps resolved from a machine rail can be removed (see + /// ): explicit zones have no clamp to name. + private static bool CanRemoveClamps(NestPlateStock stock) => + stock.ClampRail != null && stock.ClampZones.Count > 0; + /// /// Ascending run size, then ascending start index: the first contiguous run of the - /// stock's own clamp zones (already ordered by rail position) whose removal lets the - /// part fit alone is both the fewest clamps and, among ties, the run nearest the origin. + /// stock's own clamp zones, ordered by distance from the origin along the edge, whose + /// removal lets the part fit alone is both the fewest clamps and, among ties, the run + /// nearest the origin. /// private static Removal MinimalRemovalForStock( NestJobPart part, @@ -178,7 +184,11 @@ internal static class ClampRemovalRecovery CancellationToken token ) { - var zones = stock.ClampZones; + // Placement-frame X is signed from the origin, so |centre X| is the distance along + // the edge in every quadrant. + var zones = stock.ClampZones + .OrderBy(z => System.Math.Abs(z.Bounds.X + z.Bounds.Length / 2)) + .ToList(); for (var run = 1; run <= zones.Count; run++) { for (var start = 0; start + run <= zones.Count; start++) @@ -263,7 +273,8 @@ internal static class ClampRemovalRecovery stock.Quadrant, stock.Cost, plateFrameZones - ); + ) + { ClampRail = stock.ClampRail }; } private static string Suffixed(string stockId, IReadOnlyList removedIds) => diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs index 00f454e0..d00d6b8a 100644 --- a/OpenNest.Engine/Jobs/NestPipelineCommit.cs +++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs @@ -71,7 +71,9 @@ public static class NestPipelineCommit if (SameInPlacementFrame(plate.ClampZones, stock.ClampZones, left, bottom)) return; - var rail = plate.ClampRail; + // A plate without a rail still takes the rail its stock was resolved from, so a + // last-resort removal on that stock is never committed without naming the clamp. + var rail = plate.ClampRail ?? stock.ClampRail; plate.SetClampRail(null); // explicit zones from here on foreach (var zone in stock.ClampZones) plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds.Translate(-left, -bottom))); diff --git a/OpenNest.Engine/Jobs/NestPlateStock.cs b/OpenNest.Engine/Jobs/NestPlateStock.cs index 649b9be3..9cb86383 100644 --- a/OpenNest.Engine/Jobs/NestPlateStock.cs +++ b/OpenNest.Engine/Jobs/NestPlateStock.cs @@ -81,6 +81,14 @@ public sealed class NestPlateStock /// internal IReadOnlyList SheetClampZones { get; } + /// + /// The machine clamp rail were resolved from, when the stock was + /// built from a rail-following plate; null for explicit zones. Only rail-backed clamps may + /// be taken off as a last resort (D8): the rail is what lets a committed sheet name the + /// clamps to pull and carry their removal time, so explicit zones are never removed. + /// + internal ClampRail ClampRail { get; init; } + /// Generic per-physical-sheet cost; null selects area scoring. public double? Cost { get; } public string Id { get; } diff --git a/OpenNest.Engine/Jobs/NestStockBuilder.cs b/OpenNest.Engine/Jobs/NestStockBuilder.cs index 3fc72a8f..fc169923 100644 --- a/OpenNest.Engine/Jobs/NestStockBuilder.cs +++ b/OpenNest.Engine/Jobs/NestStockBuilder.cs @@ -33,7 +33,8 @@ public static class NestStockBuilder template.Quadrant, option.Cost == 0 ? null : option.Cost, ClampZonesFor(template, size) - )); + ) + { ClampRail = template.ClampRail }); } NestJobValidator.ValidateStockCosts(stock); return stock.AsReadOnly();