diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 25971c40..a4a3b9b7 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -789,14 +789,14 @@ static class NestConsole var total = 0; foreach (var plate in plates.Where(p => p.Parts.Count > 0)) { - var hasOverlaps = plate.HasOverlappingParts(out var overlapPts); + var overlapping = PartOverlapChecker.FindOverlappingPairs(plate.Parts); var label = plates.Count > 1 ? $"Plate {nest.Plates.IndexOf(plate)}: " : ""; Console.WriteLine( - hasOverlaps - ? $"{label}OVERLAPS DETECTED: {overlapPts.Count} intersection points" + overlapping.Count > 0 + ? $"{label}OVERLAPS DETECTED: {overlapping.Count} overlapping pair(s)" : $"{label}Overlap check: PASS" ); - total += overlapPts.Count; + total += overlapping.Count; } return total; diff --git a/OpenNest.Core/PartOverlapChecker.cs b/OpenNest.Core/PartOverlapChecker.cs index 45c22ef0..0d721995 100644 --- a/OpenNest.Core/PartOverlapChecker.cs +++ b/OpenNest.Core/PartOverlapChecker.cs @@ -57,6 +57,54 @@ namespace OpenNest ); } + /// + /// Every overlapping pair among , in list order (First precedes + /// Second). Cut-off parts are skipped. Each verdict is , so a part + /// contained in or stacked on another is reported even though their outlines never cross. + /// + public static List<(Part First, Part Second)> FindOverlappingPairs(IEnumerable parts) + { + var pairs = new List<(Part First, Part Second)>(); + var realParts = new List(); + + foreach (var part in parts) + { + if (!part.BaseDrawing.IsCutOff) + realParts.Add(part); + } + + var checker = new PartOverlapChecker(); + + for (var i = 0; i < realParts.Count; i++) + { + var part1 = realParts[i]; + var b1 = part1.BoundingBox; + + for (var j = i + 1; j < realParts.Count; j++) + { + var part2 = realParts[j]; + var b2 = part2.BoundingBox; + + // Skip pairs whose bounding boxes don't meaningfully overlap. + // Floating-point rounding can produce sub-epsilon overlaps for + // parts that are merely edge-touching, so require the overlap + // region to exceed Epsilon in both dimensions. + var overlapX = + System.Math.Min(b1.Right, b2.Right) - System.Math.Max(b1.Left, b2.Left); + var overlapY = + System.Math.Min(b1.Top, b2.Top) - System.Math.Max(b1.Bottom, b2.Bottom); + + if (overlapX <= Math.Tolerance.Epsilon || overlapY <= Math.Tolerance.Epsilon) + continue; + + if (checker.Overlaps(part1, part2)) + pairs.Add((part1, part2)); + } + } + + return pairs; + } + private PreparedProgram Prepare(CNC.Program program) { if (!programs.TryGetValue(program, out var prepared)) diff --git a/OpenNest.Mcp/Tools/InspectionTools.cs b/OpenNest.Mcp/Tools/InspectionTools.cs index 8ece2e25..ccd122e9 100644 --- a/OpenNest.Mcp/Tools/InspectionTools.cs +++ b/OpenNest.Mcp/Tools/InspectionTools.cs @@ -109,7 +109,7 @@ namespace OpenNest.Mcp.Tools } [McpServerTool(Name = "check_overlaps")] - [Description("Check a plate for overlapping parts. Reports collision points if any.")] + [Description("Check a plate for overlapping parts. Reports the overlapping pairs by part index if any.")] public string CheckOverlaps([Description("Index of the plate")] int plateIndex) { var plate = _session.GetPlate(plateIndex); @@ -119,24 +119,26 @@ namespace OpenNest.Mcp.Tools if (plate.Parts.Count < 2) return $"Plate {plateIndex}: no overlaps possible (fewer than 2 parts)"; - var hasOverlaps = plate.HasOverlappingParts(out var pts); + var pairs = PartOverlapChecker.FindOverlappingPairs(plate.Parts); - if (!hasOverlaps) + if (pairs.Count == 0) return $"Plate {plateIndex}: no overlapping parts detected"; var sb = new StringBuilder(); - sb.AppendLine($"Plate {plateIndex}: {pts.Count} collision point(s) detected!"); + sb.AppendLine($"Plate {plateIndex}: {pairs.Count} overlapping pair(s) detected!"); - var limit = System.Math.Min(pts.Count, 20); + var limit = System.Math.Min(pairs.Count, 20); for (var i = 0; i < limit; i++) { - var pt = pts[i]; - sb.AppendLine($" Collision at ({pt.X:F2}, {pt.Y:F2})"); + var (first, second) = pairs[i]; + sb.AppendLine( + $" Part {plate.Parts.IndexOf(first)} overlaps part {plate.Parts.IndexOf(second)}" + ); } - if (pts.Count > limit) - sb.AppendLine($" ... and {pts.Count - limit} more collision points"); + if (pairs.Count > limit) + sb.AppendLine($" ... and {pairs.Count - limit} more overlapping pairs"); return sb.ToString(); } diff --git a/OpenNest.Tests/CommonLines/CommonLinePlanningTests.cs b/OpenNest.Tests/CommonLines/CommonLinePlanningTests.cs index 2140fc98..4743696b 100644 --- a/OpenNest.Tests/CommonLines/CommonLinePlanningTests.cs +++ b/OpenNest.Tests/CommonLines/CommonLinePlanningTests.cs @@ -254,7 +254,7 @@ public class CommonLinePlanningTests nest.Drawings.Add(drawing); var utilization = plate.Utilization(); var nested = drawing.Quantity.Nested; - var overlaps = plate.HasOverlappingParts(out _); + var overlaps = PartOverlapChecker.FindOverlappingPairs(plate.Parts).Count; var pairs = PlateOverlapAnalyzer.Analyze(plate.Parts.ToList()).Pairs.Count; var area = plate.Parts.Sum(p => p.BaseDrawing.Area); @@ -267,7 +267,7 @@ public class CommonLinePlanningTests Assert.All(plate.Parts.Skip(1), p => Assert.True(CommonLineCuts.IsSinglePass(p.Program))); Assert.Equal(utilization, plate.Utilization(), 12); Assert.Equal(nested, drawing.Quantity.Nested); - Assert.Equal(overlaps, plate.HasOverlappingParts(out _)); + Assert.Equal(overlaps, PartOverlapChecker.FindOverlappingPairs(plate.Parts).Count); Assert.Equal(pairs, PlateOverlapAnalyzer.Analyze(plate.Parts.ToList()).Pairs.Count); Assert.Equal(area, plate.Parts.Sum(p => p.BaseDrawing.Area), 12); Assert.True(CommonLineGeometry.Check(plate.CommonLines[0]).IsIntact); diff --git a/OpenNest.Tests/CutOffs/CutOffTests.cs b/OpenNest.Tests/CutOffs/CutOffTests.cs index 7bfd5909..80fcac99 100644 --- a/OpenNest.Tests/CutOffs/CutOffTests.cs +++ b/OpenNest.Tests/CutOffs/CutOffTests.cs @@ -44,7 +44,7 @@ public class CutOffTests } [Fact] - public void Plate_HasOverlappingParts_SkipsCutOffParts() + public void FindOverlappingPairs_SkipsCutOffParts() { var pgm = new Program(); pgm.Codes.Add(new RapidMove(new Vector(0, 0))); @@ -60,8 +60,30 @@ public class CutOffTests plate.Parts.Add(new Part(realDrawing)); plate.Parts.Add(new Part(cutoffDrawing)); - var hasOverlap = plate.HasOverlappingParts(out var pts); - Assert.False(hasOverlap); + Assert.Empty(PartOverlapChecker.FindOverlappingPairs(plate.Parts)); + } + + [Fact] + public void FindOverlappingPairs_ReportsTheSameStackedPartsWhenNeitherIsACutOff() + { + // Control for the test above: the same two stacked squares overlap, so only the + // cut-off flag keeps that pair out of the result. + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 10))); + pgm.Codes.Add(new LinearMove(new Vector(0, 10))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + + var plate = new Plate(100, 100); + var first = new Part(new Drawing("real", pgm)); + var second = new Part(new Drawing("also real", pgm)); + plate.Parts.Add(first); + plate.Parts.Add(second); + + var pair = Assert.Single(PartOverlapChecker.FindOverlappingPairs(plate.Parts)); + Assert.Same(first, pair.First); + Assert.Same(second, pair.Second); } [Fact] diff --git a/OpenNest.Tests/Engine/EngineOverlapTests.cs b/OpenNest.Tests/Engine/EngineOverlapTests.cs index 6b2bdad9..77b14ba9 100644 --- a/OpenNest.Tests/Engine/EngineOverlapTests.cs +++ b/OpenNest.Tests/Engine/EngineOverlapTests.cs @@ -62,18 +62,15 @@ public class EngineOverlapTests foreach (var g in rotGroups) _output.WriteLine($" Rotation {g.Key:F1}°: {g.Count()} parts"); - var hasOverlaps = plate.HasOverlappingParts(out var collisionPoints); - _output.WriteLine($"Overlaps: {hasOverlaps} ({collisionPoints.Count} collision pts)"); + var pairs = PartOverlapChecker.FindOverlappingPairs(plate.Parts); + _output.WriteLine($"Overlaps: {pairs.Count > 0} ({pairs.Count} overlapping pair(s))"); - if (hasOverlaps) - { - for (var i = 0; i < System.Math.Min(collisionPoints.Count, 10); i++) - _output.WriteLine($" ({collisionPoints[i].X:F2}, {collisionPoints[i].Y:F2})"); - } + foreach (var (first, second) in pairs.Take(10)) + _output.WriteLine($" part {plate.Parts.IndexOf(first)} overlaps part {plate.Parts.IndexOf(second)}"); - Assert.False( - hasOverlaps, - $"Strategy '{strategy}' produced {collisionPoints.Count} collision point(s) with {plate.Parts.Count} parts" + Assert.True( + pairs.Count == 0, + $"Strategy '{strategy}' produced {pairs.Count} overlapping pair(s) with {plate.Parts.Count} parts" ); } @@ -87,11 +84,11 @@ public class EngineOverlapTests TestHelpers.MakePartAt(10, 0, 10) ); - var hasOverlaps = plate.HasOverlappingParts(out var pts); - _output.WriteLine($"Adjacent squares: overlaps={hasOverlaps}, collision count={pts.Count}"); + var pairs = PartOverlapChecker.FindOverlappingPairs(plate.Parts); + _output.WriteLine($"Adjacent squares: overlapping pairs={pairs.Count}"); - Assert.False( - hasOverlaps, + Assert.True( + pairs.Count == 0, "Adjacent edge-touching parts should not be reported as overlapping" ); } diff --git a/OpenNest.Tests/Engine/EngineRefactorSmokeTests.cs b/OpenNest.Tests/Engine/EngineRefactorSmokeTests.cs index 0db7b8d5..6767e7a1 100644 --- a/OpenNest.Tests/Engine/EngineRefactorSmokeTests.cs +++ b/OpenNest.Tests/Engine/EngineRefactorSmokeTests.cs @@ -72,7 +72,7 @@ public class EngineRefactorSmokeTests Assert.Equal(23, parts.Count); plate.Parts.AddRange(parts); - Assert.False(plate.HasOverlappingParts(out _), "Fill overlaps"); + Assert.Empty(PartOverlapChecker.FindOverlappingPairs(plate.Parts)); var area = plate.WorkArea(); Assert.All(parts, p => Assert.True( p.BoundingBox.Left >= area.Left - 1e-6 && p.BoundingBox.Right <= area.Right + 1e-6 diff --git a/OpenNest.Tests/Fill/FillLinearMaxPartsTests.cs b/OpenNest.Tests/Fill/FillLinearMaxPartsTests.cs index 2cc99f82..07daf9fc 100644 --- a/OpenNest.Tests/Fill/FillLinearMaxPartsTests.cs +++ b/OpenNest.Tests/Fill/FillLinearMaxPartsTests.cs @@ -79,7 +79,7 @@ public class FillLinearMaxPartsTests Assert.Equal(3, parts.Count); var plate = new Plate(width, length) { PartSpacing = Spacing }; plate.Parts.AddRange(parts); - Assert.False(plate.HasOverlappingParts(out _)); + Assert.Empty(PartOverlapChecker.FindOverlappingPairs(plate.Parts)); Assert.All(parts, part => Assert.True(part.BoundingBox.Right <= area.Right && part.BoundingBox.Top <= area.Top)); } diff --git a/OpenNest.Tests/Fill/PairRemnantFillerTests.cs b/OpenNest.Tests/Fill/PairRemnantFillerTests.cs index 56a0653a..dba0698a 100644 --- a/OpenNest.Tests/Fill/PairRemnantFillerTests.cs +++ b/OpenNest.Tests/Fill/PairRemnantFillerTests.cs @@ -59,7 +59,7 @@ public class PairRemnantFillerTests var check = new Plate { Size = new Size(60, 120), PartSpacing = Spacing }; check.Parts.AddRange(withPairs); - Assert.False(check.HasOverlappingParts(out _)); + Assert.Empty(PartOverlapChecker.FindOverlappingPairs(check.Parts)); } [Fact] diff --git a/OpenNest.Tests/Fill/PartOverlapCheckerTests.cs b/OpenNest.Tests/Fill/PartOverlapCheckerTests.cs index fe0065f6..ca066f5d 100644 --- a/OpenNest.Tests/Fill/PartOverlapCheckerTests.cs +++ b/OpenNest.Tests/Fill/PartOverlapCheckerTests.cs @@ -411,6 +411,76 @@ public class PartOverlapCheckerTests return pattern; } + [Fact] + public void FindOverlappingPairs_ReportsAContainedPart() + { + // The small square sits wholly inside the large one: no outlines cross, so there + // are no crossing points to count, but the material overlaps. + var outer = TestHelpers.MakePartAt(0, 0, 10); + var inner = TestHelpers.MakePartAt(4, 4, 2); + + Assert.True(outer.Intersects(inner, out var points)); + Assert.Empty(points); + + var pair = Assert.Single(PartOverlapChecker.FindOverlappingPairs(new[] { outer, inner })); + Assert.Same(outer, pair.First); + Assert.Same(inner, pair.Second); + } + + [Fact] + public void FindOverlappingPairs_IgnoresEdgeTouchingParts() + { + var parts = new[] + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(10, 0, 10), + TestHelpers.MakePartAt(0, 10, 10), + TestHelpers.MakePartAt(10, 10, 10), + }; + + Assert.Empty(PartOverlapChecker.FindOverlappingPairs(parts)); + } + + [Fact] + public void FindOverlappingPairs_ExcludesCutOffParts() + { + var real = TestHelpers.MakePartAt(0, 0, 10); + var other = TestHelpers.MakePartAt(5, 5, 10); + var cutOff = TestHelpers.MakePartAt(2, 2, 10); + cutOff.BaseDrawing.IsCutOff = true; + + var pair = Assert.Single(PartOverlapChecker.FindOverlappingPairs(new[] { real, cutOff, other })); + Assert.Same(real, pair.First); + Assert.Same(other, pair.Second); + } + + [Fact] + public void FindOverlappingPairs_MatchesPartIntersectsOnASmallGrid() + { + // 10-wide squares on a 7 pitch: neighbors (diagonal ones too) overlap, squares two + // steps apart are clear, and one small square is contained in the middle one. + var parts = new List(); + for (var row = 0; row < 3; row++) + for (var column = 0; column < 3; column++) + parts.Add(TestHelpers.MakePartAt(column * 7, row * 7, 10)); + parts.Add(TestHelpers.MakePartAt(11, 11, 2)); + + var expected = new List<(int, int)>(); + for (var i = 0; i < parts.Count; i++) + for (var j = i + 1; j < parts.Count; j++) + if (parts[i].Intersects(parts[j], out _)) + expected.Add((i, j)); + + var actual = PartOverlapChecker.FindOverlappingPairs(parts) + .Select(pair => (parts.IndexOf(pair.First), parts.IndexOf(pair.Second))) + .ToList(); + + Assert.Contains((0, 1), expected); + Assert.DoesNotContain((0, 2), expected); + Assert.Contains((4, 9), expected); + Assert.Equal(expected, actual); + } + private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); private static long[] PolygonBits(Polygon polygon) => new[] diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md index 6a1d7df6..ade1fa86 100644 --- a/docs/geometry/visual-overlap-check.md +++ b/docs/geometry/visual-overlap-check.md @@ -91,9 +91,11 @@ material), even if the dialog is later canceled. There is no check during export `OpenNest.Diagnostics.PlateOverlapAnalyzer` in OpenNest.Core checks a group of placed parts and returns the shared material for each overlapping pair as connected -islands. It clips with Clipper2. Existing `Part.Intersects`, `PartOverlapChecker`, -`Plate.HasOverlappingParts`, engine validators, and CLI entry points are unchanged -and still use the hand-written `Collision` kernel (its GPU port is on hold). +islands. It clips with Clipper2. Existing `Part.Intersects`, `PartOverlapChecker` and +engine validators are unchanged and still use the hand-written `Collision` kernel (its +GPU port is on hold). The console's overlap check and the MCP `check_overlaps` tool list +overlapping pairs through `PartOverlapChecker.FindOverlappingPairs`, which skips cut-off +parts and reports a contained or stacked part even though no outlines cross. ## Synchronous use