From 4226c0f902ef1ef0d6dcd385b276e765877cf2d8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 13 Oct 2026 17:44:26 -0400 Subject: [PATCH] fix(engine): find the fewest clamps to remove, not only neighbouring runs Recovery only tried contiguous runs of clamps, so a part with a notch over a middle clamp had all three clamps removed although the two outer ones were enough (D8: 'the fewest clamps that let it fit'). One probe per clamp now finds the clamps every removal must include (a part that cannot fit with only that clamp left needs it off; removal is monotone), and the search tries every set by size, mandatory clamps plus the optional ones nearest the origin first. Full-sheet parts resolve with one probe per clamp. PM 3df03af2-35a6-49c2-9082-fc6208f5b708 --- .../Jobs/ClampRemovalRepairTests.cs | 29 ++++++++ OpenNest.Engine/Jobs/ClampRemovalRecovery.cs | 66 +++++++++++++++---- docs/automatic-nesting.md | 5 +- 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs index e21b2e1..a021897 100644 --- a/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs +++ b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs @@ -359,4 +359,33 @@ public class ClampRemovalRepairTests Assert.Equal(8, outcome.Result.Plates.Count); Assert.All(outcome.Result.Plates, sheet => Assert.Empty(sheet.Stock.ClampZones)); } + // PM 3df03af2 (C11/D8 "the fewest clamps"): the minimal set need not be neighbours. The + // notch over the middle clamp lets it stay while the two outer clamps come off. + [Fact] + public void FewestClampsNeedNotBeNeighbours() + { + var program = new OpenNest.CNC.Program(); + program.MoveTo(0, 0); + program.LineTo(8, 0); + program.LineTo(8, 1); + program.LineTo(15, 1); + program.LineTo(15, 0); + program.LineTo(23, 0); + program.LineTo(23, 3); + program.LineTo(0, 3); + program.LineTo(0, 0); + var rail = new ClampRail(1.5, 0.625, new[] { 3.0, 11.5, 20.0 }); + var size = new Size(3, 23); + var stock = new NestPlateStock("sheet", size, null, 0, default, 1, null, rail.ResolveFor(size, 1)) + { ClampRail = rail }; + + var result = NestPipeline.Run(new NestPipelineRequest("Default", + new[] { new NestItem { Drawing = new Drawing("middle-notch", program), Quantity = 1 } }, + new[] { stock })); + + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(NestJobStatus.Complete, result.Status); + var sheet = Assert.Single(result.Raw.Plates); + Assert.Equal(new[] { "clamp-2" }, sheet.Stock.ClampZones.Select(z => z.Id)); + } } diff --git a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs index bb4e6c0..d5194ca 100644 --- a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs +++ b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs @@ -104,8 +104,8 @@ internal static class ClampRemovalRecovery // out moves on to another stock (with that stock's own minimal removal). for (var round = 0; round < availableStock.Count && platesLeft is not <= 0; round++) { - // C11: per blocked part, the fewest clamps (smallest contiguous run, nearest the - // origin on ties) whose removal frees an empty sheet of one of those stocks. + // C11: per blocked part, the fewest clamps (any set, nearest the origin on ties) + // whose removal frees an empty sheet of one of those stocks. // Parts needing the same removal on the same stock share one variant. var groups = new List(); foreach (var part in blocked) @@ -224,10 +224,11 @@ internal static class ClampRemovalRecovery /// /// The (stock, removal) with the fewest removed clamps across every offered rail-backed - /// stock; ties prefer the stock listed earliest, then the run nearest the origin. One + /// stock; ties prefer the stock listed earliest, then the clamps nearest the origin. One /// all-clamps-removed probe per stock first dismisses stock the part cannot use at all - /// and picks the lanes worth asking; the run search then goes size by size across the - /// remaining stock and stops at the first size that fits anywhere. + /// and picks the lanes worth asking; one probe per clamp then finds the clamps every + /// removal must include; the set search goes size by size across the remaining stock and + /// stops at the first size that fits anywhere. /// private static Removal BestRemoval( NestJobPart part, @@ -255,18 +256,40 @@ internal static class ClampRemovalRecovery candidates.Add((stock, zones, fitting)); } - var maxRun = candidates.Count == 0 ? 0 : candidates.Max(c => c.Zones.Count); - for (var run = 1; run <= maxRun; run++) + // Removing more clamps never stops a part fitting, so a clamp the part cannot live + // with even when it is the only one left must be in every feasible removal. + var searches = new List<(NestPlateStock Stock, List Zones, List Lanes, List Mandatory)>(); + foreach (var (stock, zones, fitting) in candidates) { - foreach (var (stock, zones, fitting) in candidates) + var mandatory = new List(); + for (var i = 0; i < zones.Count; i++) { - for (var start = 0; start + run <= zones.Count; start++) + token.ThrowIfCancellationRequested(); + var onlyThis = Clone(stock, stock.Id, null, zones.Where((_, j) => j != i).Select(z => z.Id).ToList()); + if (zones.Count == 1 || !fitting.Any(lane => FitsAlone(part, onlyThis, options, lane, budget, token))) + mandatory.Add(i); + } + searches.Add((stock, zones, fitting, mandatory)); + } + + // Then the fewest clamps overall (any set, not only neighbours: a notch can leave a + // middle clamp in place), size by size across stocks in offered order; within a stock + // the optional additions nearest the origin first. + var maxSize = searches.Count == 0 ? 0 : searches.Max(c => c.Zones.Count); + for (var size = 1; size <= maxSize; size++) + { + foreach (var (stock, zones, fitting, mandatory) in searches) + { + if (mandatory.Count > size || size > zones.Count) + continue; + var optional = Enumerable.Range(0, zones.Count).Where(i => !mandatory.Contains(i)).ToList(); + foreach (var added in Combinations(optional, size - mandatory.Count)) { token.ThrowIfCancellationRequested(); - var removedIds = zones.Skip(start).Take(run).Select(z => z.Id).ToList(); + var removedIds = mandatory.Concat(added).OrderBy(i => i).Select(i => zones[i].Id).ToList(); var candidate = Clone(stock, Suffixed(stock.Id, removedIds), null, removedIds); // Removing every clamp was already probed. - if (run == zones.Count || fitting.Any(lane => FitsAlone(part, candidate, options, lane, budget, token))) + if (size == zones.Count || fitting.Any(lane => FitsAlone(part, candidate, options, lane, budget, token))) return new Removal(candidate, stock.Id, removedIds); } } @@ -274,6 +297,27 @@ internal static class ClampRemovalRecovery return null; } + /// Every -element subset of , lazily, + /// in lexicographic order of positions. + private static IEnumerable> Combinations(IReadOnlyList items, int count) + { + if (count < 0 || count > items.Count) + yield break; + var positions = Enumerable.Range(0, count).ToArray(); + while (true) + { + yield return positions.Select(p => items[p]).ToList(); + var k = count - 1; + while (k >= 0 && positions[k] == items.Count - count + k) + k--; + if (k < 0) + yield break; + positions[k]++; + for (var j = k + 1; j < count; j++) + positions[j] = positions[j - 1] + 1; + } + } + /// Only clamps resolved from a machine rail can be removed (see /// ): explicit zones have no clamp to name. private static bool CanRemoveClamps(NestPlateStock stock) => diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index 9982cdc..828ab54 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -77,8 +77,9 @@ clamp covers the program start point. When a part cannot be placed on an empty sheet of any offered size with every clamp in place - neither the selected engine nor the Irregular or Rectangles -lane finds a clamped placement - Auto Nest takes the fewest neighbouring clamps -off one sheet that let it fit (the run nearest the origin on ties) and nests it +lane finds a clamped placement - Auto Nest takes the fewest clamps off one sheet +that let it fit (not necessarily neighbours: a notch may let a clamp between +them stay; the clamps nearest the origin on ties) and nests it on extra sheets with those clamps removed; other unplaced parts may share the freed sheets. It never removes a clamp to save material, never re-plans a sheet the normal nest already used, draws only on stock that is left, and only