diff --git a/OpenNest.Core/Diagnostics/PartSpacingReview.cs b/OpenNest.Core/Diagnostics/PartSpacingReview.cs
index 95fd1a3e..342a1f78 100644
--- a/OpenNest.Core/Diagnostics/PartSpacingReview.cs
+++ b/OpenNest.Core/Diagnostics/PartSpacingReview.cs
@@ -21,7 +21,7 @@ namespace OpenNest.Diagnostics;
/// is short of the spacing by more than the slack. Zero spacing enforces nothing, so contact stays
/// valid.
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
-/// that check. The sheet edge and clamps are not spacing-checked here. The pre-post review and the
+/// that check. Clamps are not spacing-checked here; checks the sheet edge. The pre-post review and the
/// desktop overlap label share this scan, so they cannot disagree.
///
internal static class PartSpacingReview
@@ -172,7 +172,7 @@ internal static class PartSpacingReview
/// Clean drawing material, rotated to the part's pose; null when the part has none.
/// Converted once per program and rotation.
- private static ShapeProfile Profile(Part part, Dictionary<(Program, long), ShapeProfile> profiles)
+ internal static ShapeProfile Profile(Part part, Dictionary<(Program, long), ShapeProfile> profiles)
{
var program = part.BaseDrawing.Program;
var rotation = part.Rotation - program.Rotation;
diff --git a/OpenNest.Core/Diagnostics/PlateEdgeReview.cs b/OpenNest.Core/Diagnostics/PlateEdgeReview.cs
new file mode 100644
index 00000000..23960d7d
--- /dev/null
+++ b/OpenNest.Core/Diagnostics/PlateEdgeReview.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using Clipper2Lib;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Diagnostics;
+
+///
+/// Parts that leave the sheet or come closer to its edges than the plate's edge spacing.
+///
+/// Off the plate: any cutting, lead or scribe motion of the placed program (its native
+/// curve bounds) past the sheet's bounding box, or, on an outlined sheet, clean material outside
+/// the outline or over one of its obstacles. A pierce or cut off the sheet cuts nothing, or the
+/// table.
+///
+///
+/// Edge spacing: clean nesting material (leads excluded, as the part-spacing check reads it)
+/// outside the plate's work area, or, on an outlined sheet, closer than the largest edge spacing to
+/// the outline or an obstacle: Auto Nest's layout check judges bounds exactly this way, so a layout
+/// it accepted stays clear. Shortfalls up to pass. Leads
+/// may run into the edge spacing; only off the sheet do they warn.
+///
+/// Cut-off parts are exempt: they run to the sheet edge by design.
+///
+internal static class PlateEdgeReview
+{
+ private const double Slack = SpacingOutline.SpacingSlack;
+
+ internal static void Add(Plate plate, int plateNumber, List findings,
+ CancellationToken cancellationToken)
+ {
+ if (plate.Parts.Count == 0)
+ return;
+ var size = plate.Size;
+ if (!(size.Length > 0) || !(size.Width > 0) || !double.IsFinite(size.Length) || !double.IsFinite(size.Width))
+ {
+ findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
+ $"Plate edge check: plate size {size} is not a positive, finite size."));
+ return;
+ }
+ var edges = plate.EdgeSpacing;
+ if (!new[] { edges.Left, edges.Bottom, edges.Right, edges.Top }.All(e => double.IsFinite(e) && e >= 0))
+ {
+ findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
+ "Plate edge check: plate edge spacing is not finite and non-negative on every side."));
+ return;
+ }
+ var sheet = plate.BoundingBox(includeParts: false);
+ var work = plate.WorkArea();
+ var outline = plate.Outline;
+ var origin = new Vector(sheet.X, sheet.Y);
+ var inset = System.Math.Max(System.Math.Max(edges.Left, edges.Right), System.Math.Max(edges.Top, edges.Bottom));
+ var profiles = new Dictionary<(Program, long), ShapeProfile>();
+
+ for (var index = 0; index < plate.Parts.Count; index++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var part = plate.Parts[index];
+ var partNumber = index + 1;
+ if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff)
+ continue;
+ try
+ {
+ if (PastSheet(part, sheet, cancellationToken) is { } past)
+ {
+ findings.Add(new(PostVerificationKind.OffPlate, plateNumber, partNumber, null,
+ $"'{part.BaseDrawing.Name}' runs {past.Distance:F3} past the plate's {past.Edge} edge.")
+ { Location = past.Marker });
+ continue;
+ }
+
+ var profile = PartSpacingReview.Profile(part, profiles);
+ if (profile == null)
+ continue;
+ PathsD region = null;
+ if (outline != null)
+ {
+ var material = SpacingOutline.Build(profile, part.Location, 0)
+ ?? throw new ArgumentException("Part material has no closed perimeter.");
+ region = new PathsD { ClipperBridge.ToPath(material.Perimeter, positive: true) };
+ if (!outline.Holds(region, 0, origin, Slack))
+ {
+ findings.Add(new(PostVerificationKind.OffPlate, plateNumber, partNumber, null,
+ $"'{part.BaseDrawing.Name}' lies outside the plate's outline or over one of its obstacles.")
+ { Location = Center(profile, part.Location) });
+ continue;
+ }
+ }
+
+ var bounds = profile.Perimeter.BoundingBox.Translate(part.Location);
+ if (Shortest(bounds, sheet, work) is { } close)
+ {
+ findings.Add(new(PostVerificationKind.EdgeSpacing, plateNumber, partNumber, null,
+ $"'{part.BaseDrawing.Name}' is {close.Gap:F3} from the plate's {close.Edge} edge, closer than "
+ + $"its edge spacing ({close.Spacing:F3}).")
+ { Location = close.Marker });
+ continue;
+ }
+ if (region != null && inset > 0 && !outline.Holds(region, inset, origin, Slack))
+ findings.Add(new(PostVerificationKind.EdgeSpacing, plateNumber, partNumber, null,
+ $"'{part.BaseDrawing.Name}' is closer than the edge spacing ({inset:F3}) to the plate's outline "
+ + "or one of its obstacles.")
+ { Location = Center(profile, part.Location) });
+ }
+ catch (Exception exception) when (IsInvalid(exception))
+ {
+ findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null,
+ "Plate edge check: " + exception.Message));
+ }
+ }
+ }
+
+ private readonly record struct Past(string Edge, double Distance, Vector Marker);
+
+ private readonly record struct Close(string Edge, double Gap, double Spacing, Vector Marker);
+
+ /// The furthest any non-rapid motion of the placed program runs past the sheet, or null.
+ private static Past? PastSheet(Part part, Box sheet, CancellationToken cancellationToken)
+ {
+ Past? worst = null;
+ foreach (var move in ExecutionMotionReader.Read(part.Program, part.Location, null, cancellationToken).Motions)
+ {
+ if (move.Rapid || move.Curve == null)
+ continue;
+ var box = move.Curve.ToEntity().BoundingBox;
+ var middle = move.Curve.Midpoint;
+ Consider("left", sheet.Left - box.Left, new Vector(sheet.Left, Clamp(middle.Y, sheet.Bottom, sheet.Top)));
+ Consider("bottom", sheet.Bottom - box.Bottom, new Vector(Clamp(middle.X, sheet.Left, sheet.Right), sheet.Bottom));
+ Consider("right", box.Right - sheet.Right, new Vector(sheet.Right, Clamp(middle.Y, sheet.Bottom, sheet.Top)));
+ Consider("top", box.Top - sheet.Top, new Vector(Clamp(middle.X, sheet.Left, sheet.Right), sheet.Top));
+ }
+ return worst;
+
+ void Consider(string edge, double distance, Vector marker)
+ {
+ if (!double.IsFinite(distance))
+ throw new ArgumentException("Placed program geometry must be finite.");
+ if (distance > Slack && (worst == null || distance > worst.Value.Distance))
+ worst = new Past(edge, distance, marker);
+ }
+ }
+
+ /// The side whose edge spacing the material falls furthest short of, or null.
+ private static Close? Shortest(Box bounds, Box sheet, Box work)
+ {
+ Close? worst = null;
+ var shortfall = 0.0;
+ var center = bounds.Center;
+ Consider("left", work.Left - bounds.Left, bounds.Left - sheet.Left, work.Left - sheet.Left,
+ new Vector((sheet.Left + bounds.Left) / 2, center.Y));
+ Consider("bottom", work.Bottom - bounds.Bottom, bounds.Bottom - sheet.Bottom, work.Bottom - sheet.Bottom,
+ new Vector(center.X, (sheet.Bottom + bounds.Bottom) / 2));
+ Consider("right", bounds.Right - work.Right, sheet.Right - bounds.Right, sheet.Right - work.Right,
+ new Vector((sheet.Right + bounds.Right) / 2, center.Y));
+ Consider("top", bounds.Top - work.Top, sheet.Top - bounds.Top, sheet.Top - work.Top,
+ new Vector(center.X, (sheet.Top + bounds.Top) / 2));
+ return worst;
+
+ void Consider(string edge, double by, double gap, double spacing, Vector marker)
+ {
+ if (by > Slack && by > shortfall)
+ {
+ shortfall = by;
+ worst = new Close(edge, System.Math.Max(0, gap), spacing, marker);
+ }
+ }
+ }
+
+ private static Vector Center(ShapeProfile profile, Vector location) =>
+ profile.Perimeter.BoundingBox.Translate(location).Center;
+
+ private static double Clamp(double value, double low, double high) =>
+ double.IsFinite(value) ? System.Math.Clamp(value, low, high) : low;
+
+ private static bool IsInvalid(Exception exception) => exception is
+ ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
+}
diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
index 10d8989c..3e9cdfa5 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
@@ -226,6 +226,8 @@ public static class PostVerificationAnalyzer
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
// Parts that do not overlap may still be closer than the plate's part spacing.
PartSpacingReview.Add(plate, plateNumber, materialParts, indices, overlap, findings, cancellationToken);
+ // Parts must stay on the sheet and keep its edge spacing.
+ PlateEdgeReview.Add(plate, plateNumber, findings, cancellationToken);
// Parts the support bed cannot hold level need a tab that keeps them level.
PartSupportReview.Add(plate, plateNumber, findings, cancellationToken);
}
diff --git a/OpenNest.Core/Diagnostics/PostVerificationReport.cs b/OpenNest.Core/Diagnostics/PostVerificationReport.cs
index b8cc1ac6..f0f71c15 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationReport.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationReport.cs
@@ -40,7 +40,19 @@ public enum PostVerificationKind
/// A part its plate's support bed cannot hold level has no tab that keeps it level, so it may
/// tip once cut free (see docs/support-tabs.md).
///
- PartSupport
+ PartSupport,
+
+ ///
+ /// A part's cuts, leads or scribes run past the sheet, or its material lies outside an outlined
+ /// sheet or over one of its obstacles.
+ ///
+ OffPlate,
+
+ ///
+ /// A part's clean material is closer to the sheet's edge than the plate's edge spacing, judged as
+ /// Auto Nest's layout check judges it.
+ ///
+ EdgeSpacing
}
/// Plate and part numbers are one-based; plate zero denotes a whole-post limitation.
@@ -92,6 +104,10 @@ public sealed class PostVerificationReport
Summary(PostVerificationKind.PartSpacing, "Part spacing");
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSupport))
Summary(PostVerificationKind.PartSupport, "Part support");
+ if (Findings.Any(finding => finding.Kind == PostVerificationKind.OffPlate))
+ Summary(PostVerificationKind.OffPlate, "Off the plate");
+ if (Findings.Any(finding => finding.Kind == PostVerificationKind.EdgeSpacing))
+ Summary(PostVerificationKind.EdgeSpacing, "Edge spacing");
foreach (var finding in Findings)
{
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanCommitTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanCommitTests.cs
index cef3744d..49f1ae10 100644
--- a/OpenNest.Tests/CuttingPlanning/CuttingPlanCommitTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanCommitTests.cs
@@ -80,7 +80,7 @@ public class CuttingPlanCommitTests
var detached = proposal.CopyProgram();
detached.Codes.Clear();
Assert.NotEmpty(part.Program.Codes);
- Assert.Empty(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings);
+ Assert.Empty(TestHelpers.RouteFindings(PostVerificationAnalyzer.Analyze(nest, Vector.Zero)).Findings);
}
[Theory]
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs
index f8e4a13b..0e5dd320 100644
--- a/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs
@@ -411,6 +411,6 @@ public class CuttingPlanServiceTests
Assert.True(copy.RestoreLeadInProgram((Program)source.Program.Clone(), source.LeadInsLocked));
plate.Parts.Add(copy);
}
- return PostVerificationAnalyzer.Analyze(nest, start);
+ return TestHelpers.RouteFindings(PostVerificationAnalyzer.Analyze(nest, start));
}
}
diff --git a/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs b/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs
index f8bafd01..cefef4b8 100644
--- a/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs
@@ -79,7 +79,7 @@ public class JointCuttingPlanTests
var copy = new Part(new Drawing("replay", (Program)part.BaseDrawing.Program.Clone()), proposal.Location);
Assert.True(copy.RestoreLeadInProgram(proposal.CopyProgram(), false));
plate.Parts.Add(copy);
- var report = PostVerificationAnalyzer.Analyze(nest, Vector.Zero);
+ var report = TestHelpers.RouteFindings(PostVerificationAnalyzer.Analyze(nest, Vector.Zero));
Assert.Empty(report.Findings);
var detached = proposal.CopyProgram(); detached.Codes.Clear(); detached.SubPrograms.Clear();
Assert.NotEmpty(proposal.CopyProgram().Codes);
diff --git a/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs b/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs
index ee2de123..483de0e7 100644
--- a/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs
@@ -232,7 +232,7 @@ public class ReorderSearchTests
Assert.True(copy.RestoreLeadInProgram((Program)source.Program.Clone(), source.LeadInsLocked));
plate.Parts.Add(copy);
}
- return PostVerificationAnalyzer.Analyze(nest, start);
+ return TestHelpers.RouteFindings(PostVerificationAnalyzer.Analyze(nest, start));
}
private static string Describe(CuttingPlanResult r) =>
diff --git a/OpenNest.Tests/Diagnostics/PlateEdgeReviewTests.cs b/OpenNest.Tests/Diagnostics/PlateEdgeReviewTests.cs
new file mode 100644
index 00000000..d2a5e7c3
--- /dev/null
+++ b/OpenNest.Tests/Diagnostics/PlateEdgeReviewTests.cs
@@ -0,0 +1,216 @@
+using OpenNest.CNC;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+using static OpenNest.Tests.TestHelpers;
+
+namespace OpenNest.Tests.Diagnostics;
+
+public class PlateEdgeReviewTests
+{
+ [Fact]
+ public void PartInsideWorkArea_IsClear()
+ {
+ var report = Analyze(Edges(0.5), Square(1, 1, 4));
+
+ Assert.Empty(Find(report, PostVerificationKind.OffPlate));
+ Assert.Empty(Find(report, PostVerificationKind.EdgeSpacing));
+ Assert.DoesNotContain("Off the plate", report.ToDisplayText());
+ Assert.DoesNotContain("Edge spacing", report.ToDisplayText());
+ }
+
+ [Fact]
+ public void PartAtEdgeSpacing_IsClear()
+ {
+ var report = Analyze(Edges(0.5), Square(0.5, 0.5, 4));
+
+ Assert.Empty(Find(report, PostVerificationKind.EdgeSpacing));
+ }
+
+ [Fact]
+ public void PartInsideEdgeSpacing_WarnsWithGapAndSide()
+ {
+ var report = Analyze(Edges(0.5), Square(0.2, 2, 4));
+
+ var finding = Assert.Single(Find(report, PostVerificationKind.EdgeSpacing));
+ Assert.Equal((1, 1), (finding.PlateNumber, finding.PartNumber));
+ Assert.Contains("0.200 from the plate's left edge", finding.Message);
+ Assert.Contains("(0.500)", finding.Message);
+ Assert.Equal(0.1, finding.Location!.Value.X, 6);
+ Assert.Contains("Edge spacing: 1 warning(s)", report.ToDisplayText());
+ Assert.False(report.CanPost(false));
+ }
+
+ [Fact]
+ public void FarEdges_FollowThePlateSize()
+ {
+ // Default plate is 120 long (X) and 60 wide (Y).
+ var finding = Assert.Single(Find(Analyze(Edges(0.5), Square(115.7, 20, 4)),
+ PostVerificationKind.EdgeSpacing));
+ Assert.Contains("right edge", finding.Message);
+
+ finding = Assert.Single(Find(Analyze(Edges(0.5), Square(20, 55.9, 4)), PostVerificationKind.EdgeSpacing));
+ Assert.Contains("top edge", finding.Message);
+ }
+
+ [Fact]
+ public void ShortfallWithinSlack_IsClear()
+ {
+ var report = Analyze(Edges(0.5), Square(0.5 - SpacingOutline.SpacingSlack / 2, 2, 4));
+
+ Assert.Empty(Find(report, PostVerificationKind.EdgeSpacing));
+ }
+
+ [Fact]
+ public void PartPastSheet_IsOffPlateNotEdgeSpacing()
+ {
+ var report = Analyze(Edges(0.5), Square(-1, 2, 4));
+
+ var finding = Assert.Single(Find(report, PostVerificationKind.OffPlate));
+ Assert.Contains("1.000 past the plate's left edge", finding.Message);
+ Assert.Equal(0, finding.Location!.Value.X, 6);
+ Assert.Empty(Find(report, PostVerificationKind.EdgeSpacing));
+ Assert.Contains("Off the plate: 1 warning(s)", report.ToDisplayText());
+ }
+
+ [Fact]
+ public void PartEntirelyOffSheet_IsOffPlate()
+ {
+ var finding = Assert.Single(Find(Analyze(new Spacing(), Square(130, 10, 4)), PostVerificationKind.OffPlate));
+ Assert.Contains("past the plate's right edge", finding.Message);
+ }
+
+ [Fact]
+ public void ZeroEdgeSpacing_AllowsContactWithTheEdge()
+ {
+ var report = Analyze(new Spacing(), Square(0, 0, 4), Square(116, 56, 4));
+
+ Assert.Empty(Find(report, PostVerificationKind.OffPlate));
+ Assert.Empty(Find(report, PostVerificationKind.EdgeSpacing));
+ }
+
+ [Fact]
+ public void LeadInPastSheet_IsOffPlate()
+ {
+ // The part keeps zero edge spacing, but its lead-in starts 0.25 off the left edge.
+ var program = new Program();
+ program.MoveTo(-0.25, 2);
+ program.Codes.Add(new LinearMove(0, 2) { Layer = LayerType.Leadin });
+ program.LineTo(0, 0);
+ program.LineTo(4, 0);
+ program.LineTo(4, 4);
+ program.LineTo(0, 4);
+ program.LineTo(0, 2);
+ var part = new Part(new Drawing("leaded", program), new Vector(0, 0));
+
+ var report = Analyze(new Spacing(), part);
+
+ var finding = Assert.Single(Find(report, PostVerificationKind.OffPlate));
+ Assert.Contains("0.250 past the plate's left edge", finding.Message);
+ }
+
+ [Fact]
+ public void LeadInsideEdgeSpacing_IsClear()
+ {
+ // Leads may run into the edge spacing; only material is held to it.
+ var program = new Program();
+ program.MoveTo(-0.25, 2);
+ program.Codes.Add(new LinearMove(0, 2) { Layer = LayerType.Leadin });
+ program.LineTo(0, 0);
+ program.LineTo(4, 0);
+ program.LineTo(4, 4);
+ program.LineTo(0, 4);
+ program.LineTo(0, 2);
+ var part = new Part(new Drawing("leaded", program), new Vector(0.5, 0.5));
+
+ var report = Analyze(Edges(0.5), part);
+
+ Assert.Empty(Find(report, PostVerificationKind.OffPlate));
+ Assert.Empty(Find(report, PostVerificationKind.EdgeSpacing));
+ }
+
+ [Fact]
+ public void Quadrant3Sheet_ChecksItsSignedFrame()
+ {
+ var nest = new Nest();
+ nest.PlateDefaults.Quadrant = 3;
+ var plate = nest.CreatePlate();
+ plate.EdgeSpacing = Edges(0.5);
+ plate.Parts.Add(Square(-4.2, -10, 4));
+
+ var finding = Assert.Single(Find(PostVerificationAnalyzer.Analyze(nest), PostVerificationKind.EdgeSpacing));
+ Assert.Contains("right edge", finding.Message);
+ }
+
+ [Fact]
+ public void OutlinedSheet_WarnsOverObstacleAndNearOutline()
+ {
+ var outline = new SheetOutline(
+ new[] { new Vector(0, 0), new Vector(120, 0), new Vector(120, 60), new Vector(0, 60) },
+ new[] { new[] { new Vector(50, 20), new Vector(70, 20), new Vector(70, 40), new Vector(50, 40) } });
+
+ var over = NestWith(Edges(0.5), Square(48, 25, 4));
+ over.Plates[0].Outline = outline;
+ Assert.Single(Find(PostVerificationAnalyzer.Analyze(over), PostVerificationKind.OffPlate));
+
+ var near = NestWith(Edges(0.5), Square(45.8, 25, 4));
+ near.Plates[0].Outline = outline;
+ var report = PostVerificationAnalyzer.Analyze(near);
+ Assert.Empty(Find(report, PostVerificationKind.OffPlate));
+ var finding = Assert.Single(Find(report, PostVerificationKind.EdgeSpacing));
+ Assert.Contains("outline or one of its obstacles", finding.Message);
+
+ var clear = NestWith(Edges(0.5), Square(10, 10, 4));
+ clear.Plates[0].Outline = outline;
+ report = PostVerificationAnalyzer.Analyze(clear);
+ Assert.Empty(Find(report, PostVerificationKind.OffPlate));
+ Assert.Empty(Find(report, PostVerificationKind.EdgeSpacing));
+ }
+
+ [Fact]
+ public void CutOffParts_AreExempt()
+ {
+ var drawing = new Drawing("cutoff", Line(-1, 30, 125, 30)) { IsCutOff = true };
+ var report = Analyze(Edges(0.5), new Part(drawing, new Vector(0, 0)));
+
+ Assert.Empty(Find(report, PostVerificationKind.OffPlate));
+ Assert.Empty(Find(report, PostVerificationKind.EdgeSpacing));
+ }
+
+ [Fact]
+ public void InvalidEdgeSpacing_IsIncomplete()
+ {
+ var report = Analyze(new Spacing(double.NaN, 0, 0, 0), Square(10, 10, 4));
+
+ Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.Incomplete
+ && f.Message.StartsWith("Plate edge check:"));
+ }
+
+ private static Program Line(double x1, double y1, double x2, double y2)
+ {
+ var program = new Program();
+ program.MoveTo(x1, y1);
+ program.LineTo(x2, y2);
+ return program;
+ }
+
+ private static Spacing Edges(double all) => new(all, all, all, all);
+
+ private static PostVerificationFinding[] Find(PostVerificationReport report, PostVerificationKind kind) =>
+ report.Findings.Where(f => f.Kind == kind).ToArray();
+
+ private static PostVerificationReport Analyze(Spacing edges, params Part[] parts) =>
+ PostVerificationAnalyzer.Analyze(NestWith(edges, parts));
+
+ private static Nest NestWith(Spacing edges, params Part[] parts)
+ {
+ var nest = new Nest();
+ var plate = nest.CreatePlate();
+ plate.EdgeSpacing = edges;
+ foreach (var part in parts)
+ plate.Parts.Add(part);
+ return nest;
+ }
+
+ private static Part Square(double x, double y, double size, string name = "square") =>
+ new(new Drawing(name, MakeSquareProgram(0, 0, size)), new Vector(x, y));
+}
diff --git a/OpenNest.Tests/Diagnostics/PostVerificationPostContractTests.cs b/OpenNest.Tests/Diagnostics/PostVerificationPostContractTests.cs
index 6aa61ce4..1c908706 100644
--- a/OpenNest.Tests/Diagnostics/PostVerificationPostContractTests.cs
+++ b/OpenNest.Tests/Diagnostics/PostVerificationPostContractTests.cs
@@ -53,7 +53,7 @@ public class PostVerificationPostContractTests
program.LineTo(4, 4);
program.LineTo(0, 4);
program.LineTo(0, 0);
- var part = new Part(new Drawing("square", program));
+ var part = new Part(new Drawing("square", program), new Vector(5, 5));
part.ApplyLeadIns(new CuttingParameters { ExternalLeadIn = new LineLeadIn { Length = 0.5 } },
new Vector(-2, -2));
var nest = new Nest();
diff --git a/OpenNest.Tests/TestHelpers.cs b/OpenNest.Tests/TestHelpers.cs
index f0750251..fc09fd04 100644
--- a/OpenNest.Tests/TestHelpers.cs
+++ b/OpenNest.Tests/TestHelpers.cs
@@ -34,6 +34,15 @@ internal static class TestConfig
internal static class TestHelpers
{
+ ///
+ /// The report without its sheet-bound findings, for routing replays whose fixtures sit at the
+ /// sheet origin with leads hanging off it. PlateEdgeReviewTests covers those checks.
+ ///
+ public static OpenNest.Diagnostics.PostVerificationReport RouteFindings(
+ OpenNest.Diagnostics.PostVerificationReport report) =>
+ new(report.Findings.Where(f => f.Kind is not (OpenNest.Diagnostics.PostVerificationKind.OffPlate
+ or OpenNest.Diagnostics.PostVerificationKind.EdgeSpacing)));
+
public static Part MakePartAt(double x, double y, double size = 1)
{
// CW winding matches CNC convention (OffsetSide.Left = outward)
diff --git a/OpenNest/Forms/CuttingInspectionPanel.cs b/OpenNest/Forms/CuttingInspectionPanel.cs
index f874672f..179f948b 100644
--- a/OpenNest/Forms/CuttingInspectionPanel.cs
+++ b/OpenNest/Forms/CuttingInspectionPanel.cs
@@ -642,6 +642,8 @@ internal sealed class CuttingInspectionPanel : UserControl
PostVerificationKind.PierceZone => "Pierce zone",
PostVerificationKind.PartSpacing => "Part spacing",
PostVerificationKind.PartSupport => "Part support",
+ PostVerificationKind.OffPlate => "Off the plate",
+ PostVerificationKind.EdgeSpacing => "Edge spacing",
_ => kind.ToString(),
};
@@ -708,6 +710,8 @@ internal sealed class CuttingInspectionPanel : UserControl
PostVerificationKind.PierceZone => "The ring marks the pierce that is too close to part material.",
PostVerificationKind.PartSpacing => "The ring marks where the two parts come closest.",
PostVerificationKind.PartSupport => "The ring marks the part's center of gravity.",
+ PostVerificationKind.OffPlate => "The ring marks where the part leaves the plate.",
+ PostVerificationKind.EdgeSpacing => "The ring marks the gap between the part and the plate edge.",
_ => "The ring marks the affected part; the exact spot is not known."
};
diff --git a/OpenNest/Forms/PostVerificationForm.cs b/OpenNest/Forms/PostVerificationForm.cs
index 6dbe1da8..f19ca01c 100644
--- a/OpenNest/Forms/PostVerificationForm.cs
+++ b/OpenNest/Forms/PostVerificationForm.cs
@@ -21,6 +21,7 @@ public partial class PostVerificationForm : Form
{
(PostVerificationKind.Incomplete, "Incomplete checks"),
(PostVerificationKind.Overlap, "Overlaps"),
+ (PostVerificationKind.OffPlate, "Off the plate"),
(PostVerificationKind.RapidCrossing, "Rapid crossings"),
(PostVerificationKind.MissingLeadIn, "Missing lead-ins"),
(PostVerificationKind.NestedCutOrder, "Nested part order"),
@@ -28,6 +29,7 @@ public partial class PostVerificationForm : Form
(PostVerificationKind.FixedLead, "Fixed leads"),
(PostVerificationKind.PierceZone, "Pierce zones"),
(PostVerificationKind.PartSpacing, "Part spacing"),
+ (PostVerificationKind.EdgeSpacing, "Edge spacing"),
(PostVerificationKind.PartSupport, "Part support"),
};
diff --git a/docs/post-verification.md b/docs/post-verification.md
index c52ed178..0fbeaf40 100644
--- a/docs/post-verification.md
+++ b/docs/post-verification.md
@@ -7,7 +7,8 @@ planner or selecting a post processor. It opens the editor's read-only side pane
in **Validate Nest (live programs)** mode, distinct from the panel's unapplied
**Plan Cutting** proposal. The validation panel checks overlapping material,
missing lead-ins, rapid crossings in the existing placed programs and cutting
-order, and parts closer than the plate's part spacing. Findings identify plate and part
+order, parts closer than the plate's part spacing, and parts off the plate or inside
+its edge spacing. Findings identify plate and part
sequence numbers in a read-only grid;
incomplete or failed checks are never reported as clear. Select a row to switch
the main plate view to that plate and focus the finding. A colored headline gives
@@ -23,7 +24,8 @@ Overlaps use the same magenta material shading as **View > Overlap Check**. A
DPI-scaled ring expands and fades continuously while the finding is selected;
both its color and contrasting halo fade as it grows. For overlaps the ring marks a point inside the largest
shared patch; for missing lead-ins it marks the contour start; for part spacing it marks
-the middle of the closest gap. Rapid warnings
+the middle of the closest gap; for edge spacing it marks the gap between the part and
+the edge; for a part off the plate it marks where it leaves the plate. Rapid warnings
pulse the offending orange dashed segment continuously and mark its contacts with
the completed contour using fixed-screen-size magenta crosshairs with white halos.
Contacts use native lines/arcs and the rapid check's existing numerical tolerances;
@@ -36,7 +38,7 @@ clears the highlight and restores the original plate, zoom, and overlap display
Validation does not change placements, programs, lead-ins, or cutting order, save
the nest, or write CNC output. It includes material overlaps with active clamps and
cut-off or rapid crossings of their footprints, but does not check minimum spacing
-to clamps or plate edges, or whether parts lie within the plate boundaries.
+to clamps.
It does not verify a post processor's final output. Posting still runs its own
fresh verification and consent flow.
@@ -118,9 +120,22 @@ shows the first three check categories, even when there are no findings:
than `SpacingSlack`, which lets interactive fills that place arcs exactly at the spacing
pass. Auto Nest's layout check and the whole-job runner's candidate validation confirm the
same way, so all three agree. Zero spacing allows contact.
- A part inside another's cutout is held to the spacing from the hole edge. Cut-offs, the
- plate edge and clamps are not spacing-checked. The finding gives the measured gap; this
+ A part inside another's cutout is held to the spacing from the hole edge. Cut-offs and
+ clamps are not spacing-checked; the plate edge has its own check below. The finding gives the measured gap; this
line appears in the summary only when there is such a finding.
+- **Off the plate:** a part whose placed program cuts, leads or scribes past the sheet
+ (native curve bounds, beyond `SpacingSlack`), or, on an [outlined sheet](irregular-stock.md),
+ whose clean material lies outside the outline or over one of its obstacles. The finding
+ gives how far it runs past which edge. This line appears in the summary only when there
+ is such a finding.
+- **Edge spacing:** a part on the sheet whose clean nesting material (leads excluded) lies
+ outside the plate's work area, so closer to an edge than that side's edge spacing, or, on
+ an outlined sheet, closer than the largest edge spacing to the outline or an obstacle.
+ This is the bound Auto Nest's layout check applies, so a layout it accepted stays clear;
+ shortfalls up to `SpacingSlack` pass. Leads may run into the edge spacing. The finding
+ gives the gap and the side's spacing. Zero edge spacing allows contact with the edge.
+ Cut-off parts are exempt from both checks. This line appears in the summary only when
+ there is such a finding.
- **Nested parts:** a part whose perimeter lies strictly inside a cutout of another part (its
host), proven on clean material exactly as Plan Cutting proves its inner-before-host
prerequisite. **Nested part order** warns when the nested part comes after its host in the