mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-18 01:31:38 -04:00
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.
This commit is contained in:
1 parent
b245290664
commit
57450ecea8
6 files changed
+145
-24
No files matched your search
@@ -80,7 +80,7 @@ public sealed class AutomaticCutOffPlan
|
||||
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
|
||||
public double StripCutLength { get; internal set; }
|
||||
|
||||
/// <summary>Length of nominal strip lines in the same gaps. Zero on the legacy path.</summary>
|
||||
/// <summary>Length of the nominal new strip lines in the same gaps. Equal to StripCutLength on the fixed-grid path.</summary>
|
||||
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<double>();
|
||||
for (var index = 1; index <= (int)candidateCount; index++)
|
||||
var nominal = new List<double>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<double[]>();
|
||||
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();
|
||||
|
||||
@@ -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<TextBox>(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<Label>(form, "stripCutsLabel").Text);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidMinimumStripRefusesApplyWithoutChangingCutoffs()
|
||||
{
|
||||
|
||||
@@ -28,7 +28,7 @@ For a 120-by-81-inch sheet whose parts extend through 80 inches, the fixed grid
|
||||
|
||||
## Shifting strip lines
|
||||
|
||||
The preview searches line positions together to minimize the total emitted strip-line cutting length after part clearance and minimum-segment filtering. Equal lengths favor fewer pierces, then less total movement from the fixed grid. The number of new lines in each span stays at the fixed-grid count; it never adds cuts to chase a shorter path. For example, a part edge at 34.75 inches can make a 35-inch line run beside much of the part, while a small shift toward the origin lets that line cross the part and skip its material. The preview reports the measured strip-cut length and the corresponding fixed-grid length; a `ShiftedCut` notice identifies moved lines.
|
||||
The preview searches line positions together to minimize the total emitted strip-line cutting length after part clearance and minimum-segment filtering. Equal lengths favor fewer pierces, then less total movement from the fixed grid. The number of new lines in each span stays at the fixed-grid count; it never adds cuts to chase a shorter path. For example, a part edge at 34.75 inches can make a 35-inch line run beside much of the part, while a small shift toward the origin lets that line cross the part and skip its material. The preview reports the measured strip-cut length and the corresponding fixed-grid length, including when Minimum strip equals spacing; a `ShiftedCut` notice identifies moved lines. Because the lines are optimized together, an individual line can cut slightly more while the total falls; its notice reports that local increase rather than a negative saving.
|
||||
|
||||
Existing full-width vertical cut-offs inside the used span become fixed strip boundaries; the planner fills only gaps longer than the spacing, never moving or duplicating the manual cut. A partial manual cut is not a boundary and its position is avoided. Active clamps exclude candidate positions including bottom-edge clearance. If a gap cannot place all its lines outside clamps, it falls back to nominal lines and reports any blocked cuts under the existing guard. An unchanged rerun adds nothing. The desktop defaults to shifting; API callers that omit `MinimumStripLength` retain the fixed-grid behavior.
|
||||
|
||||
|
||||
Reference in new issue
Block a user