From 73e54fbd67ed24813d6368c034157e9398b87b2c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 22 Oct 2026 19:56:14 -0400 Subject: [PATCH] fix(planning): refuse misplaced display perimeters by name Review found three blanks that slipped past or failed silently. Enclosure was checked against the outline's bounding box, so a cutout in the notch of an L-shaped blank reached an unverified plan that warning acceptance could apply; every cut point is now tested inside the outline itself, and the material capture names the display perimeter when no ring encloses the rest. A blank whose only cutouts are tabbed left the search with nothing to choose and ended without a finding; it is now refused with its reason, as is a blank with nothing to cut, which used to get only the generic noncutting message. None of these offers even an unverified plan. Clean-bounds containment, seam overcut trimming and incremental closure drift now treat the display perimeter like the other material boundaries. --- .../IncrementalClosureDrift.cs | 4 +- .../CuttingPlanning/LeadMaterialSnapshot.cs | 4 +- .../CuttingPlanning/NestedPartContainment.cs | 2 +- .../CNC/CuttingPlanning/PreparedContours.cs | 17 ++--- .../CNC/CuttingPlanning/SeamOvercut.cs | 4 +- .../CuttingPlanning/CuttingPlanService.cs | 4 +- .../CuttingPlanning/DisplayPerimeterTests.cs | 67 ++++++++++++++++++- 7 files changed, 86 insertions(+), 16 deletions(-) diff --git a/OpenNest.Core/CNC/CuttingPlanning/IncrementalClosureDrift.cs b/OpenNest.Core/CNC/CuttingPlanning/IncrementalClosureDrift.cs index dc097df7..e73d6a67 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/IncrementalClosureDrift.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/IncrementalClosureDrift.cs @@ -49,8 +49,8 @@ internal static class IncrementalClosureDrift // The chains material capture closes: cut, display and tab moves between rapids and other layers. private static bool IsContour(Motion motion) => !motion.Suppressed - && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab } - or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab }; + && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter } + or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter }; private static bool TryClose(Program program, List run, Vector start, Vector end, int next) { diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs index 2530c648..728c78ec 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs @@ -134,7 +134,9 @@ public sealed class LeadMaterialSnapshot } } if (outer < 0) - throw new ArgumentException("Material must have exactly one enclosing perimeter."); + throw new ArgumentException(ringDisplay.Contains(true) + ? "A display perimeter must enclose every other contour." + : "Material must have exactly one enclosing perimeter."); if (ringTabs[outer].Any(tab => tab)) throw new ArgumentException("A perimeter cannot have tabs."); if (ringDisplay.Where((_, i) => i != outer).Any(display => display)) diff --git a/OpenNest.Core/CNC/CuttingPlanning/NestedPartContainment.cs b/OpenNest.Core/CNC/CuttingPlanning/NestedPartContainment.cs index 3c85ec27..eb31acc0 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/NestedPartContainment.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/NestedPartContainment.cs @@ -54,7 +54,7 @@ internal static class NestedPartContainment /// Material extent of a clean program at a location: rapids and scribe marks excluded. internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) => ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions - .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null) + .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter && m.Curve != null) .Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox(); /// Whether a host's bounds admit an inner part's bounds as a containment candidate. diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index e8836eab..a542cb71 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -293,15 +293,16 @@ public sealed class PreparedContours throw new ArgumentException("Preparation requires nonempty closed contours."); if (contours.Count == 0 && tabbedCutouts.Count == 0 && breakCuts.Count == 0) throw new ArgumentException("A display perimeter with no cutouts has nothing to cut."); - var outline = displayPerimeter.BoundingBox; + // Every cut must lie on the blank: a non-rectangular blank's box is not enough. + var outline = displayPerimeter.ToPolygon(); foreach (var shape in contours.Concat(tabbedCutouts).Concat(breakCuts)) - { - shape.UpdateBounds(); - var box = shape.BoundingBox; - if (box.Left < outline.Left - PostVerificationGeometry.Epsilon || box.Right > outline.Right + PostVerificationGeometry.Epsilon - || box.Bottom < outline.Bottom - PostVerificationGeometry.Epsilon || box.Top > outline.Top + PostVerificationGeometry.Epsilon) - throw new ArgumentException("A display perimeter must enclose every other contour."); - } + foreach (var entity in shape.Entities) + if (!outline.ContainsPoint(entity is Circle circle ? circle.Center + new Vector(circle.Radius, 0) : Start(entity))) + throw new ArgumentException("A display perimeter must enclose every other contour."); + // The search plans a part through its closed contours; tabbed cutouts and break cuts + // alone leave it nothing to choose. + if (contours.Count == 0) + throw new ArgumentException("A display perimeter needs at least one closed cutout without tabs."); foreach (var cut in breakCuts) cut.UpdateBounds(); // Never a contour: nothing can choose, emit or lead it. Every contour is a hole. diff --git a/OpenNest.Core/CNC/CuttingPlanning/SeamOvercut.cs b/OpenNest.Core/CNC/CuttingPlanning/SeamOvercut.cs index e723ffe4..13261612 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/SeamOvercut.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/SeamOvercut.cs @@ -72,8 +72,8 @@ internal static class SeamOvercut // Incremental coordinates only need a consistent origin within one contour. var reference = incremental ? position ?? new Vector() : new Vector(); var end = reference + motion.EndPoint; - var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display } - or ArcMove { Layer: LayerType.Cut or LayerType.Display }; + var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter } + or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter }; if (!cut || position == null) { Finish(); diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs index 1cdb8896..cb2973d9 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -105,7 +105,9 @@ public static class CuttingPlanService } var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token); if (!clean.HasCuttingContour) - throw new NotSupportedException("Scribe-only or noncutting source drawings are outside this route slice."); + throw new NotSupportedException(clean.Motions.Any(m => m.Layer == LayerType.DisplayPerimeter) + ? "A display perimeter with no cutouts has nothing to cut." + : "Scribe-only or noncutting source drawings are outside this route slice."); var execution = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token); if (!execution.HasCuttingContour) throw new ArgumentException("Placed program has no nonzero cutting contour motions."); diff --git a/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs b/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs index 56598c66..38946f0e 100644 --- a/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs +++ b/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs @@ -90,13 +90,19 @@ public class DisplayPerimeterTests [Theory] [InlineData("mixed", "one whole closed contour")] [InlineData("hole", "enclose every other contour")] - [InlineData("nothing", "noncutting")] + [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")] 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(), _ => HoleOnDisplay(), }; var plate = DoorPlate(program); @@ -106,6 +112,38 @@ public class DisplayPerimeterTests 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; + } + + 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() { @@ -117,6 +155,33 @@ public class DisplayPerimeterTests } } + 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() {