Files
OpenNest/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs
T
aj bbadd6aa75 fix(cut-offs): accept a blank's outline a rounding step past the sheet
Automatic cut-offs refused any part reaching past the physical sheet by
more than Tolerance.Epsilon. Imported supplied blanks (panel doors,
remnants) equal their sheet but carry PEP's rounding: a 0.000006 outline
skew plus placements rounded to 0.001 put the never-cut outline 0.00001
to 0.00003 in past the sheet, so Plan Cutting refused every such nest.

A display perimeter may now extend up to DisplayPerimeterSheetAllowance
(0.0001 model units) past the sheet. Nothing cuts it, and a blank that
fills its sheet gets no cut-offs. Everything else on the part keeps the
ordinary tolerance: cuts, leads, scribes and travel, except a rapid that
ends on the outline itself (the move to its first corner, or an applied
program's display tail). A larger overhang is still refused.
2026-10-23 05:55:32 -04:00

480 lines
22 KiB
C#

using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Converters;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
/// <summary>
/// A supplied blank (a purchased door) whose outline is a display perimeter: shown and treated as
/// material, never cut. Only its cutouts are cut, each as a hole.
/// </summary>
public class DisplayPerimeterTests
{
// The door fills a 30 x 20 sheet: new Size(width, length) puts Length (30) along X.
private static Plate DoorPlate(Program program)
{
var plate = new Plate(new Size(20, 30));
plate.Parts.Add(new Part(new Drawing("door", program), Vector.Zero));
return plate;
}
internal static Program Door(LayerType outline = LayerType.DisplayPerimeter, bool cutouts = true,
LayerType? lastEdge = null)
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(0, 20) { Layer = outline });
program.Codes.Add(new LinearMove(30, 20) { Layer = outline });
program.Codes.Add(new LinearMove(30, 0) { Layer = outline });
program.Codes.Add(new LinearMove(0, 0) { Layer = lastEdge ?? outline });
if (cutouts)
AddCutouts(program);
return program;
}
private static void AddCutouts(Program program)
{
program.MoveTo(5, 5); program.LineTo(5, 10); program.LineTo(10, 10); program.LineTo(10, 5); program.LineTo(5, 5);
program.MoveTo(15, 5); program.LineTo(15, 12); program.LineTo(25, 12); program.LineTo(25, 5); program.LineTo(15, 5);
program.MoveTo(9.5, 15);
program.Codes.Add(new ArcMove(new Vector(9.5, 15), new Vector(8, 15), RotationType.CW));
}
// Cutout extents, for checking where pierces and cuts land.
private static readonly Box[] Cutouts =
[
new Box(5, 5, 5, 5),
new Box(15, 5, 10, 7),
new Box(6.5, 13.5, 3, 3),
];
private static bool InsideACutout(Vector point) => Cutouts.Any(box =>
point.X >= box.Left - 1e-6 && point.X <= box.Right + 1e-6 && point.Y >= box.Bottom - 1e-6 && point.Y <= box.Top + 1e-6);
[Fact]
public void Door_PlansEveryCutoutAsAHoleAndNeverCutsTheOutline()
{
var plate = DoorPlate(Door());
var part = plate.Parts[0];
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
var result = Assert.Single(proposal.Plates).Result;
Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.True(result.IndependentlyReplayed);
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: false).Status);
var motions = ExecutionMotionReader.Read(part.Program, part.Location, null, default).Motions;
Assert.DoesNotContain(motions, m => m.Layer == LayerType.DisplayPerimeter);
var pierces = motions.Where((m, i) => m.Layer == LayerType.Leadin && m.Start is { } && (i == 0 || motions[i - 1].Rapid))
.Select(m => m.Start!.Value).ToArray();
Assert.Equal(3, pierces.Length);
Assert.All(pierces, p => Assert.True(InsideACutout(p), $"Pierce ({p.X}, {p.Y}) is on the door."));
Assert.All(motions.Where(m => !m.Rapid), m => Assert.True(InsideACutout(m.End), $"Cut to ({m.End.X}, {m.End.Y}) leaves the cutouts."));
}
private static (Plate Plate, Part Part) PlannedDoor()
{
var plate = DoorPlate(Door());
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: false).Status);
return (plate, plate.Parts[0]);
}
[Fact]
public void AfterApply_TheDoorKeepsItsOutlineAsANeverCutTail()
{
var (_, part) = PlannedDoor();
Assert.Equal(30, part.BoundingBox.Length, 6);
Assert.Equal(20, part.BoundingBox.Width, 6);
Assert.True(DisplayPerimeterTail.HasTail(part.Program));
var executed = DisplayPerimeterTail.ForPosting(part.Program, "Part 1");
Assert.False(DisplayPerimeterTail.HasDisplayPerimeterMotion(executed));
// Posting and executed reads agree: the tail is the only thing either leaves out.
var full = ExecutionMotionReader.Read(part.Program, part.Location, null, default).Motions;
var posted = ExecutionMotionReader.Read(executed, part.Location, null, default).Motions;
Assert.Equal(full.Count, posted.Count);
Assert.True(executed.Codes.Count < part.Program.Codes.Count);
}
[Fact]
public void AfterApply_AutomaticCutOffsStillAddNoLineAcrossTheBlank()
{
var (plate, _) = PlannedDoor();
var staging = AutomaticCutOffBatch.Stage([plate],
new AutomaticCutOffOptions { Spacing = 10, MinimumStripLength = 5 }, new CutOffSettings());
Assert.Empty(Assert.Single(staging.Plans).Definitions);
}
[Fact]
public void AfterApply_TheDoorPlansAgainWithTheSameHoles()
{
var (plate, _) = PlannedDoor();
var again = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
Assert.True(again.CanApply, string.Join("\n", again.Describe("in")));
}
[Fact]
public void AfterApply_TimingRapidsAndSimulationIgnoreTheTail()
{
var (plate, part) = PlannedDoor();
var executed = DisplayPerimeterTail.WithoutTail(part.Program);
Assert.Equal(Timing.GetTimingInfo(executed).PierceCount, Timing.GetTimingInfo(part.Program).PierceCount);
Assert.Equal(Timing.GetTimingInfo(executed).CutDistance, Timing.GetTimingInfo(part.Program).CutDistance, 9);
// The tail starts at the outline's first corner (0, 0); no rapid may travel there.
var rapids = RapidEnumerator.Enumerate(plate.Parts);
Assert.DoesNotContain(rapids, r => r.To.DistanceTo(Vector.Zero) < 1e-9);
var simulation = CutSimulation.Build(plate, new CutParameters
{
Feedrate = 100,
RapidTravelRate = 600,
PierceTime = TimeSpan.FromSeconds(1),
Units = Units.Inches,
});
Assert.Equal(3, simulation.Steps.Count(step => step.Kind == CutSimulationStepKind.Pierce));
Assert.DoesNotContain(simulation.Steps, step => step.Layer == LayerType.DisplayPerimeter);
}
[Fact]
public void ManualAndLegacyLeadIns_RefuseADisplayPerimeterAndLeaveThePartAlone()
{
var part = new Part(new Drawing("door", Door()), Vector.Zero);
var before = ExplicitContourTests.Fingerprint(part.Program);
var edge = ConvertProgram.ToGeometry(part.Program).First(e => e.Layer == SpecialLayers.DisplayPerimeter);
var hole = ConvertProgram.ToGeometry(part.Program).First(e => e.Layer == SpecialLayers.Cut);
var onEdge = Assert.Throws<InvalidOperationException>(() => part.ApplySingleLeadIn(
ExplicitContourTests.Parameters(), new Vector(0, 10), edge, OpenNest.CNC.CuttingStrategy.ContourType.External));
Assert.Throws<InvalidOperationException>(() => part.ApplySingleLeadIn(
ExplicitContourTests.Parameters(), new Vector(5, 7), hole, OpenNest.CNC.CuttingStrategy.ContourType.Internal));
Assert.Throws<InvalidOperationException>(() => part.ApplyLeadIns(ExplicitContourTests.Parameters(), Vector.Zero));
Assert.Contains("Plan Cutting", onEdge.Message);
Assert.False(part.HasManualLeadIns);
Assert.Equal(before, ExplicitContourTests.Fingerprint(part.Program));
}
private static Program SquareThenMarker(bool displayTail, bool markerInSubProgram = false)
{
var cuts = new Program();
cuts.MoveTo(1, 1); cuts.LineTo(1, 2); cuts.LineTo(2, 2); cuts.LineTo(2, 1); cuts.LineTo(1, 1);
cuts.Codes.Add(new Comment(DisplayPerimeterTail.Marker));
cuts.Codes.Add(new RapidMove(3, 3));
var layer = displayTail ? LayerType.DisplayPerimeter : LayerType.Cut;
foreach (var (x, y) in new[] { (3.0, 4.0), (4.0, 4.0), (4.0, 3.0), (3.0, 3.0) })
cuts.Codes.Add(new LinearMove(x, y) { Layer = layer });
if (!markerInSubProgram)
return cuts;
var main = new Program();
main.Codes.Add(new SubProgramCall(cuts, 0) { Id = 1 });
main.SubPrograms[1] = cuts;
return main;
}
// A tail marker anywhere but the end of the top-level program, or followed by anything but
// a rapid and the outline, is a malformed program: nothing may silently drop cuts after it.
[Theory]
[InlineData(false, false)]
[InlineData(true, true)]
[InlineData(false, true)]
public void MalformedTailMarker_IsRefusedByPostsAndVerification(bool displayTail, bool inSubProgram)
{
var program = SquareThenMarker(displayTail, inSubProgram);
Assert.Throws<InvalidOperationException>(() => DisplayPerimeterTail.ForPosting(program, "Part 1"));
Assert.Throws<ArgumentException>(() => ExecutionMotionReader.Read(program, Vector.Zero, null, default));
Assert.True(DisplayPerimeterTail.HasTail(SquareThenMarker(displayTail: true)));
}
[Fact]
public void Posting_RefusesAnUnplannedDisplayPerimeter()
{
var error = Assert.Throws<InvalidOperationException>(() => DisplayPerimeterTail.ForPosting(Door(), "Part 4"));
Assert.Contains("Part 4", error.Message);
Assert.Contains("Plan Cutting", error.Message);
var holesOnly = Door(LayerType.Cut);
Assert.Same(holesOnly, DisplayPerimeterTail.ForPosting(holesOnly, "Part 1"));
}
// A sparse support bed and a pierce zone both steer perimeter-led paths (support tabs, scrap
// pierces); a blank has no cut perimeter, so neither may reach it.
[Theory]
[InlineData("supportBed")]
[InlineData("pierceZone")]
public void Door_PlansWithSupportTabsOrAPierceZoneAndStillNeverTouchesTheOutline(string setting)
{
var plate = DoorPlate(Door());
var parameters = ExplicitContourTests.Parameters();
if (setting == "supportBed")
{
plate.SupportBed = new OpenNest.Support.SupportGrid(25, 100, 2, 2, stabilityMargin: 0.1);
parameters.SupportTabs = true;
parameters.TabConfig = new OpenNest.CNC.CuttingStrategy.NormalTab { Size = 0.05 };
}
else
parameters.PierceDiameter = 0.1;
var part = plate.Parts[0];
var proposal = CuttingPlanBatch.Capture([plate], parameters, false).Plan();
Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: false).Status);
var motions = ExecutionMotionReader.Read(part.Program, part.Location, null, default).Motions;
Assert.All(motions.Where(m => !m.Rapid), m => Assert.True(InsideACutout(m.End), $"({m.End.X}, {m.End.Y}) leaves the cutouts."));
}
// PEP draws a supplied blank a few hundred-thousandths of an inch past its sheet (an outline
// skew of 0.000006 plus placements rounded to 0.001). The outline is never cut, so automatic
// cut-offs accept it within DisplayPerimeterSheetAllowance; anything larger, or the same overhang
// on geometry that is cut, is still refused.
[Theory]
[InlineData(LayerType.DisplayPerimeter, 3e-5, 0, true)]
[InlineData(LayerType.DisplayPerimeter, 0, 3e-5, true)]
[InlineData(LayerType.DisplayPerimeter, 2e-4, 0, false)]
[InlineData(LayerType.Cut, 3e-5, 0, false)]
public void AutomaticCutOffs_AcceptOnlyADisplayPerimeterWithinTheAllowancePastTheSheet(
LayerType outline, double overRight, double overBottom, bool accepted)
{
var plate = new Plate(new Size(20, 30 - overRight));
plate.Parts.Add(new Part(new Drawing("door", Door(outline)), new Vector(0, -overBottom)));
var create = () => AutomaticCutOffPlanner.Create(plate,
new AutomaticCutOffOptions { Spacing = 35, MinimumStripLength = 17.5, MinimumTailLength = 12 },
new CutOffSettings());
if (!accepted)
{
var refusal = Assert.Throws<ArgumentException>(create);
Assert.Contains("outside the physical sheet", refusal.Message);
return;
}
// The blank fills the sheet: nothing is left to cut off.
Assert.Empty(create().Definitions);
}
// Cut-offs are staged before planning checks the door, so a malformed one can still carry a
// cut past the sheet while its outline is inside: the allowance covers the outline only.
[Fact]
public void AutomaticCutOffs_RefuseACutPastTheSheetEvenOnADisplayPerimeterPart()
{
var program = Door();
program.MoveTo(29, 1); program.LineTo(30.00003, 1); program.LineTo(30.00003, 2); program.LineTo(29, 2); program.LineTo(29, 1);
var plate = new Plate(new Size(20, 30));
plate.Parts.Add(new Part(new Drawing("door", program), Vector.Zero));
var refusal = Assert.Throws<ArgumentException>(() => AutomaticCutOffPlanner.Create(plate,
new AutomaticCutOffOptions { Spacing = 35, MinimumStripLength = 17.5, MinimumTailLength = 12 },
new CutOffSettings()));
Assert.Contains("outside the physical sheet", refusal.Message);
}
private static AutomaticCutOffPlan StageCutOffs(Plate plate) => AutomaticCutOffPlanner.Create(plate,
new AutomaticCutOffOptions { Spacing = 35, MinimumStripLength = 17.5, MinimumTailLength = 12 },
new CutOffSettings());
// The allowance belongs to the outline alone: a scribe (lasered) or a rapid past the sheet on a
// door is refused at the ordinary tolerance, except a rapid ending on the outline itself.
[Theory]
[InlineData("scribe")]
[InlineData("rapid")]
public void AutomaticCutOffs_RefuseAScribeOrRapidPastTheSheetOnADisplayPerimeterPart(string motion)
{
var program = Door();
if (motion == "scribe")
{
program.Codes.Add(new RapidMove(29, 1));
program.Codes.Add(new LinearMove(30.00003, 1) { Layer = LayerType.Scribe });
program.Codes.Add(new RapidMove(29, 2));
}
else
program.Codes.Add(new RapidMove(30.00005, 1));
var plate = new Plate(new Size(20, 30));
plate.Parts.Add(new Part(new Drawing("door", program), Vector.Zero));
var refusal = Assert.Throws<ArgumentException>(() => StageCutOffs(plate));
Assert.Contains("outside the physical sheet", refusal.Message);
}
// The door's rapid to its outline's first corner, in the drawing and again in an applied
// program's display tail, ends on the outline, so it shares the outline's allowance.
[Fact]
public void AutomaticCutOffs_AcceptTheRapidToAnOverhangingOutlineBeforeAndAfterApply()
{
var plate = new Plate(new Size(20, 30 - 3e-5));
var part = new Part(new Drawing("door", Door()), new Vector(-3e-5, 0));
plate.Parts.Add(part);
Assert.Empty(StageCutOffs(plate).Definitions);
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: false).Status);
Assert.True(DisplayPerimeterTail.HasTail(part.Program));
Assert.Empty(StageCutOffs(plate).Definitions);
}
[Fact]
public void Door_AutomaticCutOffsAddNoLineAcrossTheBlank()
{
var plate = DoorPlate(Door());
var staging = AutomaticCutOffBatch.Stage([plate],
new AutomaticCutOffOptions { Spacing = 10, MinimumStripLength = 5 }, new CutOffSettings());
Assert.False(staging.IsBlocked);
Assert.Empty(Assert.Single(staging.Plans).Definitions);
}
[Theory]
[InlineData("mixed", "one whole closed contour")]
[InlineData("hole", "enclose every other contour")]
[InlineData("nothing", "display perimeter with no cutouts")]
[InlineData("notch", "enclose every other contour")]
[InlineData("second", "enclose every other contour")]
[InlineData("tabbedOnly", "closed cutout without tabs")]
[InlineData("circleOverEdge", "enclose every other contour")]
[InlineData("edgeOverNotch", "enclose every other contour")]
public void InvalidDisplayPerimeters_AreRefusedWithTheirReason(string layout, string reason)
{
var program = layout switch
{
"mixed" => Door(lastEdge: LayerType.Cut),
"nothing" => Door(cutouts: false),
"notch" => NotchedDoor(),
"second" => SecondDisplayRing(),
"tabbedOnly" => TabbedOnly(),
"circleOverEdge" => CircleOverEdge(),
"edgeOverNotch" => EdgeOverNotch(),
_ => HoleOnDisplay(),
};
var plate = DoorPlate(program);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
confirmedParameters: ExplicitContourTests.Parameters()));
Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
Assert.Contains(result.Findings, f => f.Message.Contains(reason, StringComparison.OrdinalIgnoreCase));
// Not even an unverified plan: nothing about a refused blank may be applied.
var proposal = CuttingPlanBatch.Capture([DoorPlate(program)], ExplicitContourTests.Parameters(), false).Plan();
Assert.False(proposal.CanApply);
Assert.False(proposal.CanApplyWithWarnings);
// An L-shaped blank with a cutout in its notch: inside the outline's box, outside the blank.
static Program NotchedDoor()
{
var program = new Program();
Ring(program, LayerType.DisplayPerimeter, (0, 0), (0, 20), (15, 20), (15, 10), (30, 10), (30, 0));
Ring(program, LayerType.Cut, (2, 2), (2, 6), (6, 6), (6, 2));
Ring(program, LayerType.Cut, (20, 14), (20, 18), (25, 18), (25, 14));
return program;
}
// A round cutout whose start lies on the blank but whose arc runs past its edge.
static Program CircleOverEdge()
{
var program = Door(cutouts: false);
program.Codes.Add(new RapidMove(3, 10));
program.Codes.Add(new ArcMove(new Vector(3, 10), new Vector(1, 10), RotationType.CW));
return program;
}
// Every corner on an L-shaped blank, but the long edge crosses its notch.
static Program EdgeOverNotch()
{
var program = new Program();
Ring(program, LayerType.DisplayPerimeter, (0, 0), (0, 20), (15, 20), (15, 10), (30, 10), (30, 0));
Ring(program, LayerType.Cut, (2, 19), (29, 2), (2, 2));
return program;
}
static Program SecondDisplayRing()
{
var program = Door();
Ring(program, LayerType.DisplayPerimeter, (26, 14), (26, 18), (28, 18), (28, 14));
return program;
}
static Program TabbedOnly()
{
var program = Door(cutouts: false);
program.Codes.Add(new RapidMove(5, 5));
program.Codes.Add(new LinearMove(5, 10) { Layer = LayerType.Cut });
program.Codes.Add(new LinearMove(10, 10) { Layer = LayerType.Tab });
program.Codes.Add(new LinearMove(10, 5) { Layer = LayerType.Cut });
program.Codes.Add(new LinearMove(5, 5) { Layer = LayerType.Cut });
return program;
}
static Program HoleOnDisplay()
{
var program = Door(LayerType.Cut, cutouts: false);
program.MoveTo(5, 5);
foreach (var (x, y) in new[] { (5.0, 10.0), (10.0, 10.0), (10.0, 5.0), (5.0, 5.0) })
program.Codes.Add(new LinearMove(x, y) { Layer = LayerType.DisplayPerimeter });
return program;
}
}
private static void Ring(Program program, LayerType layer, params (double X, double Y)[] points)
{
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
foreach (var (x, y) in points.Skip(1).Append(points[0]))
program.Codes.Add(new LinearMove(x, y) { Layer = layer });
}
[Fact]
public void Preparation_HasNoCutPerimeterAndTreatsEveryContourAsAHole()
{
var prepared = PreparedContours.Capture(Door(), ExplicitContourTests.Parameters());
Assert.False(prepared.HasCutPerimeter);
Assert.Equal(3, prepared.Count);
Assert.Equal(prepared.Count, prepared.PerimeterOrdinal);
Assert.True(PreparedContours.Capture(Door(LayerType.Cut), ExplicitContourTests.Parameters()).HasCutPerimeter);
}
[Fact]
public void CleanBounds_IncludeTheDisplayPerimeter()
{
var bounds = NestedPartContainment.CleanBounds(Door(), Vector.Zero, default);
Assert.Equal(30, bounds.Length, 9);
Assert.Equal(20, bounds.Width, 9);
}
[Fact]
public void Replay_RefusesAProgramThatCutsTheDisplayPerimeter()
{
var material = LeadMaterialSnapshot.Capture(Door(), Vector.Zero);
Assert.True(material.IsComplete, material.Reason);
var holesOnly = new Program();
AddCutouts(holesOnly);
var cutsOutline = Door(LayerType.Cut);
Assert.True(ContourProgramVerifier.Verify(
ExecutionMotionReader.ReadSupported(holesOnly, Vector.Zero, null, default), material, null, default));
Assert.False(ContourProgramVerifier.Verify(
ExecutionMotionReader.ReadSupported(cutsOutline, Vector.Zero, null, default), material, null, default));
// Same count of cut rings as a valid plan: the outline in place of the circular cutout.
var outlineForAHole = Door(LayerType.Cut, cutouts: false);
outlineForAHole.MoveTo(5, 5); outlineForAHole.LineTo(5, 10); outlineForAHole.LineTo(10, 10);
outlineForAHole.LineTo(10, 5); outlineForAHole.LineTo(5, 5);
outlineForAHole.MoveTo(15, 5); outlineForAHole.LineTo(15, 12); outlineForAHole.LineTo(25, 12);
outlineForAHole.LineTo(25, 5); outlineForAHole.LineTo(15, 5);
Assert.False(ContourProgramVerifier.Verify(
ExecutionMotionReader.ReadSupported(outlineForAHole, Vector.Zero, null, default), material, null, default));
}
}