diff --git a/AGENTS.md b/AGENTS.md
index 798530a5..af2fc746 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/OpenNest.Core/Diagnostics/PartSpacingReview.cs b/OpenNest.Core/Diagnostics/PartSpacingReview.cs
new file mode 100644
index 00000000..0ccb9c00
--- /dev/null
+++ b/OpenNest.Core/Diagnostics/PartSpacingReview.cs
@@ -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;
+
+///
+/// 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
+/// (, leads excluded) outlined by ,
+/// the leftmost of a pair inflated by . 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 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.
+///
+internal static class PartSpacingReview
+{
+ /// Chord tolerance of the confirming measurement. Circumscribed on both sides, it
+ /// understates a gap by at most twice this, a tenth of .
+ internal const double FineTolerance = SpacingOutline.SpacingSlack / 20;
+
+ internal static void Add(Plate plate, int plateNumber, IReadOnlyList parts,
+ IReadOnlyList partNumbers, PlateOverlapReport overlap, List 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 });
+ }
+ }
+ }
+
+ /// 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)
+ {
+ 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;
+ }
+
+ /// Closest boundary approach between two parts, including a part inside the other's
+ /// cutout; is the middle of that gap. Only edges within
+ /// of the other part's bounds can be closer than it, so the rest are
+ /// skipped. Infinity when no edges are that near.
+ 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 ringA, List 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 Near(List 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();
+
+ /// A part's native contours in world space, flattened at .
+ /// Circumscribed, so the measured gap never exceeds the true one by more than rounding.
+ private sealed class FineRings
+ {
+ public List Perimeter { get; private init; }
+ public List> 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;
+}
diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
index ea7b7662..1880b54a 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
@@ -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);
diff --git a/OpenNest.Core/Diagnostics/PostVerificationReport.cs b/OpenNest.Core/Diagnostics/PostVerificationReport.cs
index 0aef6e3c..9556697d 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationReport.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationReport.cs
@@ -29,7 +29,12 @@ public enum PostVerificationKind
NestedLead,
/// A lead-in pierce is closer to part material than half the pierce diameter.
- PierceZone
+ PierceZone,
+ ///
+ /// Two parts are closer than the plate's part spacing without sharing material, judged as
+ /// Auto Nest's layout check judges them.
+ ///
+ PartSpacing
}
/// Plate and part numbers are one-based; plate zero denotes a whole-post limitation.
@@ -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}");
diff --git a/OpenNest.Core/Geometry/Clearance.cs b/OpenNest.Core/Geometry/Clearance.cs
index d37eab9a..41249b21 100644
--- a/OpenNest.Core/Geometry/Clearance.cs
+++ b/OpenNest.Core/Geometry/Clearance.cs
@@ -96,10 +96,44 @@ namespace OpenNest.Geometry
/// at the closest contact.
///
private static ClearanceResult Separation(List linesA, List 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);
+ }
+
+ ///
+ /// Minimum boundary (edge-to-edge) distance between two rings and the point on each
+ /// boundary where it occurs. Like , never
+ /// tests overlap. Infinity, with zero points, when either ring has no edges.
+ ///
+ 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 linesA, List 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;
}
///
diff --git a/OpenNest.Core/Geometry/SpacingOutline.cs b/OpenNest.Core/Geometry/SpacingOutline.cs
new file mode 100644
index 00000000..8ee0545c
--- /dev/null
+++ b/OpenNest.Core/Geometry/SpacingOutline.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.Math;
+
+namespace OpenNest.Geometry
+{
+ ///
+ /// 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 , so they cannot disagree.
+ ///
+ public sealed class SpacingOutline
+ {
+ /// Arc chord tolerance of the outline. Perimeter arcs are circumscribed and
+ /// cutout arcs inscribed, so flattening never hides material.
+ public const double ChordTolerance = 0.001;
+
+ /// 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.
+ public const double SpacingSlack = 0.0005;
+
+ private bool prepared;
+ private TriangulatedRegion triangles;
+
+ public SpacingOutline(Polygon perimeter, List holes)
+ {
+ Perimeter = perimeter ?? throw new ArgumentNullException(nameof(perimeter));
+ Holes = holes ?? new List();
+ }
+
+ public Polygon Perimeter { get; }
+ public List Holes { get; }
+
+ /// Prepared on the first overlap test; null preparation is cached too.
+ public TriangulatedRegion Triangles
+ {
+ get
+ {
+ if (!prepared)
+ {
+ triangles = TriangulatedRegion.Build(Perimeter, Holes);
+ prepared = true;
+ }
+ return triangles;
+ }
+ }
+
+ /// Inflation that enforces less .
+ /// 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.
+ public static double InflationFor(double spacing) =>
+ System.Math.Max(0, spacing - SpacingSlack);
+
+ ///
+ /// Outlines (already rotated) at ,
+ /// grown by . 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.
+ ///
+ 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);
+ }
+
+ /// True when the two outlines share material (holes subtracted).
+ 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();
+ }
+ }
+}
diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs
index da5f73a6..c6c28578 100644
--- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs
+++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs
@@ -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.
///
- 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() };
+ return new SpacingOutline(perimeter, new List());
}
///
@@ -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.
///
- 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 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);
/// Inflation that enforces less the shared slack.
- 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 Holes { get; init; }
-
- private bool prepared;
- private TriangulatedRegion triangles;
-
- /// Prepared on the first candidate pair; null preparation is cached too.
- 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);
///
/// 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.
///
- 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);
}
diff --git a/OpenNest.Engine/Jobs/NestTolerances.cs b/OpenNest.Engine/Jobs/NestTolerances.cs
index 9ce222e8..f2f15b17 100644
--- a/OpenNest.Engine/Jobs/NestTolerances.cs
+++ b/OpenNest.Engine/Jobs/NestTolerances.cs
@@ -11,13 +11,11 @@ public static class NestTolerances
{
/// Arc chord tolerance used by both validators. The layout check circumscribes
/// perimeter arcs and inscribes cutouts; the placement validator uses inscribed arcs.
- public const double ValidationOutline = 0.001;
+ public const double ValidationOutline = SpacingOutline.ChordTolerance;
- /// 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.
- public const double SpacingSlack = 0.0005;
+ /// How far under the part spacing a gap may fall and still pass both validators
+ /// and the pre-post spacing review; see .
+ public const double SpacingSlack = SpacingOutline.SpacingSlack;
/// 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
diff --git a/OpenNest.Tests/Diagnostics/PartSpacingReviewTests.cs b/OpenNest.Tests/Diagnostics/PartSpacingReviewTests.cs
new file mode 100644
index 00000000..d5ac2dcc
--- /dev/null
+++ b/OpenNest.Tests/Diagnostics/PartSpacingReviewTests.cs
@@ -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
+ {
+ [a.BaseDrawing] = ("a", 1),
+ [b.BaseDrawing] = ("b", 1),
+ };
+ var layout = NestLayoutCheck.Validate(new() { (nest.Plates[0], new List { 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;
+ }
+}
diff --git a/OpenNest/Forms/CuttingInspectionPanel.cs b/OpenNest/Forms/CuttingInspectionPanel.cs
index e25b5c4c..46a2cc34 100644
--- a/OpenNest/Forms/CuttingInspectionPanel.cs
+++ b/OpenNest/Forms/CuttingInspectionPanel.cs
@@ -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."
};
diff --git a/docs/post-verification.md b/docs/post-verification.md
index 486cbdb1..933e9189 100644
--- a/docs/post-verification.md
+++ b/docs/post-verification.md
@@ -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