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