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