fix(core): report overlapping pairs through PartOverlapChecker

The console overlap check and the MCP check_overlaps tool counted
crossing points, so a part contained in or stacked on another reported
no overlap. Both now list pairs from PartOverlapChecker.FindOverlappingPairs,
which uses the Intersects verdict and skips cut-off parts.

Plate.HasOverlappingParts stays for now: two test files outside this
change still call it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Fable 5.2 committed 2026-11-12 19:10:26 -05:00
1 parent a451cf5ef3
commit e5d4337681
11 files changed
+179 -38

No files matched your search

+4 -4
View File
@@ -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;
+48
View File
@@ -57,6 +57,54 @@ namespace OpenNest
);
}
/// <summary>
/// Every overlapping pair among <paramref name="parts"/>, in list order (First precedes
/// Second). Cut-off parts are skipped. Each verdict is <see cref="Overlaps"/>, so a part
/// contained in or stacked on another is reported even though their outlines never cross.
/// </summary>
public static List<(Part First, Part Second)> FindOverlappingPairs(IEnumerable<Part> parts)
{
var pairs = new List<(Part First, Part Second)>();
var realParts = new List<Part>();
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))
+11 -9
View File
@@ -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();
}
@@ -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);
+25 -3
View File
@@ -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]
+11 -14
View File
@@ -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"
);
}
@@ -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
@@ -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));
}
@@ -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]
@@ -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<Part>();
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[]
+5 -3
View File
@@ -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