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