feat(diagnostics): warn when parts are closer than the part spacing

Validate Nest, the posting review and console --post now report a
"Part spacing" finding for pairs of parts that do not overlap but sit
closer than the plate's part spacing. Before, only Auto Nest's
Keep/Discard review checked spacing, so hand-edited layouts could be
validated and posted with parts too close together.

Candidates are found with Auto Nest's own outline test, moved into Core
as SpacingOutline (NestLayoutCheck and NestTolerances now use it; Engine
tests unchanged). Because that outline circumscribes arcs by more than
the spacing slack, each candidate is confirmed on native contours
flattened 20x finer than the slack, so arcs placed exactly at the
spacing by interactive fills stay clear. The finding reports the
measured gap and marks the middle of the closest approach. Overlapping
pairs, cut-offs, zero spacing, the plate edge and clamps are not
reported as spacing findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-19 01:56:27 -04:00
1 parent 93cf8007bc
commit 5dc13a03ae
11 files changed
+638 -99

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
## Geometry and ownership safeguards
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding. Do not loosen tolerances to hide failures.
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check and the pre-post part-spacing review share Core's `SpacingOutline` for candidates; the review then confirms each one on finely flattened native contours, so it never warns where Auto Nest's check would pass. Do not loosen tolerances to hide failures.
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
@@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Diagnostics;
/// <summary>
/// Pairs of placed parts closer than the plate's part spacing. Candidates are found exactly as Auto
/// Nest's layout check finds them: each part's clean nesting material
/// (<see cref="NestingMaterial.ForNesting"/>, leads excluded) outlined by <see cref="SpacingOutline"/>,
/// the leftmost of a pair inflated by <see cref="SpacingOutline.InflationFor"/>. That outline
/// circumscribes arcs by up to its chord tolerance, more than the slack, and engines add a margin
/// for it; interactive fills place native arcs exactly at the spacing. So each candidate is
/// confirmed on the native contours flattened at <see cref="FineTolerance"/> and reported only when
/// that gap 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.
/// </summary>
internal static class PartSpacingReview
{
/// <summary>Chord tolerance of the confirming measurement. Circumscribed on both sides, it
/// understates a gap by at most twice this, a tenth of <see cref="SpacingOutline.SpacingSlack"/>.</summary>
internal const double FineTolerance = SpacingOutline.SpacingSlack / 20;
internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts,
IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings,
CancellationToken cancellationToken)
{
var spacing = plate.PartSpacing;
if (!double.IsFinite(spacing) || spacing < 0)
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
$"Part spacing check: plate part spacing {spacing} is not a finite, non-negative value."));
return;
}
var inflateBy = SpacingOutline.InflationFor(spacing);
if (inflateBy <= Tolerance.Epsilon || parts.Count < 2)
return;
var unreadable = overlap.Issues.Where(issue => issue.PartBId == null)
.Select(issue => issue.PartAId).ToHashSet();
var overlapping = overlap.Pairs.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet();
var profiles = new Dictionary<(Program, long), ShapeProfile>();
var shapes = new ShapeProfile[parts.Count];
var fine = new FineRings[parts.Count];
var raw = new SpacingOutline[parts.Count];
var inflated = new SpacingOutline[parts.Count];
for (var i = 0; i < parts.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
if (unreadable.Contains(i))
continue;
try
{
var profile = Profile(parts[i], profiles);
if (profile == null)
continue;
shapes[i] = profile;
raw[i] = SpacingOutline.Build(profile, parts[i].Location, 0);
inflated[i] = SpacingOutline.Build(profile, parts[i].Location, inflateBy);
if (raw[i] == null || inflated[i] == null)
throw new ArgumentException("Part material has no closed perimeter.");
}
catch (Exception exception) when (IsInvalid(exception))
{
raw[i] = inflated[i] = null;
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumbers[i], null,
"Part spacing check: " + exception.Message));
}
}
var order = Enumerable.Range(0, parts.Count)
.Where(i => raw[i] != null)
.OrderBy(i => raw[i].Perimeter.BoundingBox.Left)
.ToList();
for (var a = 0; a < order.Count; a++)
{
cancellationToken.ThrowIfCancellationRequested();
var i = order[a];
var reach = inflated[i].Perimeter.BoundingBox;
for (var b = a + 1; b < order.Count; b++)
{
var j = order[b];
var other = raw[j].Perimeter.BoundingBox;
// Sorted by Left, so nothing further along can reach part i either.
if (other.Left > reach.Right + Tolerance.Epsilon)
break;
if (!BoxesTouch(reach, other) || overlapping.Contains(Key(i, j))
|| !inflated[i].Overlaps(raw[j]))
continue;
double gap;
Vector marker;
try
{
fine[i] ??= FineRings.Of(shapes[i], parts[i].Location);
fine[j] ??= FineRings.Of(shapes[j], parts[j].Location);
gap = Gap(fine[i], fine[j], spacing, out marker);
}
catch (Exception exception) when (IsInvalid(exception))
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber,
System.Math.Min(partNumbers[i], partNumbers[j]),
System.Math.Max(partNumbers[i], partNumbers[j]),
"Part spacing check: " + exception.Message));
continue;
}
if (!(gap < spacing - SpacingOutline.SpacingSlack))
continue;
var (first, second) = partNumbers[i] < partNumbers[j] ? (i, j) : (j, i);
findings.Add(new(PostVerificationKind.PartSpacing, plateNumber, partNumbers[first],
partNumbers[second],
$"'{Name(parts[first])}' and '{Name(parts[second])}' are {gap:F3} apart, closer than the "
+ $"plate's part spacing ({spacing:F3}).")
{ Location = marker });
}
}
}
/// <summary>Clean drawing material, rotated to the part's pose; null when the part has none.
/// Converted once per program and rotation.</summary>
private static ShapeProfile Profile(Part part, Dictionary<(Program, long), ShapeProfile> profiles)
{
var program = part.BaseDrawing.Program;
var rotation = part.Rotation - program.Rotation;
if (!double.IsFinite(rotation))
throw new ArgumentException("Part pose must be finite.");
var key = (program, BitConverter.DoubleToInt64Bits(rotation));
if (profiles.TryGetValue(key, out var cached))
return cached;
// Conversion creates fresh geometry, so rotating it never touches the live program.
var entities = ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
.Where(entity => entity.Layer != SpecialLayers.Leadin && entity.Layer != SpecialLayers.Leadout)
.ForNesting();
if (rotation != 0)
foreach (var entity in entities)
entity.Rotate(rotation);
var profile = entities.Count == 0 ? null : new ShapeProfile(entities);
if (profile != null && profile.Perimeter == null)
throw new ArgumentException("Part material has no closed perimeter.");
profiles[key] = profile;
return profile;
}
/// <summary>Closest boundary approach between two parts, including a part inside the other's
/// cutout; <paramref name="marker"/> is the middle of that gap. Only edges within
/// <paramref name="reach"/> of the other part's bounds can be closer than it, so the rest are
/// skipped. Infinity when no edges are that near.</summary>
private static double Gap(FineRings a, FineRings b, double reach, out Vector marker)
{
var best = double.PositiveInfinity;
var middle = a.Bounds.Center;
var perimeterA = Near(a.Perimeter, b.Bounds, reach);
var perimeterB = Near(b.Perimeter, a.Bounds, reach);
Measure(perimeterA, perimeterB);
foreach (var hole in a.Holes)
Measure(Near(hole, b.Bounds, reach), perimeterB);
foreach (var hole in b.Holes)
Measure(perimeterA, Near(hole, a.Bounds, reach));
marker = middle;
return best;
void Measure(List<Line> ringA, List<Line> ringB)
{
if (ringA.Count == 0 || ringB.Count == 0)
return;
var distance = Clearance.ClosestPoints(ringA, ringB, out var onA, out var onB);
if (distance < best)
{
best = distance;
middle = (onA + onB) * 0.5;
}
}
}
private static List<Line> Near(List<Line> lines, Box other, double reach) =>
lines.Where(line =>
System.Math.Min(line.StartPoint.X, line.EndPoint.X) <= other.Right + reach
&& System.Math.Max(line.StartPoint.X, line.EndPoint.X) >= other.Left - reach
&& System.Math.Min(line.StartPoint.Y, line.EndPoint.Y) <= other.Top + reach
&& System.Math.Max(line.StartPoint.Y, line.EndPoint.Y) >= other.Bottom - reach).ToList();
/// <summary>A part's native contours in world space, flattened at <see cref="FineTolerance"/>.
/// Circumscribed, so the measured gap never exceeds the true one by more than rounding.</summary>
private sealed class FineRings
{
public List<Line> Perimeter { get; private init; }
public List<List<Line>> Holes { get; private init; }
public Box Bounds { get; private init; }
public static FineRings Of(ShapeProfile profile, Vector location)
{
var perimeter = World(profile.Perimeter, location, out var bounds);
return new FineRings
{
Perimeter = perimeter.ToLines(),
Holes = profile.Cutouts.Select(cutout => World(cutout, location, out _).ToLines()).ToList(),
Bounds = bounds,
};
}
private static Polygon World(Shape shape, Vector location, out Box bounds)
{
var polygon = ClipperBridge.Flatten(shape, FineTolerance, true);
polygon.Offset(location);
polygon.UpdateBounds();
bounds = polygon.BoundingBox;
return polygon;
}
}
private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a);
private static string Name(Part part) => part.BaseDrawing.Name;
private static bool BoxesTouch(Box a, Box b) =>
a.Left <= b.Right + Tolerance.Epsilon
&& b.Left <= a.Right + Tolerance.Epsilon
&& a.Bottom <= b.Top + Tolerance.Epsilon
&& b.Bottom <= a.Top + Tolerance.Epsilon;
private static bool IsInvalid(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
}
@@ -217,6 +217,8 @@ public static class PostVerificationAnalyzer
foreach (var issue in overlap.Issues)
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
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);
}
cancellationToken.ThrowIfCancellationRequested();
return new PostVerificationReport(findings);
@@ -29,7 +29,12 @@ public enum PostVerificationKind
NestedLead,
/// <summary>A lead-in pierce is closer to part material than half the pierce diameter.</summary>
PierceZone
PierceZone,
/// <summary>
/// Two parts are closer than the plate's part spacing without sharing material, judged as
/// Auto Nest's layout check judges them.
/// </summary>
PartSpacing
}
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
@@ -77,6 +82,8 @@ public sealed class PostVerificationReport
Summary(PostVerificationKind.NestedLead, "Nested part leads");
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PierceZone))
Summary(PostVerificationKind.PierceZone, "Pierce zones");
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSpacing))
Summary(PostVerificationKind.PartSpacing, "Part spacing");
foreach (var finding in Findings)
{
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
+37 -11
View File
@@ -96,10 +96,44 @@ namespace OpenNest.Geometry
/// <paramref name="linesB"/> at the closest contact.
/// </summary>
private static ClearanceResult Separation(List<Line> linesA, List<Line> linesB)
{
var minDist = ClosestPoints(linesA, linesB, out var pa, out var pb);
var dir = pa - pb;
var len = Magnitude(dir);
if (len <= Tolerance.Epsilon)
dir = CentroidAway(linesA, linesB);
else
dir = dir / len;
return new ClearanceResult(minDist, dir);
}
/// <summary>
/// Minimum boundary (edge-to-edge) distance between two rings and the point on each
/// boundary where it occurs. Like <see cref="BoundaryDistance(Polygon, Polygon)"/>, never
/// tests overlap. Infinity, with zero points, when either ring has no edges.
/// </summary>
public static double ClosestPoints(Polygon a, Polygon b, out Vector onA, out Vector onB)
{
var linesA = a.ToLines();
var linesB = b.ToLines();
if (linesA.Count == 0 || linesB.Count == 0)
{
onA = onB = Vector.Zero;
return double.PositiveInfinity;
}
return ClosestPoints(linesA, linesB, out onA, out onB);
}
internal static double ClosestPoints(List<Line> linesA, List<Line> linesB, out Vector pa, out Vector pb)
{
var minDist = double.MaxValue;
var pa = Vector.Zero;
var pb = Vector.Zero;
pa = Vector.Zero;
pb = Vector.Zero;
var boxes = new Box[linesB.Count];
for (var i = 0; i < linesB.Count; i++)
@@ -124,15 +158,7 @@ namespace OpenNest.Geometry
}
}
var dir = pa - pb;
var len = Magnitude(dir);
if (len <= Tolerance.Epsilon)
dir = CentroidAway(linesA, linesB);
else
dir = dir / len;
return new ClearanceResult(minDist, dir);
return minDist;
}
/// <summary>
+105
View File
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry
{
/// <summary>
/// A part's material as world-space polygons for part-spacing checks: the perimeter and its
/// cutouts, optionally grown by a clearance (perimeter offset outward, cutouts inward, in one
/// Clipper region offset). Auto Nest's layout check and the pre-post review both judge
/// spacing with this outline and <see cref="InflationFor"/>, so they cannot disagree.
/// </summary>
public sealed class SpacingOutline
{
/// <summary>Arc chord tolerance of the outline. Perimeter arcs are circumscribed and
/// cutout arcs inscribed, so flattening never hides material.</summary>
public const double ChordTolerance = 0.001;
/// <summary>How far under the part spacing a gap may fall and still pass. Layouts placed
/// exactly at the spacing land up to ~1e-4 short once rotated, rounded coordinates
/// (e.g. PEP's 4-decimal exports) meet the Clipper grid; 0.0005 covers that with margin
/// and is far below anything a cutting machine can resolve.</summary>
public const double SpacingSlack = 0.0005;
private bool prepared;
private TriangulatedRegion triangles;
public SpacingOutline(Polygon perimeter, List<Polygon> holes)
{
Perimeter = perimeter ?? throw new ArgumentNullException(nameof(perimeter));
Holes = holes ?? new List<Polygon>();
}
public Polygon Perimeter { get; }
public List<Polygon> Holes { get; }
/// <summary>Prepared on the first overlap test; null preparation is cached too.</summary>
public TriangulatedRegion Triangles
{
get
{
if (!prepared)
{
triangles = TriangulatedRegion.Build(Perimeter, Holes);
prepared = true;
}
return triangles;
}
}
/// <summary>Inflation that enforces <paramref name="spacing"/> less <see cref="SpacingSlack"/>.
/// Inflating one part of a pair by it and testing against the other's raw outline covers
/// both sides: a part inside the other's (shrunk) cutout, or the inflated outline inside
/// the other's raw cutout.</summary>
public static double InflationFor(double spacing) =>
System.Math.Max(0, spacing - SpacingSlack);
/// <summary>
/// Outlines <paramref name="profile"/> (already rotated) at <paramref name="location"/>,
/// grown by <paramref name="inflateBy"/>. A cutout that closes up under the offset is
/// dropped, which treats it as solid: conservative, since it has no room for another part
/// at the required spacing anyway. Null when the offset leaves no perimeter.
/// </summary>
public static SpacingOutline Build(ShapeProfile profile, Vector location, double inflateBy)
{
ArgumentNullException.ThrowIfNull(profile);
// Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000 segments per
// arc): arc-heavy real parts otherwise produce thousands of vertices.
var region = ClipperBridge.OffsetForValidation(
profile,
inflateBy > Tolerance.Epsilon ? inflateBy : 0,
ChordTolerance
);
var perimeter = region.LargestOuter();
if (perimeter == null)
return null;
ToWorld(perimeter, location);
foreach (var hole in region.Holes)
ToWorld(hole, location);
return new SpacingOutline(perimeter, region.Holes);
}
/// <summary>True when the two outlines share material (holes subtracted).</summary>
public bool Overlaps(SpacingOutline other)
{
ArgumentNullException.ThrowIfNull(other);
var at = Triangles;
var bt = other.Triangles;
// Keeping translations at zero preserves the reference's floating-point operation order.
return (at != null && bt != null ? at.Overlaps(bt, 0, 0, 0, 0) : null)
?? Collision.HasOverlap(Perimeter, other.Perimeter, Holes, other.Holes);
}
private static void ToWorld(Polygon polygon, Vector location)
{
polygon.Offset(location);
polygon.UpdateBounds();
}
}
}
+15 -76
View File
@@ -154,9 +154,9 @@ public static class NestLayoutCheck
{
private readonly ShapeProfile profile;
private readonly Vector location;
private PartOutline inflated;
private SpacingOutline inflated;
internal PreparedPlacement(ShapeProfile profile, Vector location, PartOutline raw, double inflateBy)
internal PreparedPlacement(ShapeProfile profile, Vector location, SpacingOutline raw, double inflateBy)
{
this.profile = profile;
this.location = location;
@@ -164,10 +164,10 @@ public static class NestLayoutCheck
InflateBy = inflateBy;
}
internal PartOutline Raw { get; }
internal SpacingOutline Raw { get; }
internal double InflateBy { get; }
internal PartOutline Inflated =>
internal SpacingOutline Inflated =>
inflated ??= InflateBy > Tolerance.Epsilon ? Outline(profile, location, InflateBy) : Raw;
}
@@ -257,7 +257,7 @@ public static class NestLayoutCheck
/// tolerance-based validation offset collapses a zone smaller than that tolerance to
/// nothing (PM 6859db53), so the rectangle is never offset or flattened here.
/// </summary>
private static PartOutline RectangleOutline(Box box)
private static SpacingOutline RectangleOutline(Box box)
{
var perimeter = new Polygon();
perimeter.Vertices.Add(new Vector(box.Left, box.Bottom));
@@ -266,7 +266,7 @@ public static class NestLayoutCheck
perimeter.Vertices.Add(new Vector(box.Left, box.Top));
perimeter.Vertices.Add(new Vector(box.Left, box.Bottom));
perimeter.UpdateBounds();
return new PartOutline { Perimeter = perimeter, Holes = new List<Polygon>() };
return new SpacingOutline(perimeter, new List<Polygon>());
}
/// <summary>
@@ -275,7 +275,7 @@ public static class NestLayoutCheck
/// on the part's native contours (not the grid-rounded validation outline): the
/// tolerant path would call real overlap clear. A part without material is skipped.
/// </summary>
private static bool OverlapsKeepOut(Part part, PartOutline keepOutOutline, Box keepOut)
private static bool OverlapsKeepOut(Part part, SpacingOutline keepOutOutline, Box keepOut)
{
if (KeepOutOverlap.NeedsToleranceFreeTest(keepOut) || keepOutOutline.Triangles == null)
{
@@ -506,8 +506,8 @@ public static class NestLayoutCheck
List<string> result
)
{
var raw = new PartOutline[parts.Count];
var inflated = new PartOutline[parts.Count];
var raw = new SpacingOutline[parts.Count];
var inflated = new SpacingOutline[parts.Count];
var inflateBy = InflationFor(spacing);
for (var i = 0; i < parts.Count; i++)
@@ -560,8 +560,7 @@ public static class NestLayoutCheck
.Translate(part.Location);
/// <summary>Inflation that enforces <paramref name="spacing"/> less the shared slack.</summary>
private static double InflationFor(double spacing) =>
System.Math.Max(0, spacing - NestTolerances.SpacingSlack);
private static double InflationFor(double spacing) => SpacingOutline.InflationFor(spacing);
private static bool BoxesTouch(Box a, Box b) =>
a.Left <= b.Right + Tolerance.Epsilon
@@ -580,40 +579,7 @@ public static class NestLayoutCheck
? requirement.Name
: part.BaseDrawing.Name;
private const double OutlineTolerance = NestTolerances.ValidationOutline;
private static bool Overlaps(PartOutline a, PartOutline b)
{
var at = a.Triangles;
var bt = b.Triangles;
// Cache the same world-space triangulation the reference would build. Keeping
// translations at zero also preserves its floating-point operation order.
return (at != null && bt != null ? at.Overlaps(bt, 0, 0, 0, 0) : null)
?? Collision.HasOverlap(a.Perimeter, b.Perimeter, a.Holes, b.Holes);
}
internal sealed class PartOutline
{
public Polygon Perimeter { get; init; }
public List<Polygon> Holes { get; init; }
private bool prepared;
private TriangulatedRegion triangles;
/// <summary>Prepared on the first candidate pair; null preparation is cached too.</summary>
public TriangulatedRegion Triangles
{
get
{
if (!prepared)
{
triangles = TriangulatedRegion.Build(Perimeter, Holes);
prepared = true;
}
return triangles;
}
}
}
private static bool Overlaps(SpacingOutline a, SpacingOutline b) => a.Overlaps(b);
/// <summary>
/// Extracts a part's material as world-space polygons - the perimeter and
@@ -625,10 +591,10 @@ public static class NestLayoutCheck
/// circumscribed, cutout arcs inscribed) but nothing is padded. Callers inflate
/// by the spacing less NestTolerances.SpacingSlack, so a layout exactly at the
/// spacing passes despite rounding; the only other leniency is the round-join
/// chord error at convex corners (OutlineTolerance / 10).
/// chord error at convex corners (SpacingOutline.ChordTolerance / 10).
/// part.Program is already rotated; only a Location offset is needed.
/// </summary>
private static PartOutline Outline(Part part, double inflateBy)
private static SpacingOutline Outline(Part part, double inflateBy)
{
var entities = ConvertProgram
.ToGeometry(part.Program)
@@ -646,33 +612,6 @@ public static class NestLayoutCheck
return Outline(profile, part.Location, inflateBy);
}
private static PartOutline Outline(ShapeProfile profile, Vector location, double inflateBy)
{
// Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000
// segments per arc) - arc-heavy real parts otherwise produce thousands
// of vertices, which is needlessly slow for a spacing check.
var region = ClipperBridge.OffsetForValidation(
profile,
inflateBy > Tolerance.Epsilon ? inflateBy : 0,
OutlineTolerance
);
var perimeter = region.LargestOuter();
if (perimeter == null)
return null;
ToWorld(perimeter, location);
foreach (var hole in region.Holes)
ToWorld(hole, location);
return new PartOutline { Perimeter = perimeter, Holes = region.Holes };
}
private static void ToWorld(Polygon polygon, Vector location)
{
polygon.Offset(location);
polygon.UpdateBounds();
}
private static SpacingOutline Outline(ShapeProfile profile, Vector location, double inflateBy) =>
SpacingOutline.Build(profile, location, inflateBy);
}
+4 -6
View File
@@ -11,13 +11,11 @@ public static class NestTolerances
{
/// <summary>Arc chord tolerance used by both validators. The layout check circumscribes
/// perimeter arcs and inscribes cutouts; the placement validator uses inscribed arcs.</summary>
public const double ValidationOutline = 0.001;
public const double ValidationOutline = SpacingOutline.ChordTolerance;
/// <summary>How far under the part spacing a gap may fall and still pass both validators.
/// Layouts placed exactly at the spacing land up to ~1e-4 short once rotated, rounded
/// coordinates (e.g. PEP's 4-decimal exports) meet the Clipper grid; 0.0005 covers that
/// with margin and is far below anything a cutting machine can resolve.</summary>
public const double SpacingSlack = 0.0005;
/// <summary>How far under the part spacing a gap may fall and still pass both validators
/// and the pre-post spacing review; see <see cref="SpacingOutline.SpacingSlack"/>.</summary>
public const double SpacingSlack = SpacingOutline.SpacingSlack;
/// <summary>How far material may extend past the work area and still pass the layout check's
/// bounds test. Matches the general geometry tolerance, so a part drawn a few millionths
@@ -0,0 +1,215 @@
using OpenNest.CNC;
using OpenNest.Diagnostics;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Tests.Diagnostics;
public class PartSpacingReviewTests
{
private const double Spacing = 0.25;
[Fact]
public void GapUnderSpacing_WarnsWithMeasuredGapAndMarker()
{
var report = Analyze(Spacing, Square(0, 0, 4), Square(4.1, 0, 4));
var finding = Assert.Single(Find(report, PostVerificationKind.PartSpacing));
Assert.Equal((1, 1, 2), (finding.PlateNumber, finding.PartNumber, finding.OtherPartNumber));
Assert.Contains("0.100 apart", finding.Message);
Assert.Contains("(0.250)", finding.Message);
Assert.NotNull(finding.Location);
Assert.Equal(4.05, finding.Location.Value.X, 3);
Assert.InRange(finding.Location.Value.Y, 0, 4);
Assert.Empty(Find(report, PostVerificationKind.Overlap));
Assert.Contains("Part spacing: 1 warning(s)", report.ToDisplayText());
Assert.False(report.CanPost(false));
Assert.True(report.CanPost(true));
}
[Fact]
public void ClearReport_OmitsPartSpacingLine()
{
var report = Analyze(Spacing, Square(0, 0, 4), Square(4.5, 0, 4));
Assert.Empty(Find(report, PostVerificationKind.PartSpacing));
Assert.DoesNotContain("Part spacing", report.ToDisplayText());
}
[Fact]
public void Touching_WarnsWhenSpacingIsRequired()
{
var finding = Assert.Single(Find(Analyze(Spacing, Square(0, 0, 4), Square(4, 0, 4)),
PostVerificationKind.PartSpacing));
Assert.Contains("0.000 apart", finding.Message);
}
[Fact]
public void ZeroSpacing_AllowsContact()
{
var report = Analyze(0, Square(0, 0, 4), Square(4, 0, 4));
Assert.Empty(Find(report, PostVerificationKind.PartSpacing));
Assert.Empty(Find(report, PostVerificationKind.Overlap));
}
[Fact]
public void OverlappingPair_IsReportedOnlyAsOverlap()
{
var report = Analyze(Spacing, Square(0, 0, 4), Square(3, 0, 4));
Assert.Single(Find(report, PostVerificationKind.Overlap));
Assert.Empty(Find(report, PostVerificationKind.PartSpacing));
}
[Theory]
[InlineData(2.1, true)] // 0.1 from the hole edge
[InlineData(4.0, false)] // 2 from every hole edge
public void PartInsideCutout_IsHeldToSpacingFromTheHoleEdge(double insert, bool warns)
{
var frame = new Program();
frame.Codes.AddRange(SquareProgram(0, 0, 10).Codes);
frame.Codes.AddRange(SquareProgram(2, 2, 6).Codes);
var report = Analyze(Spacing, new Part(new Drawing("frame", frame)), Square(insert, insert, 2));
Assert.Equal(warns, Find(report, PostVerificationKind.PartSpacing).Length == 1);
Assert.Empty(Find(report, PostVerificationKind.Overlap));
Assert.Empty(Find(report, PostVerificationKind.Incomplete));
}
[Fact]
public void RotatedPart_UsesItsPlacedPose()
{
// A 1x4 bar turned a quarter turn spans x in [-4, 0] about its location, so at x = 8.1
// its left edge sits 0.1 from the square. Read unrotated (x in [8.1, 9.1]) it would clear.
var bar = new Part(new Drawing("bar", Rectangle(1, 4)));
bar.Rotate(System.Math.PI / 2);
bar.Location = new Vector(8.1, 0);
Assert.Equal(-4, bar.BoundingBox.Left - bar.Location.X, 6);
Assert.Single(Find(Analyze(Spacing, Square(0, 0, 4), bar), PostVerificationKind.PartSpacing));
}
[Fact]
public void LeadsAndCutOffs_AreNotMaterial()
{
var leaded = new Program();
leaded.MoveTo(4.2, 2);
leaded.Codes.Add(new LinearMove(5, 2) { Layer = LayerType.Leadin });
leaded.LineTo(5, 0);
leaded.LineTo(9, 0);
leaded.LineTo(9, 4);
leaded.LineTo(5, 4);
leaded.LineTo(5, 2);
var cutoff = new Program();
cutoff.MoveTo(0, 4.05);
cutoff.LineTo(4, 4.05);
var report = Analyze(Spacing, Square(0, 0, 4), new Part(new Drawing("leaded", leaded)),
new Part(new Drawing("cutoff", cutoff) { IsCutOff = true }));
Assert.Empty(Find(report, PostVerificationKind.PartSpacing));
}
[Theory]
[InlineData(0.2)]
[InlineData(0.2494)]
[InlineData(0.2496)]
[InlineData(0.25)]
[InlineData(0.3)]
public void AgreesWithAutoNestLayoutCheck(double gap)
{
var a = Square(0, 0, 4, "a");
var b = Square(4 + gap, 1, 4, "b");
var nest = NestWith(Spacing, a, b);
var drawings = new Dictionary<Drawing, (string Name, int Quantity)>
{
[a.BaseDrawing] = ("a", 1),
[b.BaseDrawing] = ("b", 1),
};
var layout = NestLayoutCheck.Validate(new() { (nest.Plates[0], new List<Part> { a, b }) }, drawings)
.Where(v => v.Contains("spacing")).ToList();
var review = Find(PostVerificationAnalyzer.Analyze(nest), PostVerificationKind.PartSpacing);
Assert.Equal(layout.Count, review.Length);
Assert.Equal(gap < Spacing - SpacingOutline.SpacingSlack, review.Length == 1);
}
[Theory]
[InlineData(0)]
[InlineData(45)]
[InlineData(17)]
public void CirclesExactlyAtSpacing_AreClear(double degrees)
{
// Interactive fills place native arcs exactly at the spacing, with no engine margin.
var angle = degrees * System.Math.PI / 180;
var pitch = 4 + Spacing;
var report = Analyze(Spacing, Circle(0, 0, "a"),
Circle(pitch * System.Math.Cos(angle), pitch * System.Math.Sin(angle), "b"));
Assert.Empty(Find(report, PostVerificationKind.PartSpacing));
}
[Theory]
[InlineData(0)]
[InlineData(45)]
public void CirclesJustInsideSpacing_Warn(double degrees)
{
// 0.001 short: twice the slack, so a hand-nudged pair still warns.
var angle = degrees * System.Math.PI / 180;
var pitch = 4 + Spacing - 0.001;
var report = Analyze(Spacing, Circle(0, 0, "a"),
Circle(pitch * System.Math.Cos(angle), pitch * System.Math.Sin(angle), "b"));
var finding = Assert.Single(Find(report, PostVerificationKind.PartSpacing));
Assert.Contains("0.249 apart", finding.Message);
}
private static Part Circle(double x, double y, string name)
{
var program = new Program();
program.MoveTo(2, 0);
program.Codes.Add(new ArcMove(2, 0, 0, 0));
return new Part(new Drawing(name, program), new Vector(x, y));
}
private static PostVerificationFinding[] Find(PostVerificationReport report, PostVerificationKind kind) =>
report.Findings.Where(f => f.Kind == kind).ToArray();
private static PostVerificationReport Analyze(double spacing, params Part[] parts) =>
PostVerificationAnalyzer.Analyze(NestWith(spacing, parts));
private static Nest NestWith(double spacing, params Part[] parts)
{
var nest = new Nest();
var plate = nest.CreatePlate();
plate.PartSpacing = spacing;
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, SquareProgram(0, 0, size)), new Vector(x, y));
private static Program SquareProgram(double x, double y, double size)
{
var program = new Program();
program.MoveTo(x, y);
program.LineTo(x + size, y);
program.LineTo(x + size, y + size);
program.LineTo(x, y + size);
program.LineTo(x, y);
return program;
}
private static Program Rectangle(double width, double height)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(width, 0);
program.LineTo(width, height);
program.LineTo(0, height);
program.LineTo(0, 0);
return program;
}
}
+2
View File
@@ -509,6 +509,7 @@ internal sealed class CuttingInspectionPanel : UserControl
PostVerificationKind.NestedCutOrder => "Nested part order",
PostVerificationKind.NestedLead => "Nested part lead",
PostVerificationKind.PierceZone => "Pierce zone",
PostVerificationKind.PartSpacing => "Part spacing",
_ => kind.ToString(),
};
@@ -573,6 +574,7 @@ internal sealed class CuttingInspectionPanel : UserControl
PostVerificationKind.NestedCutOrder => "The ring marks the nested part that is cut after its host.",
PostVerificationKind.NestedLead => "The ring marks the middle of the lead that meets the other part.",
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.",
_ => "The ring marks the affected part; the exact spot is not known."
};
+19 -4
View File
@@ -6,8 +6,9 @@ Choose **Nest > Validate Nest...** to inspect all plates without running the cut
planner or selecting a post processor. It opens the editor's read-only side panel
in **Validate Nest (live programs)** mode, distinct from the panel's unapplied
**Plan Cutting** proposal. The validation panel checks overlapping material,
missing lead-ins, and rapid crossings in the existing placed programs and cutting
order. Findings identify plate and part sequence numbers in a read-only grid;
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
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
the warning count by kind (or says the check is incomplete or clear), a single line
@@ -21,7 +22,8 @@ restores normal rendering without changing drawing colors or selection.
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. Rapid warnings
shared patch; for missing lead-ins it marks the contour start; for part spacing it marks
the middle of the closest gap. 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;
@@ -32,7 +34,8 @@ findings without a usable location do not invent a marker. Closing the inspector
clears the highlight and restores the original plate, zoom, and overlap display mode.
Validation does not change placements, programs, lead-ins, or cutting order, save
the nest, or write CNC output. Minimum spacing and plate boundaries are not checked.
the nest, or write CNC output. Spacing to the plate edge and to clamps, and plate
boundaries, are not checked.
It does not verify a post processor's final output. Posting still runs its own
fresh verification and consent flow.
@@ -92,6 +95,18 @@ shows the first three check categories, even when there are no findings:
are not reported. A part whose clean material cannot be captured makes the check
incomplete for leads near it. This line appears in the summary only when there is such
a finding.
- **Part spacing:** two parts closer than the plate's part spacing without sharing material
(overlapping pairs are reported only as overlaps). Candidate pairs come from each part's
clean nesting material (leads excluded) tested exactly as Auto Nest's layout check tests
it, with the shared `SpacingOutline` and `SpacingSlack`, so a layout Auto Nest accepted
stays clear. That outline circumscribes arcs by more than the slack, so each candidate is
confirmed on its native contours flattened 20 times finer than the slack. The review warns
only when that gap falls short of the spacing by more than `SpacingSlack`, which lets
interactive fills that place arcs exactly at the spacing pass. On curves it is therefore
slightly more lenient than Auto Nest's check, never stricter. 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
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