diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs
index d619d180..566882e7 100644
--- a/OpenNest.Core/AutomaticCutOffPlanner.cs
+++ b/OpenNest.Core/AutomaticCutOffPlanner.cs
@@ -90,6 +90,15 @@ public sealed class AutomaticCutOffPlan
public static class AutomaticCutOffPlanner
{
public const double GeometryTolerance = Tolerance.Epsilon;
+
+ ///
+ /// How far a display perimeter (a supplied blank's never-cut outline) may extend past the
+ /// physical sheet, in model units. Imported blanks equal their sheet but carry the source CAM's
+ /// rounding (PEP: a 0.000006 outline skew plus placements rounded to 0.001), a few hundred-
+ /// thousandths of an inch. Nothing cuts that outline, so it is accepted; anything cut, or a
+ /// larger overhang that signals a real placement error, is still refused.
+ ///
+ public const double DisplayPerimeterSheetAllowance = 1e-4;
public const double MinimumSpacing = 2 * GeometryTolerance;
public const int MaximumCandidateCount = 10000;
public const int MaximumShiftCandidateCount = 20000;
@@ -432,19 +441,31 @@ public static class AutomaticCutOffPlanner
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
"Part geometry has stale bounds; update it before planning.", "plate");
- Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate");
+ var hasDisplayPerimeter = DisplayPerimeterTail.HasDisplayPerimeterMotion(part.Program);
+ Require(Inside(box, sheet, hasDisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
+ "Part geometry extends outside the physical sheet.", "plate");
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
// Checking converted entities catches overflowing incremental moves and curve bounds.
var hasMaterial = false;
var occupied = sign > 0 ? box.Right : -box.Left;
var roundoff = CutOff.GetBoundsRoundoff(part);
- foreach (var entity in ConvertProgram.ToGeometry(part.Program))
+ var entities = ConvertProgram.ToGeometry(part.Program);
+ var outline = entities.Where(e => e.Layer == SpecialLayers.DisplayPerimeter).ToList();
+ foreach (var entity in entities)
{
var entityBox = entity.BoundingBox;
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
if (!SpecialLayers.IsMaterial(entity.Layer))
+ {
+ // Travel and marks (a scribe is lasered) keep the ordinary tolerance. Only a rapid
+ // ending on the outline (its first corner, or an applied program's display tail)
+ // shares the outline's allowance.
+ Require(Inside(entityBox.Translate(part.Location), sheet)
+ || hasDisplayPerimeter && IsRapidOntoOutline(entity, outline, part.Location, sheet),
+ "Part geometry extends outside the physical sheet.", "plate");
continue;
+ }
entityBox = entityBox.Translate(part.Location);
// Permit only bounded floating-point roundoff, with the same conservative
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
@@ -452,7 +473,9 @@ public static class AutomaticCutOffPlanner
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
- Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate");
+ Require(Inside(entityBox, sheet,
+ entity.Layer == SpecialLayers.DisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
+ "Part geometry extends outside the physical sheet.", "plate");
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
}
@@ -531,9 +554,21 @@ public static class AutomaticCutOffPlanner
Nonnegative(box.Length) && Nonnegative(box.Width) &&
double.IsFinite(box.Right) && double.IsFinite(box.Top);
- private static bool Inside(Box box, Box sheet) =>
- box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance &&
- box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance;
+ // A rapid that leaves the sheet only at an end lying on the display perimeter, within its allowance.
+ private static bool IsRapidOntoOutline(Entity entity, List outline, Vector location, Box sheet)
+ {
+ if (entity.Layer != SpecialLayers.Rapid || entity is not Line rapid
+ || !Inside(rapid.BoundingBox.Translate(location), sheet, DisplayPerimeterSheetAllowance))
+ return false;
+ bool Allowed(Vector point) =>
+ Inside(new Box(point.X + location.X, point.Y + location.Y, 0, 0), sheet)
+ || outline.Any(e => e.ClosestPointTo(point).DistanceTo(point) <= GeometryTolerance);
+ return Allowed(rapid.StartPoint) && Allowed(rapid.EndPoint);
+ }
+
+ private static bool Inside(Box box, Box sheet, double tolerance = GeometryTolerance) =>
+ box.Left >= sheet.Left - tolerance && box.Right <= sheet.Right + tolerance &&
+ box.Bottom >= sheet.Bottom - tolerance && box.Top <= sheet.Top + tolerance;
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
diff --git a/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs b/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs
index c0c33365..76d2a1a8 100644
--- a/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs
@@ -234,6 +234,96 @@ public class DisplayPerimeterTests
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(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(() => 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(() => 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()
{