Files
OpenNest/OpenNest.Core/Diagnostics/PlateEdgeReview.cs
T
ajandClaude Opus 5.5 f9abab0c6d feat(verify): warn when parts leave the plate or its edge spacing
Validate Nest and the pre-post review checked overlaps and part-to-part
spacing but never the sheet itself, so a part dragged past the plate edge,
a lead-in piercing off the sheet, or a part inside the edge spacing could
post without a warning. Only Auto Nest's own layout check held parts to
the work area.

PlateEdgeReview adds two finding kinds:
- OffPlate: any cut, lead or scribe motion past the sheet, or clean
  material outside an outlined sheet or over one of its obstacles.
- EdgeSpacing: clean material (leads excluded) outside the work area, or
  closer than the largest edge spacing to an outline or obstacle - the
  bound Auto Nest's layout check applies, with SpacingSlack, so layouts it
  accepted stay clear.

Cut-off parts are exempt. Routing-replay test fixtures sit at the sheet
origin with leads hanging off it; their route assertions now ignore the
sheet-bound kinds, and the post-contract fixture moves onto the sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 08:59:23 -05:00

183 lines
8.9 KiB
C#

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;
/// <summary>
/// Parts that leave the sheet or come closer to its edges than the plate's edge spacing.
/// <para>
/// <b>Off the plate</b>: 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.
/// </para>
/// <para>
/// <b>Edge spacing</b>: 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 <see cref="SpacingOutline.SpacingSlack"/> pass. Leads
/// may run into the edge spacing; only off the sheet do they warn.
/// </para>
/// Cut-off parts are exempt: they run to the sheet edge by design.
/// </summary>
internal static class PlateEdgeReview
{
private const double Slack = SpacingOutline.SpacingSlack;
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> 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);
/// <summary>The furthest any non-rapid motion of the placed program runs past the sheet, or null.</summary>
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);
}
}
/// <summary>The side whose edge spacing the material falls furthest short of, or null.</summary>
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;
}