From f04d9f298b661656bbc2e51f5362106e66ac29fe Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 14 Oct 2026 17:32:32 -0400 Subject: [PATCH] perf(diagnostics): compute overlap areas with Clipper2 The material-overlap diagnostic clipped every triangle of one part against every triangle of the other, then carved each fragment by every hole triangle. Curved overlaps produced thousands of fragments, and a part sitting inside another part's cutout was slow even when clear. PlateOverlapAnalyzer now intersects each candidate pair's hole-aware material once with Clipper2, in part-local coordinates on a 1e-8 grid. Each operand is its outline minus the union of its holes (positive fill rule). Regions become connected islands: a counterclockwise outline plus clockwise Holes, including an island that both parts' holes enclose together. PolygonAreaMoments.TrySubtract gives each island's net moments. A pair is reported when its total shared area exceeds Tolerance.Epsilon. Out-of-range or nonfinite coordinates make the pair incomplete rather than clear. The overlay and the cutting inspection highlight fill outlines with their holes. On 389 real PEP-converted plates the pair, clamp-finding and issue sets are identical to the previous kernel. Pair areas are equal or up to 0.0002 larger: the kernel drops fragments below its area threshold. With material prepared, the per-plate clipping maximum fell from 2.6 s to 52 ms (total 5.0 s to 0.5 s). Engine and validation checks still use Collision. --- OpenNest.Core/Diagnostics/OverlapClipping.cs | 124 ++++++++++++++++++ .../Diagnostics/PlateOverlapAnalyzer.cs | 79 ++++++----- .../Diagnostics/PlateOverlapReport.cs | 25 +++- OpenNest.Core/Geometry/PolygonAreaMoments.cs | 30 +++++ .../OverlapPairPresentationTests.cs | 2 +- .../Diagnostics/PlateOverlapAnalyzerTests.cs | 85 +++++++++++- .../Geometry/PolygonAreaMomentsTests.cs | 30 +++++ OpenNest/Controls/OverlapOverlayController.cs | 42 +++--- OpenNest/Controls/ValidationOverlay.cs | 6 + docs/geometry/visual-overlap-check.md | 93 +++++++------ 10 files changed, 414 insertions(+), 102 deletions(-) create mode 100644 OpenNest.Core/Diagnostics/OverlapClipping.cs 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.