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();