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