From 2cc06d1dc7e3b0b32a11405851b0347307656ba5 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 28 Sep 2026 23:08:47 -0400 Subject: [PATCH] fix(cutoffs): exclude leads from material perimeter chaining --- OpenNest.Core/Plate.cs | 8 +- .../CutOffs/CutOffLeadClearanceTests.cs | 142 ++++++++++++++++++ docs/automatic-scrap-cutoffs.md | 2 + 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 OpenNest.Tests/CutOffs/CutOffLeadClearanceTests.cs diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index abc0ad1..16eb22c 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -174,7 +174,13 @@ namespace OpenNest if (entities.Count > 0) { - var profile = new Geometry.ShapeProfile(entities); + // Leads are cutting paths in scrap, not boundary edges. Chaining them + // into the outline can make a closed part appear open and replace its + // recesses with a convex hull. Keep the full geometry for the existing + // conservative fallback when the material contour really is open. + var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin && + e.Layer != SpecialLayers.Leadout).ToList(); + var profile = new Geometry.ShapeProfile(outline); if (profile.Perimeter.IsClosed()) { diff --git a/OpenNest.Tests/CutOffs/CutOffLeadClearanceTests.cs b/OpenNest.Tests/CutOffs/CutOffLeadClearanceTests.cs new file mode 100644 index 0000000..5d7ce64 --- /dev/null +++ b/OpenNest.Tests/CutOffs/CutOffLeadClearanceTests.cs @@ -0,0 +1,142 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Tests.CutOffs; + +public class CutOffLeadClearanceTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Leads_DoNotReplaceRecessedOutlineWithConvexHull(bool horizontal, bool curved) + { + // Like P260805-10 plate 13: the main top edge is recessed 0.25 below small shoulders. + var program = new Program(); + program.MoveTo(0, 0); + foreach (var point in new[] + { + new Vector(40, 0), new Vector(40, 20.25), new Vector(38, 20.25), + new Vector(38, 20), new Vector(2, 20), new Vector(2, 20.25), + new Vector(0, 20.25), Vector.Zero, + }) + program.Codes.Add(new LinearMove(point)); + var drawing = new Drawing("recessed-top", program); + drawing.Quantity.Required = 1; + var nest = new Nest("cutoff-leads"); + nest.Drawings.Add(drawing); + var plate = nest.CreatePlate(); + plate.Size = new Size(100, 100); + var part = new Part(drawing); + if (horizontal) + part.Rotate(System.Math.PI / 2); + part.Location = new Vector(50, 10); + plate.Parts.Add(part); + var settings = new CutOffSettings(); + var cutoff = new CutOff(horizontal ? new Vector(0, 35) : new Vector(75, 0), + horizontal ? CutOffAxis.Horizontal : CutOffAxis.Vertical); + plate.CutOffs.Add(cutoff); + plate.RegenerateCutOffs(settings); + var cleanCut = NestWriter.GetProgramText(cutoff.Drawing.Program); + AssertClearance(plate, horizontal); + + part.ApplyLeadIns(new CuttingParameters + { + ExternalLeadIn = curved ? new ArcLeadIn { Radius = 0.25 } : new LineLeadIn { Length = 0.25 }, + ExternalLeadOut = curved ? new ArcLeadOut { Radius = 0.25 } : new LineLeadOut { Length = 0.25 }, + }, new Vector(-2, -2)); + Assert.Contains(OpenNest.Converters.ConvertProgram.ToGeometry(part.Program), e => e.Layer == SpecialLayers.Leadin); + Assert.Contains(OpenNest.Converters.ConvertProgram.ToGeometry(part.Program), e => e.Layer == SpecialLayers.Leadout); + var originalProgram = part.Program; + var originalText = NestWriter.GetProgramText(part.Program); + var originalLocation = part.Location; + var originalRotation = part.Rotation; + var originalBounds = part.BoundingBox; + var nested = drawing.Quantity.Nested; + + var perimeter = Assert.IsType(Plate.BuildPerimeterCache(plate)[part]); + Assert.True(perimeter.IsClosed()); + for (var iteration = 0; iteration < 2; iteration++) + { + plate.RegenerateCutOffs(settings); + Assert.Equal(cleanCut, NestWriter.GetProgramText(cutoff.Drawing.Program)); + AssertClearance(plate, horizontal); + Assert.Same(originalProgram, part.Program); + Assert.Equal(originalText, NestWriter.GetProgramText(part.Program)); + Assert.Equal(originalLocation, part.Location); + Assert.Equal(originalRotation, part.Rotation); + Assert.Equal(originalBounds, part.BoundingBox); + Assert.Equal(nested, drawing.Quantity.Nested); + Assert.Equal(1, drawing.Quantity.Required); + } + + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + stream.Position = 0; + var reader = new NestReader(stream); + var loaded = reader.Read(); + Assert.Empty(reader.Warnings); + var loadedPlate = Assert.Single(loaded.Plates); + Assert.Equal(cleanCut, NestWriter.GetProgramText(Assert.Single(loadedPlate.CutOffs).Drawing.Program)); + AssertClearance(loadedPlate, horizontal); + } + + private static void AssertClearance(Plate plate, bool horizontal) + { + var cutPart = Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff)); + var codes = cutPart.Program.Codes; + Assert.Equal(4, codes.Count); + // Independent material oracle: the unrotated outline is a union of three rectangles. + // Undo the test's exact quarter-turn, not a production geometry/offset operation. + Vector Local(Vector point) + { + point += cutPart.Location - new Vector(50, 10); + return horizontal ? new Vector(point.Y, -point.X) : point; + } + var rectangles = new[] { (0.0, 40.0, 0.0, 20.0), (0.0, 2.0, 20.0, 20.25), (38.0, 40.0, 20.0, 20.25) }; + var topGap = double.PositiveInfinity; + for (var i = 0; i < codes.Count; i += 2) + { + var start = Local(Assert.IsType(codes[i]).EndPoint); + var end = Local(Assert.IsType(codes[i + 1]).EndPoint); + var minX = System.Math.Min(start.X, end.X); + var maxX = System.Math.Max(start.X, end.X); + var minY = System.Math.Min(start.Y, end.Y); + var maxY = System.Math.Max(start.Y, end.Y); + foreach (var (left, right, bottom, top) in rectangles) + { + var dx = System.Math.Max(0, System.Math.Max(left - maxX, minX - right)); + var dy = System.Math.Max(0, System.Math.Max(bottom - maxY, minY - top)); + Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.02 - 1e-9); + } + if (minY > 20) + topGap = System.Math.Min(topGap, minY - 20); + } + Assert.InRange(topGap, 0.02, 0.021); + } + + [Fact] + public void OpenContour_WithLeadIn_StillUsesConservativeFallback() + { + var program = new Program(); + program.MoveTo(-1, 0); + program.Codes.Add(new LinearMove(0, 0) { Layer = LayerType.Leadin }); + program.LineTo(10, 0); + program.LineTo(10, 10); + var plate = new Plate(100, 100); + var part = new Part(new Drawing("open", program), new Vector(20, 20)); + plate.Parts.Add(part); + var perimeter = Assert.IsType(Plate.BuildPerimeterCache(plate)[part]); + Assert.True(perimeter.Vertices.Count >= 3); + var cutoff = new CutOff(new Vector(25, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(cutoff); + plate.RegenerateCutOffs(new CutOffSettings()); + var codes = cutoff.Drawing.Program.Codes; + Assert.Equal(4, codes.Count); + Assert.True(Assert.IsType(codes[1]).EndPoint.Y <= 19.98); + Assert.True(Assert.IsType(codes[2]).EndPoint.Y >= 25.02); + } +} diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md index 398fdbe..d8e93b6 100644 --- a/docs/automatic-scrap-cutoffs.md +++ b/docs/automatic-scrap-cutoffs.md @@ -14,6 +14,8 @@ An empty sheet is left untouched. Invalid input or out-of-sheet part geometry pr Only real parts contribute to the occupied envelope; existing cutoff parts do not. When there is room, the final separator is beyond the furthest occupied extent by the larger of plate part spacing and cutoff part clearance, plus numerical tolerance. Repeated skeleton cuts stop before that separator. The larger margin affects only the separator: repeated cuts keep the existing manual-cutoff clearance behavior. +Cutoff trimming measures clearance from the part contour, excluding tagged lead-in and lead-out moves when building the closed outline. Leads must not turn a recessed outline into a convex hull and leave oversized gaps elsewhere. Genuinely open contours still use the conservative hull/bounds fallback. The conservative curve/offset approximation can leave a small extra gap (about 0.0003 in on a straight edge at the default 0.02-in clearance), not an extra quarter inch. + If no safe separator fits inside the sheet, there is no retained-tail claim and the nominal cuts may cover the full sheet length. A proposed tail is not reported as separated unless its generated program spans the full sheet width. Obstructed or minimum-length-filtered lines produce diagnostics; they are not proof of a completed partition. For a 120-by-81-inch sheet whose parts extend through 80 inches, the nominal skeleton lines are at 35 and 70 inches, followed by a separator beyond 80 inches at the required margin. There is no line at 105 inches through the retained tail.