mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-14 20:15:39 -05:00
Validate Nest and the pre-post review checked overlaps and part-to-part spacing but never the sheet itself, so a part dragged past the plate edge, a lead-in piercing off the sheet, or a part inside the edge spacing could post without a warning. Only Auto Nest's own layout check held parts to the work area. PlateEdgeReview adds two finding kinds: - OffPlate: any cut, lead or scribe motion past the sheet, or clean material outside an outlined sheet or over one of its obstacles. - EdgeSpacing: clean material (leads excluded) outside the work area, or closer than the largest edge spacing to an outline or obstacle - the bound Auto Nest's layout check applies, with SpacingSlack, so layouts it accepted stay clear. Cut-off parts are exempt. Routing-replay test fixtures sit at the sheet origin with leads hanging off it; their route assertions now ignore the sheet-bound kinds, and the post-contract fixture moves onto the sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
12 KiB
C#
241 lines
12 KiB
C#
using OpenNest.CNC;
|
|
using OpenNest.CNC.CuttingPlanning;
|
|
using OpenNest.Diagnostics;
|
|
using OpenNest.Engine.CuttingPlanning;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest.Tests.CuttingPlanning;
|
|
|
|
/// <summary>Whole-part order chosen by the planner itself (the part order is not preserved).</summary>
|
|
public class ReorderSearchTests
|
|
{
|
|
[Theory]
|
|
[InlineData(16, false)]
|
|
[InlineData(16, true)]
|
|
[InlineData(36, false)]
|
|
[InlineData(36, true)]
|
|
public void FreeOrder_DenseGrid_IsReadyWithinTheDefaultBudget(int count, bool shuffled)
|
|
{
|
|
var nest = new Nest();
|
|
var plate = nest.CreatePlate();
|
|
plate.Size = new Size(100, 100);
|
|
foreach (var part in Grid(count, shuffled))
|
|
plate.Parts.Add(part);
|
|
var parts = plate.Parts.ToArray();
|
|
|
|
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
|
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
|
|
|
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
|
Assert.True(result.IndependentlyReplayed);
|
|
Assert.True(result.Expansions <= 20000);
|
|
Assert.Equal(parts.OrderBy(Key), result.ProposedOrder.Select(p => p.SourcePart).OrderBy(Key));
|
|
Assert.All(result.ProposedOrder, p => Assert.True(p.IsRegenerated));
|
|
Assert.Equal(parts, plate.Parts); // Planning alone never reorders the live plate.
|
|
}
|
|
|
|
[SkippableFact]
|
|
public void DenseGrid144_PerformanceProbe()
|
|
{
|
|
Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_CUTTING_PERF") == "1",
|
|
"Set OPENNEST_RUN_CUTTING_PERF=1 for the 144-part cutting-planner probe.");
|
|
var nest = new Nest();
|
|
var plate = nest.CreatePlate();
|
|
plate.Size = new Size(100, 100);
|
|
foreach (var part in Grid(144, false))
|
|
plate.Parts.Add(part);
|
|
var source = plate.Parts.ToArray();
|
|
var timer = System.Diagnostics.Stopwatch.StartNew();
|
|
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
|
|
confirmedParameters: ExplicitContourTests.Parameters(), expansionBudget: 57600));
|
|
timer.Stop();
|
|
// Expanded owned execution includes called hole programs; Program.ToString() does not,
|
|
// and rounds coordinates. Hash exact scalar bits in execution order instead.
|
|
static string Bits(double value) => BitConverter.DoubleToInt64Bits(value).ToString("X16");
|
|
static string Point(Vector point) => $"{Bits(point.X)},{Bits(point.Y)}";
|
|
var payload = string.Join("\n", result.ProposedOrder.SelectMany(p =>
|
|
new[] { $"part:{p.SourceOrdinal}:{Point(p.Location)}:{Bits(p.Rotation)}" }.Concat(
|
|
p.Execution.Motions.Select(m =>
|
|
{
|
|
var geometry = m.Curve?.ToEntity() switch
|
|
{
|
|
Arc arc => $"arc:{Point(arc.Center)}:{Bits(arc.Radius)}:{Bits(arc.StartAngle)}:{Bits(arc.EndAngle)}:{arc.Rotation}",
|
|
Circle circle => $"circle:{Point(circle.Center)}:{Bits(circle.Radius)}:{circle.Rotation}",
|
|
Line => "line",
|
|
null => "none",
|
|
_ => throw new NotSupportedException("Unexpected execution curve."),
|
|
};
|
|
return $"{m.Layer}:{m.Rapid}:{(m.Start is { } start ? Point(start) : "null")}:{Point(m.End)}:{geometry}";
|
|
}))));
|
|
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
|
|
System.Text.Encoding.UTF8.GetBytes(payload)));
|
|
Console.WriteLine($"Cutting144 elapsedMs={timer.Elapsed.TotalMilliseconds:F1} expansions={result.Expansions} status={result.Status} executionSha256={hash}");
|
|
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
|
Assert.True(result.IndependentlyReplayed);
|
|
Assert.Equal(source.OrderBy(Key), result.ProposedOrder.Select(p => p.SourcePart).OrderBy(Key));
|
|
Assert.Equal(source, plate.Parts);
|
|
}
|
|
|
|
[Fact]
|
|
public void FreeOrder_BlockedApproach_LearnsToCutThatPartFirst()
|
|
{
|
|
// Locked programs lead in and out on each part's left side, so leaving a cut part to the
|
|
// right crosses it. The shortest tour B, A, C is blocked at A (crossing B); with A before B
|
|
// it is A, B, C, blocked at C (crossing A and B). Only right to left is safe.
|
|
var a = LeftLeadRectangle("A", 4, 0, 4, 4);
|
|
var b = LeftLeadRectangle("B", 0, 1, 2, 2);
|
|
var c = LeftLeadRectangle("C", 14, 1, 2, 2);
|
|
Assert.Contains(Analyze(b, a, c).Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
|
|
Assert.Contains(Analyze(a, b, c).Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
|
|
|
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest([a, b, c],
|
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
|
|
|
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
|
Assert.True(result.IndependentlyReplayed);
|
|
Assert.Equal(new[] { c, a, b }, result.ProposedOrder.Select(p => p.SourcePart));
|
|
Assert.All(result.ProposedOrder, p => Assert.False(p.IsRegenerated));
|
|
Assert.Empty(Analyze(c, a, b).Findings);
|
|
}
|
|
|
|
[Fact]
|
|
public void FreeOrder_BlockedAfterRegeneratedParts_LearnsBeforeRetryingTheirEntries()
|
|
{
|
|
// Two regenerated parts come first; then the locked trio blocks as above. Retrying every
|
|
// entry combination of the two parts (over a million) before learning would exhaust the budget.
|
|
var drawing = new Drawing("holes", PreparedContourTests.Holes());
|
|
var first = new Part(drawing, new Vector(1, 1));
|
|
var second = new Part(drawing, new Vector(12, 1));
|
|
var a = LeftLeadRectangle("A", 44, 0, 4, 4);
|
|
var b = LeftLeadRectangle("B", 40, 1, 2, 2);
|
|
var c = LeftLeadRectangle("C", 54, 1, 2, 2);
|
|
|
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest([first, second, a, b, c],
|
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
|
|
|
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
|
Assert.Equal(new[] { first, second, c, a, b }, result.ProposedOrder.Select(p => p.SourcePart));
|
|
Assert.True(result.IndependentlyReplayed);
|
|
}
|
|
|
|
[Fact]
|
|
public void FreeOrder_ApproachCrossesThePartCutJustBefore_SwapsThatPair()
|
|
{
|
|
// Two long bars, one above the other, and a small part off to the right below them. The
|
|
// shortest tour cuts the lower bar, the upper one, then the small part, whose approach
|
|
// from the upper bar's left side crosses the lower bar. Cutting the upper bar first lets
|
|
// the tool leave the lower bar below its corner instead; jumping the small part ahead of
|
|
// the lower bar would detour.
|
|
var lower = LeftLeadRectangle("lower", 0, 0, 19, 6, departure: new Vector(-0.25, -0.25));
|
|
var upper = LeftLeadRectangle("upper", 0, 7, 19, 6);
|
|
var small = LeftLeadRectangle("small", 28, -19, 4, 4);
|
|
var start = new Vector(9.5, -10);
|
|
Assert.Contains(Analyze(start, lower, upper, small).Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
|
|
|
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest([lower, upper, small], start,
|
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
|
|
|
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
|
Assert.True(result.IndependentlyReplayed);
|
|
Assert.Equal(new[] { upper, lower, small }, result.ProposedOrder.Select(p => p.SourcePart));
|
|
Assert.Empty(Analyze(start, upper, lower, small).Findings);
|
|
}
|
|
|
|
[Fact]
|
|
public void FreeOrder_LearnedOrderContradicts_FallsBackToTheFullSearch()
|
|
{
|
|
// Left leads on its left, right on its right: right straight after left crosses left, and
|
|
// left straight after right crosses right, so "cut before" rules contradict each other.
|
|
// Going via the part above, which leaves downward, reaches right safely.
|
|
var left = LeftLeadRectangle("left", 0, 0, 2, 2);
|
|
var right = LeftLeadRectangle("right", 4, 0, 2, 2, mirror: true);
|
|
var via = LeftLeadRectangle("via", -1, 5, 2, 2, departure: new Vector(-0.25, -0.25));
|
|
|
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest([left, right, via],
|
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
|
|
|
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
|
Assert.True(result.IndependentlyReplayed);
|
|
Assert.Equal(new[] { left, via, right }, result.ProposedOrder.Select(p => p.SourcePart));
|
|
}
|
|
|
|
[Fact]
|
|
public void FreeOrder_NoSafeOrder_RefusesWithTheCrossing()
|
|
{
|
|
// Leads on the far sides: whichever part is cut first, reaching the other crosses it.
|
|
var left = LeftLeadRectangle("left", 0, 0, 2, 2);
|
|
var right = LeftLeadRectangle("right", 4, 0, 2, 2, mirror: true);
|
|
|
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest([left, right],
|
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
|
|
|
Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status);
|
|
Assert.Empty(result.ProposedOrder);
|
|
Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
|
|
}
|
|
|
|
private static Part[] Grid(int count, bool shuffled)
|
|
{
|
|
var drawing = new Drawing("grid", PreparedContourTests.Holes());
|
|
var side = (int)System.Math.Ceiling(System.Math.Sqrt(count));
|
|
var parts = Enumerable.Range(0, count)
|
|
.Select(i => new Part(drawing, new Vector(1 + i % side * 11, 1 + i / side * 11)))
|
|
.ToArray();
|
|
if (!shuffled)
|
|
return parts;
|
|
var random = new Random(7);
|
|
return parts.OrderBy(_ => random.Next()).ToArray();
|
|
}
|
|
|
|
private static (double, double) Key(Part part) => (part.Location.X, part.Location.Y);
|
|
|
|
// A locked rectangle whose lead-in and lead-out sit 0.25 outside its left edge (its right
|
|
// edge when mirrored), so the tool departs on that side unless a final rapid moves it on.
|
|
private static Part LeftLeadRectangle(string name, double x, double y, double width, double height,
|
|
bool mirror = false, Vector? departure = null)
|
|
{
|
|
var clean = LeadPathValidationTests.Rectangle(0, 0, width, height);
|
|
var part = new Part(new Drawing(name, clean), new Vector(x, y));
|
|
var edge = mirror ? width : 0;
|
|
var outside = mirror ? width + 0.25 : -0.25;
|
|
var placed = new Program();
|
|
placed.MoveTo(outside, height / 2);
|
|
placed.Codes.Add(new LinearMove(edge, height / 2) { Layer = LayerType.Leadin });
|
|
// Same direction as the clean outline, which runs clockwise from its corner at the origin.
|
|
if (mirror)
|
|
{
|
|
placed.LineTo(width, 0); placed.LineTo(0, 0); placed.LineTo(0, height);
|
|
placed.LineTo(width, height);
|
|
}
|
|
else
|
|
{
|
|
placed.LineTo(0, height); placed.LineTo(width, height); placed.LineTo(width, 0);
|
|
placed.LineTo(0, 0);
|
|
}
|
|
placed.LineTo(edge, height / 2);
|
|
placed.Codes.Add(new LinearMove(outside, height / 2) { Layer = LayerType.Leadout });
|
|
if (departure is { } end)
|
|
placed.MoveTo(end.X, end.Y);
|
|
Assert.True(part.RestoreLeadInProgram(placed, true));
|
|
return part;
|
|
}
|
|
|
|
private static PostVerificationReport Analyze(params Part[] parts) => Analyze(Vector.Zero, parts);
|
|
|
|
private static PostVerificationReport Analyze(Vector start, params Part[] parts)
|
|
{
|
|
var nest = new Nest();
|
|
var plate = nest.CreatePlate();
|
|
foreach (var source in parts)
|
|
{
|
|
var copy = new Part(new Drawing("replay", (Program)source.BaseDrawing.Program.Clone()), source.Location);
|
|
Assert.True(copy.RestoreLeadInProgram((Program)source.Program.Clone(), source.LeadInsLocked));
|
|
plate.Parts.Add(copy);
|
|
}
|
|
return TestHelpers.RouteFindings(PostVerificationAnalyzer.Analyze(nest, start));
|
|
}
|
|
|
|
private static string Describe(CuttingPlanResult r) =>
|
|
$"{r.Status}, expanded {r.Expansions}: " + string.Join("; ", r.Findings.Select(f => f.Message).Take(5));
|
|
}
|