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() {