From 57450ecea8413a08b541b2008e73482ee5d20944 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 13 Oct 2026 20:01:48 -0400 Subject: [PATCH] fix(cutoffs): honor candidate budget and truthful cut-length ties Reserve nominal-line slots in the candidate lattice, compare rounded lengths in exact micro-units before pierce ties, and report real fixed-grid cut length. Per-line notices name local increases instead of negative savings. Failing-first regressions cover dense spacing, decimal ties and the legacy summary. --- OpenNest.Core/AutomaticCutOffPlanner.cs | 66 ++++++++++++++----- OpenNest.Core/AutomaticCutOffStripSearch.cs | 25 ++++++- .../CutOffs/AutomaticCutOffShiftTests.cs | 42 +++++++++++- .../AutomaticCutOffStripSearchTests.cs | 15 ++++- .../Forms/AutomaticCutOffFormTests.cs | 19 ++++++ docs/automatic-scrap-cutoffs.md | 2 +- 6 files changed, 145 insertions(+), 24 deletions(-) diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs index 5473d028..ce5e0f74 100644 --- a/OpenNest.Core/AutomaticCutOffPlanner.cs +++ b/OpenNest.Core/AutomaticCutOffPlanner.cs @@ -80,7 +80,7 @@ public sealed class AutomaticCutOffPlan /// Total cut length of proposed new strip lines (excludes the tail separator). public double StripCutLength { get; internal set; } - /// Length of nominal strip lines in the same gaps. Zero on the legacy path. + /// Length of the nominal new strip lines in the same gaps. Equal to StripCutLength on the fixed-grid path. public double FixedGridCutLength { get; internal set; } public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking); @@ -161,13 +161,32 @@ public static class AutomaticCutOffPlanner .Where(c => c.Axis == CutOffAxis.Vertical && FullSpanLimits(c, bounds, settings)) .Select(c => sign * c.Position.X) .Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance) - .Distinct().OrderBy(distance => distance); + .Distinct().OrderBy(distance => distance).Append(usedSpan); + var gaps = new List<(double Start, double End, int Count)>(); var start = 0.0; - foreach (var end in boundaries.Append(usedSpan)) + var nominalCount = 0; + foreach (var end in boundaries) { - AddShiftedGap(start, end); + var count = 0; + for (var index = 1; index <= (int)candidateCount; index++) + { + if (start + index * options.Spacing >= end - GeometryTolerance) + break; + count++; + } + gaps.Add((start, end, count)); + nominalCount += count; start = end; } + // The origin-based lattice alone fits within 20,000 positions, but its + // off-lattice nominal lines need reserved slots too. Budget both before + // constructing any row so a valid, dense grid is never refused halfway. + Require(nominalCount < MaximumShiftCandidateCount, + "Too many nominal strip lines for the shift search.", nameof(options)); + var step = System.Math.Max(options.Spacing / 700, + usedSpan / (MaximumShiftCandidateCount - nominalCount)); + foreach (var gap in gaps) + AddShiftedGap(gap.Start, gap.End, gap.Count, step); } else { @@ -200,6 +219,10 @@ public static class AutomaticCutOffPlanner separated = false; separatorX = null; } + // The legacy placement loop stays untouched. Its detached programs are already + // regenerated, so reporting their actual new strip cutting needs no new search. + if (!shifted) + fixedGridCutLength = stripCutLength; return new AutomaticCutOffPlan { @@ -214,31 +237,32 @@ public static class AutomaticCutOffPlanner FixedGridCutLength = fixedGridCutLength, }; - void AddShiftedGap(double start, double end) + void AddShiftedGap(double start, double end, int count, double step) { - var nominal = new List(); - for (var index = 1; index <= (int)candidateCount; index++) + var nominal = new List(count); + for (var index = 1; index <= count; index++) { var distance = start + index * options.Spacing; - if (distance >= end - GeometryTolerance) - break; nominal.Add(distance); fixedGridCutLength += Measure(distance).Length; } - if (nominal.Count == 0) + if (count == 0) return; - var step = System.Math.Max(options.Spacing / 700, usedSpan / MaximumShiftCandidateCount); - var positions = AutomaticCutOffStripSearch.FindPositions(start, end, nominal.Count, + var positions = AutomaticCutOffStripSearch.FindPositions(start, end, count, options.Spacing, options.MinimumStripLength.Value, step, Allowed, Measure) ?? nominal.ToArray(); for (var index = 0; index < positions.Length; index++) { var distance = positions[index]; AddCandidate(sign * distance, false); if (!Near(distance, nominal[index])) + { + var difference = Measure(nominal[index]).Length - Measure(distance).Length; + var comparison = difference > 0 ? $"{difference:G8} model units less" : + difference < 0 ? $"{-difference:G8} model units more" : "the same length"; diagnostics.Add(new AutomaticCutOffDiagnostic(AutomaticCutOffDiagnosticCode.ShiftedCut, - $"Moved from {nominal[index]:G8} to {distance:G8} model units; this line cuts " - + $"{Measure(nominal[index]).Length - Measure(distance).Length:G8} model units less.", - X: sign * distance)); + $"Moved from {nominal[index]:G8} to {distance:G8} model units; " + + $"this line cuts {comparison}.", X: sign * distance)); + } } } @@ -345,8 +369,16 @@ public static class AutomaticCutOffPlanner if (usable && existing == null) { definitions.Add(candidate); - if (shifted && !isSeparator) - stripCutLength += Measure(System.Math.Abs(x)).Length; + if (!isSeparator) + { + if (shifted) + stripCutLength += Measure(System.Math.Abs(x)).Length; + else + for (var index = 0; index < program.Codes.Count; index += 2) + stripCutLength += System.Math.Abs( + ((LinearMove)program.Codes[index + 1]).EndPoint.Y - + ((RapidMove)program.Codes[index]).EndPoint.Y); + } } } diff --git a/OpenNest.Core/AutomaticCutOffStripSearch.cs b/OpenNest.Core/AutomaticCutOffStripSearch.cs index 73a1e06d..e68d9e30 100644 --- a/OpenNest.Core/AutomaticCutOffStripSearch.cs +++ b/OpenNest.Core/AutomaticCutOffStripSearch.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Numerics; namespace OpenNest; @@ -73,7 +75,7 @@ internal static class AutomaticCutOffStripSearch current.Add(new State { Position = position, - Length = parent.Length + System.Math.Round(cost.Length, 6), + Length = parent.Length + RoundedMicroUnits(cost.Length), Pierces = parent.Pierces + cost.SegmentCount, Deviation = parent.Deviation + System.Math.Abs(nominal[line] - position), Parent = parent, @@ -97,6 +99,25 @@ internal static class AutomaticCutOffStripSearch return result; } + private static BigInteger RoundedMicroUnits(double length) + { + if (!double.IsFinite(length) || length < 0) + throw new ArgumentException("Cut-off segment length must be finite and nonnegative.", nameof(length)); + var rounded = System.Math.Round(length, 6); + // Compare sums of the specified decimal-rounded model units, not binary + // approximations such as 0.1 + 0.2 versus 0.3. The round-trip string + // retains micro-unit digits even at larger (but still fractional) scales. + if (rounded < 1e22) + { + var value = decimal.Parse(rounded.ToString("R", CultureInfo.InvariantCulture), + NumberStyles.Float, CultureInfo.InvariantCulture); + return new BigInteger(decimal.Round(value * 1_000_000m, 0)); + } + // At this magnitude a double has no fractional model units; avoid a + // decimal overflow while retaining an unbounded exact accumulated score. + return new BigInteger(rounded) * 1_000_000; + } + private static int Compare(State left, State right) { var comparison = left.Length.CompareTo(right.Length); @@ -128,7 +149,7 @@ internal static class AutomaticCutOffStripSearch private sealed class State { public double Position; - public double Length; + public BigInteger Length; public int Pierces; public double Deviation; public State Parent; diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffShiftTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftTests.cs index edbfc863..3e239a0d 100644 --- a/OpenNest.Tests/CutOffs/AutomaticCutOffShiftTests.cs +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftTests.cs @@ -106,8 +106,9 @@ public class AutomaticCutOffShiftTests Assert.Equal(legacy.UsedSpan, fixedGrid.UsedSpan); Assert.Equal(legacy.StripCutLength, fixedGrid.StripCutLength); Assert.Equal(legacy.FixedGridCutLength, fixedGrid.FixedGridCutLength); - Assert.Equal(0, legacy.StripCutLength); - Assert.Equal(0, legacy.FixedGridCutLength); + Assert.Equal(legacy.PreviewParts.Take(legacy.Definitions.Count - (legacy.HasSeparatedTail ? 1 : 0)) + .Sum(p => CutLength(p.Program)), legacy.StripCutLength, 8); + Assert.Equal(legacy.StripCutLength, legacy.FixedGridCutLength); } [Fact] @@ -315,6 +316,43 @@ public class AutomaticCutOffShiftTests Assert.True(plan.FixedGridCutLength > 0); } + [Fact] + public void LegacyGridReportsActualNewStripCutLengthWithoutChangingItsDefinitions() + { + var plate = new Plate(60, 120) { PartSpacing = 0.3 }; + plate.Parts.Add(Rectangle(5, 5, 75, 10)); + var settings = new CutOffSettings(); + var options = new AutomaticCutOffOptions { Spacing = 35 }; + var legacy = AutomaticCutOffPlanner.Create(plate, options, settings); + Assert.Equal(new[] { 35.0, 70.0 }, legacy.Definitions.Take(2).Select(c => c.Position.X)); + Assert.Equal(120, legacy.PreviewParts.Take(2).Sum(p => CutLength(p.Program))); + Assert.Equal(120, legacy.StripCutLength); + Assert.Equal(legacy.StripCutLength, legacy.FixedGridCutLength); + options.MinimumStripLength = 35; + var equal = AutomaticCutOffPlanner.Create(plate, options, settings); + Assert.Equal(legacy.Definitions.Select(c => c.Position.X), equal.Definitions.Select(c => c.Position.X)); + Assert.Equal(legacy.Diagnostics, equal.Diagnostics); + Assert.Equal(legacy.StripCutLength, equal.StripCutLength); + Assert.Equal(legacy.FixedGridCutLength, equal.FixedGridCutLength); + } + + [Fact] + public void ShiftedSearchKeepsNominalLinesWithoutRejectingAValidDenseGrid() + { + var plate = new Plate(60, 120) { PartSpacing = 0 }; + plate.Parts.Add(Rectangle(0.01, 0.01, 109.97009, 0.5)); + var settings = new CutOffSettings(); + var fixedGrid = AutomaticCutOffPlanner.Create(plate, + new AutomaticCutOffOptions { Spacing = 0.1 }, settings); + Assert.Equal(1101, fixedGrid.Definitions.Count); + + var shifted = AutomaticCutOffPlanner.Create(plate, + new AutomaticCutOffOptions { Spacing = 0.1, MinimumStripLength = 0.05 }, settings); + + Assert.Equal(fixedGrid.Definitions.Count, shifted.Definitions.Count); + Assert.True(shifted.StripCutLength <= shifted.FixedGridCutLength + 0.000001); + } + internal static void AssertLimits(AutomaticCutOffPlan plan, double spacing, double minimum) { var positions = plan.Definitions.Where(c => c.Position.X != plan.TailSeparatorX) diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs index 871a244f..5c3f70b3 100644 --- a/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs @@ -12,6 +12,16 @@ public class AutomaticCutOffStripSearchTests Assert.Equal(new[] { pierceTieBreak ? 18.0 : 35.0 }, result); } + [Fact] + public void DecimalRoundedLengthTie_UsesPierceCountNotBinaryAdditionNoise() + { + var result = AutomaticCutOffStripSearch.Solve(0, + new[] { new[] { 20.0, 35.0 }, new[] { 40.0, 70.0 } }, + new[] { 35.0, 70.0 }, 35, 17.5, + x => x switch { 20 => (0.1, 1), 40 => (0.2, 1), 35 => (0.3, 3), _ => (0.0, 0) }); + Assert.Equal(new[] { 20.0, 40.0 }, result); + } + [Fact] public void GlobalOptimum_IsBetterThanGreedyFirstLine() { @@ -39,7 +49,8 @@ public class AutomaticCutOffStripSearchTests { var rows = new[] { new[] { 18.0, 23, 29, 35 }, new[] { 38.0, 47, 56, 62, 70 }, new[] { 60.0, 74, 81, 91, 105 } }; var costs = rows.SelectMany(row => row).Distinct().ToDictionary(x => x, - _ => (Length: random.Next(0, 4) + random.Next(0, 2) * 0.0000001, SegmentCount: random.Next(0, 3))); + _ => (Length: random.Next(0, 4) + random.Next(0, 1_000_000) / 1_000_000d, + SegmentCount: random.Next(0, 3))); var nominal = new[] { 35.0, 70, 105 }; var paths = new List(); foreach (var first in rows[0]) @@ -47,7 +58,7 @@ public class AutomaticCutOffStripSearchTests foreach (var third in rows[2]) if (second - first >= 17.5 && second - first <= 35 && third - second >= 17.5 && third - second <= 35) paths.Add(new[] { first, second, third }); - var expected = paths.OrderBy(path => path.Sum(x => System.Math.Round(costs[x].Length, 6))) + var expected = paths.OrderBy(path => path.Sum(x => (decimal)System.Math.Round(costs[x].Length, 6))) .ThenBy(path => path.Sum(x => costs[x].SegmentCount)) .ThenBy(path => Enumerable.Range(0, 3).Sum(i => System.Math.Abs(nominal[i] - path[i]))) .ThenBy(path => path[0]).ThenBy(path => path[1]).ThenBy(path => path[2]).First(); diff --git a/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs b/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs index e266d5dc..b5c32fcf 100644 --- a/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/AutomaticCutOffFormTests.cs @@ -61,6 +61,25 @@ public class AutomaticCutOffFormTests }); } + [Fact] + public void EqualMinimumAndSpacing_PreviewReportsMeasuredFixedGridCutting() + { + RunSta(() => + { + using var view = CreateView(); + using var form = new AutomaticCutOffForm(view, Units.Inches); + Control(form, "minimumStripBox").Text = "35"; + Invoke(form, "PreviewButton_Click", null, EventArgs.Empty); + var expected = AutomaticCutOffPlanner.Create(view.Plate, + new AutomaticCutOffOptions { Spacing = 35, MinimumStripLength = 35, MinimumTailLength = 12 }, + view.CutOffSettings); + Assert.True(expected.StripCutLength > 0); + Assert.Equal(expected.StripCutLength, expected.FixedGridCutLength); + Assert.Contains($"Strip cuts: {expected.StripCutLength:0.####} in (same as the fixed grid)", + Control