diff --git a/OpenNest.Core/Diagnostics/OverlapClipping.cs b/OpenNest.Core/Diagnostics/OverlapClipping.cs
new file mode 100644
index 00000000..acc10f2f
--- /dev/null
+++ b/OpenNest.Core/Diagnostics/OverlapClipping.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using Clipper2Lib;
+using OpenNest.Geometry;
+
+namespace OpenNest.Diagnostics;
+
+///
+/// Shared material of two hole-aware outlines as connected islands, through Clipper2.
+/// Callers pass part-local coordinates (rebased near the origin) and add the origin back.
+///
+/// Coordinates are rounded to a 1e-8 model-unit grid, far below the 0.001 chord tolerance
+/// and the area threshold. Each operand is
+/// its outline minus the union of its holes (outline positive, holes negative,
+/// ).
+/// Nonfinite or out-of-range coordinates throw , which the
+/// analyzer turns into an incomplete-check issue rather than a clear result.
+///
+///
+internal static class OverlapClipping
+{
+ private const double Scale = 1e8;
+
+ // Scaled coordinates stay several orders of magnitude inside Clipper2's int64 range.
+ private const double MaxCoordinate = 1e9;
+
+ /// One connected island: a counterclockwise outline and clockwise holes, both closed.
+ internal sealed record Island(List Outline, List> Holes, PolygonAreaMoments Moments);
+
+ internal static List Intersect(Polygon outerA, IReadOnlyList holesA,
+ Polygon outerB, IReadOnlyList holesB)
+ {
+ var clipper = new Clipper64();
+ clipper.AddSubject(Region(outerA, holesA));
+ clipper.AddClip(Region(outerB, holesB));
+ var tree = new PolyTree64();
+ if (!clipper.Execute(ClipType.Intersection, FillRule.Positive, tree))
+ throw new InvalidOperationException("Clipper2 could not intersect the overlap material.");
+ var islands = new List();
+ Collect(tree, islands);
+ return islands;
+ }
+
+ private static void Collect(PolyPath64 node, List islands)
+ {
+ for (var i = 0; i < node.Count; i++)
+ {
+ var outer = (PolyPath64)node[i];
+ var outline = ToVectors(outer.Polygon, positive: true);
+ var holes = new List>();
+ for (var h = 0; h < outer.Count; h++)
+ {
+ var hole = (PolyPath64)outer[h];
+ holes.Add(ToVectors(hole.Polygon, positive: false));
+ // An island can sit inside a hole formed by both operands' holes together.
+ Collect(hole, islands);
+ }
+ if (TryMoments(outline, holes, out var moments))
+ islands.Add(new Island(outline, holes, moments));
+ else if (Clipper.Area(outer.Polygon) / (Scale * Scale) >= OpenNest.Math.Tolerance.Epsilon)
+ throw new ArithmeticException("Overlap island area moments are invalid.");
+ // Otherwise a degenerate dust island below the reporting threshold is skipped.
+ }
+ }
+
+ ///
+ /// Net area and centroid of an outline minus its holes. Fails for degenerate,
+ /// nonfinite or nonpositive results.
+ ///
+ private static bool TryMoments(List outline, List> holes, out PolygonAreaMoments moments)
+ {
+ moments = default;
+ if (!PolygonAreaMoments.TryCompute(outline, out var outer))
+ return false;
+ var removed = new List(holes.Count);
+ foreach (var hole in holes)
+ {
+ if (!PolygonAreaMoments.TryCompute(hole, out var hollow))
+ return false;
+ removed.Add(hollow);
+ }
+ return PolygonAreaMoments.TrySubtract(outer, removed, out moments);
+ }
+
+ private static Paths64 Region(Polygon outer, IReadOnlyList holes)
+ {
+ var region = new Paths64 { ToPath(outer, positive: true) };
+ if (holes != null)
+ foreach (var hole in holes)
+ region.Add(ToPath(hole, positive: false));
+ return region;
+ }
+
+ private static Path64 ToPath(Polygon polygon, bool positive)
+ {
+ var vertices = polygon.Vertices;
+ var count = vertices.Count;
+ if (count > 1 && vertices[count - 1].X == vertices[0].X && vertices[count - 1].Y == vertices[0].Y)
+ count--;
+ var path = new Path64(count);
+ for (var i = 0; i < count; i++)
+ {
+ var point = vertices[i];
+ if (!double.IsFinite(point.X) || !double.IsFinite(point.Y)
+ || System.Math.Abs(point.X) > MaxCoordinate || System.Math.Abs(point.Y) > MaxCoordinate)
+ throw new ArithmeticException("Overlap material is outside the exact clipping range.");
+ path.Add(new Point64(System.Math.Round(point.X * Scale), System.Math.Round(point.Y * Scale)));
+ }
+ if (Clipper.IsPositive(path) != positive)
+ path.Reverse();
+ return path;
+ }
+
+ private static List ToVectors(Path64 path, bool positive)
+ {
+ var points = new List(path.Count + 1);
+ foreach (var point in path)
+ points.Add(new Vector(point.X / Scale, point.Y / Scale));
+ if (Clipper.IsPositive(path) != positive)
+ points.Reverse();
+ points.Add(points[0]); // Regions repeat their closing vertex exactly.
+ return points;
+ }
+}
diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
index 72c8af06..43487a64 100644
--- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
@@ -140,7 +140,7 @@ public static class PlateOverlapAnalyzer
Analyze(Capture(parts, cancellationToken), cancellationToken);
///
- /// Returns deterministic pair reports containing closed world-coordinate overlap fragments.
+ /// Returns deterministic pair reports containing closed world-coordinate overlap islands.
/// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear.
///
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot,
@@ -205,32 +205,13 @@ public static class PlateOverlapAnalyzer
try
{
// Keep pair clipping arithmetic near the parts where possible, then
- // restore output to world space. Triangulation itself also uses stable
- // local-origin winding so tiny holes in a huge part remain correct.
+ // restore output to world space.
var origin = a.Material.Outer.Vertices[0];
var localA = a.Material.Transform(0, origin * -1);
var localB = b.Material.Transform(0, origin * -1);
- var result = Collision.Check(localA.Outer, localB.Outer, localA.Holes, localB.Holes);
- if (!result.Overlaps)
+ if (!TryShare(localA, localB.Outer, localB.Holes, origin, cancellationToken,
+ out var regions, out var centroid))
continue;
- // Evaluate every hole-subtracted fragment before restoring world space.
- // A failed moment must make this pair incomplete, never an origin marker.
- var moments = new List();
- var regions = new List();
- foreach (var region in result.OverlapRegions)
- {
- cancellationToken.ThrowIfCancellationRequested();
- if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment))
- throw new ArithmeticException("Overlap fragment area moments are invalid.");
- moments.Add(fragment);
- regions.Add(new PlateOverlapRegion(region.Vertices.Select(point => point + origin),
- fragment.Area));
- }
- if (!PolygonAreaMoments.TryCombine(moments, out var combined))
- throw new ArithmeticException("Combined overlap area moments are invalid.");
- var centroid = combined.Centroid + origin;
- if (!OverlapMaterial.IsFinite(centroid))
- throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id,
a.Input.Name, b.Input.Name, regions, centroid));
}
@@ -299,25 +280,8 @@ public static class PlateOverlapAnalyzer
var origin = part.Material.Outer.Vertices[0];
var local = part.Material.Transform(0, origin * -1);
var rect = RectanglePolygon(zone, origin * -1);
- var result = Collision.Check(local.Outer, rect, local.Holes, null);
- if (!result.Overlaps)
+ if (!TryShare(local, rect, null, origin, cancellationToken, out var regions, out var centroid))
continue;
- var moments = new List();
- var regions = new List();
- foreach (var region in result.OverlapRegions)
- {
- cancellationToken.ThrowIfCancellationRequested();
- if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment))
- throw new ArithmeticException("Clamp overlap fragment area moments are invalid.");
- moments.Add(fragment);
- regions.Add(new PlateOverlapRegion(
- region.Vertices.Select(point => point + origin), fragment.Area));
- }
- if (!PolygonAreaMoments.TryCombine(moments, out var combined))
- throw new ArithmeticException("Combined clamp overlap area moments are invalid.");
- var centroid = combined.Centroid + origin;
- if (!OverlapMaterial.IsFinite(centroid))
- throw new ArithmeticException("Clamp overlap centroid is not finite in world coordinates.");
findings.Add(new PlateOverlapPair(part.Input.Id,
ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
$"Clamp '{clamp.Id}'", regions, centroid));
@@ -332,6 +296,39 @@ public static class PlateOverlapAnalyzer
return findings;
}
+ ///
+ /// Shared material of a rebased operand with another rebased outline/holes, as world
+ /// regions (connected islands) plus the area centroid of them all. Returns false when the
+ /// total shared area does not exceed : contact and rounding
+ /// dust are not overlap. Invalid moments throw, so the pair becomes incomplete rather
+ /// than clear or marked at the origin.
+ ///
+ private static bool TryShare(OverlapMaterial localA, Polygon outerB, List holesB, Vector origin,
+ CancellationToken cancellationToken, out List regions, out Vector centroid)
+ {
+ regions = null;
+ centroid = default;
+ var islands = OverlapClipping.Intersect(localA.Outer, localA.Holes, outerB, holesB);
+ var area = islands.Sum(island => island.Moments.Area);
+ if (!double.IsFinite(area))
+ throw new ArithmeticException("Overlap area moments are invalid.");
+ if (area <= OpenNest.Math.Tolerance.Epsilon)
+ return false;
+ regions = new List(islands.Count);
+ foreach (var island in islands)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ regions.Add(new PlateOverlapRegion(island.Outline.Select(point => point + origin), island.Moments.Area,
+ island.Holes.Select(hole => hole.Select(point => point + origin))));
+ }
+ if (!PolygonAreaMoments.TryCombine(islands.Select(island => island.Moments), out var combined))
+ throw new ArithmeticException("Combined overlap area moments are invalid.");
+ centroid = combined.Centroid + origin;
+ if (!OverlapMaterial.IsFinite(centroid))
+ throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
+ return true;
+ }
+
private static PlateOverlapPair TinyClampFinding(PreparedPart part, CapturedClamp clamp)
{
var zone = clamp.Bounds;
diff --git a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs
index a5fd5b47..136c0764 100644
--- a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs
+++ b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs
@@ -80,7 +80,7 @@ public sealed class PlateOverlapPair
public int PartBId { get; }
public string PartAName { get; }
public string PartBName { get; }
- /// Convex fragments, not connected islands; no mutable kernel polygons are exposed.
+ /// Connected islands of shared material; no mutable kernel polygons are exposed.
public IReadOnlyList Regions { get; }
public double Area { get; }
///
@@ -92,17 +92,34 @@ public sealed class PlateOverlapPair
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
}
-/// A positive-area, hole-subtracted convex polygon in world coordinates.
+///
+/// One connected island of shared material in world coordinates, after hole subtraction:
+/// a counterclockwise outline and its clockwise holes.
+///
public sealed class PlateOverlapRegion
{
- internal PlateOverlapRegion(IEnumerable vertices, double area)
+ private static readonly IReadOnlyList> NoHoles = System.Array.AsReadOnly(
+ System.Array.Empty>());
+
+ internal PlateOverlapRegion(IEnumerable vertices, double area,
+ IEnumerable> holes = null)
{
Vertices = System.Array.AsReadOnly(vertices.ToArray());
Area = area;
+ Holes = holes == null
+ ? NoHoles
+ : System.Array.AsReadOnly(holes
+ .Select(hole => (IReadOnlyList)System.Array.AsReadOnly(hole.ToArray()))
+ .ToArray());
}
- /// Read-only vertices with an exactly repeated closing vertex.
+ /// Read-only counterclockwise outline with an exactly repeated closing vertex.
public IReadOnlyList Vertices { get; }
+
+ /// Read-only clockwise hole rings, each with an exactly repeated closing vertex.
+ public IReadOnlyList> Holes { get; }
+
+ /// Positive net area: the outline's area minus its holes'.
public double Area { get; }
}
diff --git a/OpenNest.Core/Geometry/PolygonAreaMoments.cs b/OpenNest.Core/Geometry/PolygonAreaMoments.cs
index 4bb78daa..698ea023 100644
--- a/OpenNest.Core/Geometry/PolygonAreaMoments.cs
+++ b/OpenNest.Core/Geometry/PolygonAreaMoments.cs
@@ -97,5 +97,35 @@ public readonly struct PolygonAreaMoments
return true;
}
+ ///
+ /// Net moments of an outline minus nonoverlapping holes that lie inside it, all given as
+ /// positive moments (for example from ). Moments are taken about
+ /// the outline's centroid. Fails for an invalid input or a nonpositive or nonfinite result.
+ ///
+ public static bool TrySubtract(PolygonAreaMoments outline, IEnumerable holes,
+ out PolygonAreaMoments moments)
+ {
+ moments = default;
+ if (holes == null || !double.IsFinite(outline.Area) || outline.Area <= 0 || !IsFinite(outline.Centroid))
+ return false;
+ var area = outline.Area;
+ var firstMoment = Vector.Zero;
+ foreach (var hole in holes)
+ {
+ if (!double.IsFinite(hole.Area) || hole.Area <= 0 || !IsFinite(hole.Centroid))
+ return false;
+ firstMoment -= (hole.Centroid - outline.Centroid) * hole.Area;
+ area -= hole.Area;
+ }
+
+ if (!double.IsFinite(area) || area <= 0 || !IsFinite(firstMoment))
+ return false;
+ var centroid = outline.Centroid + firstMoment / area;
+ if (!IsFinite(centroid))
+ return false;
+ moments = new PolygonAreaMoments(area, centroid);
+ return true;
+ }
+
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
}
diff --git a/OpenNest.Tests/Diagnostics/OverlapPairPresentationTests.cs b/OpenNest.Tests/Diagnostics/OverlapPairPresentationTests.cs
index 1d2c3292..f0bb185a 100644
--- a/OpenNest.Tests/Diagnostics/OverlapPairPresentationTests.cs
+++ b/OpenNest.Tests/Diagnostics/OverlapPairPresentationTests.cs
@@ -62,7 +62,7 @@ public class OverlapPairPresentationTests
var pairs = PlateOverlapAnalyzer.Analyze(new[] { Rectangle("a"), Rectangle("b"), Rectangle("c") }).Pairs;
var hits = OverlapPairPresentation.HitTest(pairs.Reverse(), p => p, new Vector(2, 2), 96);
Assert.Equal(new[] { "1/2", "1/3", "2/3" }, hits.Select(OverlapPairPresentation.Label));
- Assert.All(hits, p => Assert.True(p.Regions.Count > 1));
+ Assert.All(hits, p => Assert.Single(p.Regions)); // One island per coincident pair.
}
private static Part Rectangle(string name)
diff --git a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs
index 390b02bf..ceca6dc0 100644
--- a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs
+++ b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs
@@ -487,7 +487,8 @@ public class PlateOverlapAnalyzerTests
Assert.False(report.IsComplete);
var issue = Assert.Single(report.Issues);
Assert.Equal((0, (int?)1), (issue.PartAId, issue.PartBId));
- Assert.Contains("moment", issue.Message, StringComparison.OrdinalIgnoreCase);
+ // The exact clipping range refuses the pair before any moment could overflow.
+ Assert.Contains("range", issue.Message, StringComparison.OrdinalIgnoreCase);
var valid = Assert.Single(report.Pairs);
Assert.Equal((2, 3), (valid.PartAId, valid.PartBId));
AssertCentroid(valid, -1.5, -1.5);
@@ -501,6 +502,88 @@ public class PlateOverlapAnalyzerTests
Assert.Equal(y, pair.Centroid.Y, 7);
}
+ // Regions are connected islands of shared material, not triangulation fragments: a
+ // C-shaped overlap (frame half beside its hole) is one region with the C's area.
+ [Fact]
+ public void Analyze_ConcaveOverlapIsOneConnectedRegion()
+ {
+ var frame = WithContours(Square(0, 0, 4), Square(1, 1, 2));
+
+ var report = PlateOverlapAnalyzer.Analyze(new[] { frame, Rectangle(2, -1, 4, 6) });
+
+ Assert.True(report.IsComplete, string.Join("; ", report.Issues));
+ var pair = Assert.Single(report.Pairs);
+ var region = Assert.Single(pair.Regions);
+ Assert.Equal(6, pair.Area, 8);
+ Assert.Equal(6, region.Area, 8);
+ Assert.Equal(region.Vertices[0], region.Vertices[^1]);
+ }
+
+ // A part covering a whole frame shares one island whose hole is the frame's hole.
+ [Fact]
+ public void Analyze_RegionKeepsTheHoleOfSharedMaterial()
+ {
+ var frame = WithContours(Square(0, 0, 4), Square(1, 1, 2));
+
+ var report = PlateOverlapAnalyzer.Analyze(new[] { Rectangle(-1, -1, 6, 6), frame });
+
+ Assert.True(report.IsComplete, string.Join("; ", report.Issues));
+ var pair = Assert.Single(report.Pairs);
+ var region = Assert.Single(pair.Regions);
+ Assert.Equal(12, pair.Area, 8);
+ AssertCentroid(pair, 2, 2);
+ var hole = Assert.Single(region.Holes);
+ Assert.Equal(hole[0], hole[^1]);
+ Assert.Equal(16, SignedArea(region.Vertices), 8); // Outline counterclockwise.
+ Assert.Equal(-4, SignedArea(hole), 8); // Hole clockwise.
+ Assert.All(hole, point =>
+ {
+ Assert.InRange(point.X, 1, 3);
+ Assert.InRange(point.Y, 1, 3);
+ });
+ Assert.Throws(() => ((IList>)region.Holes).Clear());
+ Assert.Throws(() => ((IList)hole)[0] = new Vector(9, 9));
+ }
+
+ private static double SignedArea(IReadOnlyList ring)
+ {
+ var twice = 0.0;
+ for (var i = 0; i + 1 < ring.Count; i++)
+ twice += ring[i].X * ring[i + 1].Y - ring[i + 1].X * ring[i].Y;
+ return twice / 2;
+ }
+
+ // A's square hole closes the mouth of B's C-shaped hole, so the material enclosed by
+ // both holes is a separate island inside the shared hole and must not be dropped.
+ [Fact]
+ public void Analyze_IslandEnclosedByBothPartsHolesIsKept()
+ {
+ var a = WithContours(Square(0, 0, 10), new[]
+ {
+ new Vector(6.5, 3.5), new Vector(7.5, 3.5), new Vector(7.5, 6.5), new Vector(6.5, 6.5)
+ });
+ var b = WithContours(Square(0, 0, 10), new[]
+ {
+ new Vector(3, 3), new Vector(7, 3), new Vector(7, 4), new Vector(4, 4),
+ new Vector(4, 6), new Vector(7, 6), new Vector(7, 7), new Vector(3, 7)
+ });
+
+ var report = PlateOverlapAnalyzer.Analyze(new[] { a, b });
+
+ Assert.True(report.IsComplete, string.Join("; ", report.Issues));
+ var pair = Assert.Single(report.Pairs);
+ Assert.Equal(87.5, pair.Area, 8); // 100 - |H1 u H2| = 100 - (3 + 10 - 0.5).
+ Assert.Equal(new[] { 5.0, 82.5 }, pair.Regions.Select(r => System.Math.Round(r.Area, 8)).Order());
+ var island = pair.Regions.Single(r => r.Area < 10);
+ Assert.Empty(island.Holes);
+ Assert.All(island.Vertices, point =>
+ {
+ Assert.InRange(point.X, 4, 6.5);
+ Assert.InRange(point.Y, 4, 6);
+ });
+ Assert.Single(pair.Regions.Single(r => r.Area > 10).Holes);
+ }
+
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
diff --git a/OpenNest.Tests/Geometry/PolygonAreaMomentsTests.cs b/OpenNest.Tests/Geometry/PolygonAreaMomentsTests.cs
index 5b59de61..16b62623 100644
--- a/OpenNest.Tests/Geometry/PolygonAreaMomentsTests.cs
+++ b/OpenNest.Tests/Geometry/PolygonAreaMomentsTests.cs
@@ -135,6 +135,36 @@ public class PolygonAreaMomentsTests
Assert.False(PolygonAreaMoments.TryCombine(new[] { default(PolygonAreaMoments), valid }, out _));
}
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1000000000)]
+ [InlineData(-1000000000)]
+ public void TrySubtract_OffCentreHoleShiftsCentroidAway(double offset)
+ {
+ // 4x4 square minus a 1x1 hole centred at (1, 1): area 15, centroid (16*2 - 1)/15 = 2 + 1/15.
+ Assert.True(PolygonAreaMoments.TryCompute(Rectangle(offset, offset, 4, 4), out var outline));
+ Assert.True(PolygonAreaMoments.TryCompute(Rectangle(offset + 0.5, offset + 0.5, 1, 1), out var hole));
+
+ Assert.True(PolygonAreaMoments.TrySubtract(outline, new[] { hole }, out var net));
+
+ Assert.Equal(15, net.Area, 12);
+ Assert.Equal(offset + 2 + 1.0 / 15, net.Centroid.X, 7);
+ Assert.Equal(offset + 2 + 1.0 / 15, net.Centroid.Y, 7);
+ Assert.True(PolygonAreaMoments.TrySubtract(outline, Array.Empty(), out var whole));
+ Assert.Equal((outline.Area, outline.Centroid), (whole.Area, whole.Centroid));
+ }
+
+ [Fact]
+ public void TrySubtract_InvalidOrNonpositiveResultIsRejected()
+ {
+ Assert.True(PolygonAreaMoments.TryCompute(Rectangle(0, 0, 1, 1), out var unit));
+
+ Assert.False(PolygonAreaMoments.TrySubtract(unit, null, out _));
+ Assert.False(PolygonAreaMoments.TrySubtract(default, new[] { unit }, out _));
+ Assert.False(PolygonAreaMoments.TrySubtract(unit, new[] { default(PolygonAreaMoments) }, out _));
+ Assert.False(PolygonAreaMoments.TrySubtract(unit, new[] { unit }, out _));
+ }
+
private static Vector[] Rectangle(double x, double y, double width, double height) =>
new[] { new Vector(x, y), new Vector(x + width, y),
new Vector(x + width, y + height), new Vector(x, y + height) };
diff --git a/OpenNest/Controls/OverlapOverlayController.cs b/OpenNest/Controls/OverlapOverlayController.cs
index d7e01be1..d46c7761 100644
--- a/OpenNest/Controls/OverlapOverlayController.cs
+++ b/OpenNest/Controls/OverlapOverlayController.cs
@@ -571,21 +571,11 @@ internal sealed class OverlapOverlayController : IDisposable
{
foreach (var region in pair.Regions)
{
- var vertices = region.Vertices;
- var points = new PointF[vertices.Count - 1]; // Analyzer repeats the closing vertex.
- var signedArea = 0.0;
- var start = vertices[0];
- for (var i = 0; i < points.Length; i++)
- {
- var a = vertices[i] - start;
- var b = vertices[i + 1] - start;
- signedArea += a.X * b.Y - b.X * a.Y;
- // The host has already translated by origin: graph, not control coordinates.
- points[i] = view.PointWorldToGraph(vertices[i]);
- }
- if (signedArea < 0)
- Array.Reverse(points);
- next.AddPolygon(points);
+ // Outlines wind one way and holes the other, so the winding fill leaves
+ // holes empty while overlapping pairs' islands still merge.
+ next.AddPolygon(GraphRing(region.Vertices, positive: true));
+ foreach (var hole in region.Holes)
+ next.AddPolygon(GraphRing(hole, positive: false));
}
}
path = next;
@@ -599,6 +589,28 @@ internal sealed class OverlapOverlayController : IDisposable
}
}
+ ///
+ /// Graph points of a closed world ring (the analyzer repeats the closing vertex), wound
+ /// positive for outlines or negative for holes in world space whatever the input order.
+ ///
+ private PointF[] GraphRing(IReadOnlyList vertices, bool positive)
+ {
+ var points = new PointF[vertices.Count - 1];
+ var signedArea = 0.0;
+ var start = vertices[0];
+ for (var i = 0; i < points.Length; i++)
+ {
+ var a = vertices[i] - start;
+ var b = vertices[i + 1] - start;
+ signedArea += a.X * b.Y - b.X * a.Y;
+ // The host has already translated by origin: graph, not control coordinates.
+ points[i] = view.PointWorldToGraph(vertices[i]);
+ }
+ if (signedArea < 0 == positive)
+ Array.Reverse(points);
+ return points;
+ }
+
private void DrawCutOffClampContacts(Graphics graphics)
{
var dpi = view.DeviceDpi / 96f;
diff --git a/OpenNest/Controls/ValidationOverlay.cs b/OpenNest/Controls/ValidationOverlay.cs
index 169902c6..c48bb3f6 100644
--- a/OpenNest/Controls/ValidationOverlay.cs
+++ b/OpenNest/Controls/ValidationOverlay.cs
@@ -71,7 +71,13 @@ internal sealed class ValidationOverlay : IDisposable
areas?.Dispose();
areas = new GraphicsPath(FillMode.Winding);
foreach (var region in overlap.Regions)
+ {
+ // The analyzer winds outlines counterclockwise and holes clockwise,
+ // so the winding fill leaves each island's holes empty.
areas.AddPolygon(region.Vertices.Select(view.PointWorldToGraph).ToArray());
+ foreach (var hole in region.Holes)
+ areas.AddPolygon(hole.Select(view.PointWorldToGraph).ToArray());
+ }
scale = view.ViewScale;
}
using var brush = new SolidBrush(Color.FromArgb(100, 255, 0, 80));
diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md
index 4e40e1e4..233408c2 100644
--- a/docs/geometry/visual-overlap-check.md
+++ b/docs/geometry/visual-overlap-check.md
@@ -57,11 +57,10 @@ material), even if the dialog is later canceled. There is no check during export
## Analysis API
`OpenNest.Diagnostics.PlateOverlapAnalyzer` in OpenNest.Core checks a group of placed
-parts and returns the shared polygon areas for each overlapping pair. Existing
-`Part.Intersects`, `PartOverlapChecker`, `Plate.HasOverlappingParts`, engine
-validators, and CLI entry points are unchanged. A separate shared-triangulator fix
-uses translation-stable winding, correcting missed clockwise outlines/holes far
-from the origin without changing contact or fragment-area tolerance policies.
+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).
## Synchronous use
@@ -77,8 +76,9 @@ foreach (var pair in report.Pairs)
Console.WriteLine($"{pair.PartAId} / {pair.PartBId}: {pair.Area}, centroid {pair.Centroid}");
foreach (var region in pair.Regions)
{
- // region.Vertices: closed, read-only world-coordinate polygon
- // region.Area: positive shared material area, in model units squared
+ // region.Vertices: closed, read-only, counterclockwise world-coordinate outline
+ // region.Holes: closed, read-only, clockwise hole rings inside that outline
+ // region.Area: positive net shared material area, in model units squared
}
}
@@ -116,17 +116,20 @@ conversion cost; callers must not mutate inputs while capture runs.
Analysis clones captured entities before chaining, prepares polygons once per
source, then prepares each pose. An X-sorted bounds sweep prunes separated pairs.
-It invokes the existing hole-aware `Collision.Check` once per candidate pair,
-without an earlier boolean collision pass. Pairs are rebased near the origin for
-clipping/triangulation and restored to world coordinates; area calculation uses
-translated-origin products to avoid cancellation far from the origin. Snapshots
-can be reused and analyzed concurrently. Callers own freshness checks and must
-not publish results after geometry changes or after a newer request supersedes them.
+Each candidate pair is intersected once with Clipper2: each operand is its outline
+minus the union of its holes (outline positive, holes negative, positive fill rule).
+Pairs are rebased near the origin and rounded to a 1e-8 model-unit grid for
+clipping, then restored to world coordinates. A coordinate more than 1e9 units from
+the pair's origin, or a nonfinite one, makes that pair incomplete rather than clear.
+Area calculation uses translated-origin products to avoid cancellation far from the
+origin. Snapshots can be reused and analyzed concurrently. Callers own freshness
+checks and must not publish results after geometry changes or after a newer request
+supersedes them.
Cancellation throws `OperationCanceledException`; it never returns a partial
all-clear. Checks occur between source/pose preparation, validation loops, and
candidate pairs, and before return. An individual conversion, polygonization,
-triangulation, or `Collision.Check` call is not internally interruptible.
+or Clipper2 intersection is not internally interruptible.
## Material contract
@@ -171,13 +174,14 @@ quadrant change moves the zones); posting runs its own fresh check with per-atte
## Interpretation and limits
-Regions are the kernel's convex, hole-subtracted fragments, not merged connected
-islands. Fill the fragments for a visual overlay; do not outline triangulation
-seams as physical boundaries. Areas within one pair may be summed. Areas across
+Regions are connected islands of shared material. Each has a counterclockwise
+outline and clockwise `Holes`; an island can also sit inside a hole that both parts'
+holes enclose together. Fill outlines and holes in one nonzero-winding path so holes
+stay empty. Areas within one pair may be summed. Areas across
pairs are not a union: three coincident parts produce three overlapping pairs,
so summing all pair areas double-counts shared plate locations.
-`pair.Centroid` is the true area-weighted center of every shared-material fragment
+`pair.Centroid` is the true area-weighted center of every shared-material island
for that pair, after hole subtraction. It is not an average of crossings or
vertices. For a disconnected or concave overlap, the mathematical centroid can
lie outside the red material (for example, between two separate patches). This
@@ -185,9 +189,10 @@ is intentional: shaded Areas are authoritative for actual overlap locations;
use Both for detailed inspection rather than interpreting a centroid as an
interior collision point. There is one centroid per pair, not per connected island.
-`PolygonAreaMoments` evaluates signed moments about local origins and combines
-fragments with positive area weights independent of winding. The analyzer does
-this on rebased clipping fragments before adding the world origin back. Invalid,
+`PolygonAreaMoments` evaluates signed moments about local origins. The analyzer
+subtracts each island's hole moments from its outline's (`TrySubtract`), then
+combines the islands with positive area weights (`TryCombine`), on rebased
+coordinates before adding the world origin back. Invalid,
degenerate, or nonfinite moments produce an incomplete pair issue, not a marker
at zero. Curved-outline centroids inherit the polygonization approximation.
@@ -198,11 +203,12 @@ repairs, export blocks, or machining-validity guarantees.
Arc/circle flattening uses a chord tolerance of `0.001` model units (also exposed
as `report.ChordTolerance`). Curved overlaps and topology are therefore polygonal
-approximations. The unchanged collision kernel applies dimensional bounds and
-fragment-area thresholds using `Tolerance.Epsilon` (`0.00001`); sufficiently small
-slivers are below its reporting policy. Floating-point coordinates still have
-finite resolution. Contact and fragment thresholds are unchanged; only the shared
-triangulator's winding arithmetic was stabilized in the prerequisite fix.
+approximations. A pair is reported when its total shared area exceeds
+`Tolerance.Epsilon` (`0.00001` square units). Contact and rounding dust below that
+are not overlap. The engine's `Collision` kernel instead drops each convex fragment
+at or below that area, so a sliver it splits into small fragments can read clear
+there while this diagnostic reports it. Floating-point coordinates still have finite
+resolution.
## Desktop lifecycle and rendering
@@ -247,21 +253,28 @@ whose ordered input slots changed; this display-only path does not renumber them
Explicit geometry invalidation, plate switch, handle loss, cancellation and
failure clear all retained highlights.
-Measured on 501 real PEP-converted plates with 2 to 384 parts, a from-scratch check
-takes median 1 ms, p99 368 ms and max 6.5 s (a 299-part plate); an incremental
-recheck after moving one part takes median 0.1 ms, p99 20 ms and max 35 ms. A
-comparison against uncached full analysis over 2505 edits (nudge, drag onto
+Measured on 389 real PEP-converted plates (252 nests), a from-scratch check takes
+median 0.8 ms, p99 97 ms and max 3.2 s. Nearly all of the slow cases are first-time
+material preparation for a hole-heavy drawing. With material prepared, clipping every
+candidate pair takes median 0.05 ms, p99 27 ms and max 52 ms. The triangle-pair kernel
+took up to 2.6 s on the same plates. On those plates Clipper2 and the previous kernel
+found identical pair, clamp and issue sets. Areas differed by at most 0.0002 square
+units, always smaller from the kernel, consistent with its dropping fragments below
+its area threshold. With the previous kernel, an
+incremental recheck after moving one part took median 0.1 ms, p99 20 ms and max
+35 ms. A comparison against uncached full analysis over 2505 edits (nudge, drag onto
another part, move three, delete first, swap order) matched exactly. It checks freshness before
publication and painting. Handle destruction/disposal cancels work and releases
paths; old completions cannot replace a newer report. Snapshot conversion and
clipping never run in paint or mouse-move handlers.
PlateView draws the controller overlay after work-area/debug-remnant drawing and
-before action paint subscribers and hover tooltips. One consistently wound path
-is filled once, avoiding fragment outlines, internal triangulation seams, and
-darker triple coverage. World-to-graph conversion excludes pan, because PlateView
-already applies origin translation. Paths are rebuilt for displayed-pair/scale changes,
-not ordinary repaints or panning. The state label saves/restores graphics state.
+before action paint subscribers and hover tooltips. Island outlines and reversed
+holes go into one consistently wound path that is filled once, so holes stay empty
+and triple coverage does not darken. World-to-graph conversion excludes pan,
+because PlateView already applies origin translation. Paths are rebuilt for
+displayed-pair/scale changes, not ordinary repaints or panning. The state label
+saves/restores graphics state.
Centroid hit tests use only cached report coordinates and DPI-scaled screen
radii. Hover clears on edits, mode/request/view changes, leave, and teardown.
Diagnostic details draw above action adorners and take precedence over the normal
@@ -269,14 +282,14 @@ part-name tooltip only while visible.
Next hardening: the first check of a plate with a hole-heavy drawing can still
take seconds, and cancellation cannot interrupt the interior of material
-preparation or one kernel operation. Prepared per-part triangulations are not yet
-cached across requests.
+preparation or one Clipper2 intersection.
## Verification
`OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs` exercises analytical
rectangle regions/areas, containment and contact, both operands' holes, concave
-and disconnected intersections, curves, baseline rotation, large translations,
+and disconnected intersections, connected islands with holes (including an island
+enclosed by both parts' holes), curves, baseline rotation, large translations,
deterministic pair ordering against an exhaustive rectangle oracle, invalid
inputs, snapshot isolation, read-only output, cutting-program independence, and
cancellation. Run:
@@ -294,8 +307,8 @@ waits, and the no-retry rule for canceled or failed layouts.
freshness, per-pair display retention through repeated edits and pending checks,
hard invalidation, cancellation, and incomplete-versus-clear messaging.
`PolygonAreaMomentsTests` covers analytic
-centers, unequal/disconnected fragments, winding, closure, large translations,
-and invalid/overflow cases. `OverlapPairPresentationTests` checks adaptive unit
+centers, unequal/disconnected fragments, hole subtraction, winding, closure, large
+translations, and invalid/overflow cases. `OverlapPairPresentationTests` checks adaptive unit
formatting, sequence labels, coincident ordering, and zoom-independent DPI hit
radii. `OverlapHoverPagesTests` proves bounded continuation pages retain every
pair and long/Unicode name, with navigation bounds and explicit tiny-view failure.