From 5dc13a03aed5119a13d0d44b61c2014c5271547a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 19 Oct 2026 00:54:32 -0400 Subject: [PATCH] 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 --- AGENTS.md | 2 +- .../Diagnostics/PartSpacingReview.cs | 230 ++++++++++++++++++ .../Diagnostics/PostVerificationAnalyzer.cs | 2 + .../Diagnostics/PostVerificationReport.cs | 9 +- OpenNest.Core/Geometry/Clearance.cs | 48 +++- OpenNest.Core/Geometry/SpacingOutline.cs | 105 ++++++++ OpenNest.Engine/Jobs/NestLayoutCheck.cs | 91 ++----- OpenNest.Engine/Jobs/NestTolerances.cs | 10 +- .../Diagnostics/PartSpacingReviewTests.cs | 215 ++++++++++++++++ OpenNest/Forms/CuttingInspectionPanel.cs | 2 + docs/post-verification.md | 23 +- 11 files changed, 638 insertions(+), 99 deletions(-) create mode 100644 OpenNest.Core/Diagnostics/PartSpacingReview.cs create mode 100644 OpenNest.Core/Geometry/SpacingOutline.cs create mode 100644 OpenNest.Tests/Diagnostics/PartSpacingReviewTests.cs 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