diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs
index 5473d02..ce5e0f7 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 73a1e06..e68d9e3 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 edbfc86..3e239a0 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 871a244..5c3f70b 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 e266d5d..b5c32fc 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