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.
This commit is contained in:
aj committed 2026-10-15 23:31:14 -04:00
1 parent 8663463945
commit f04d9f298b
10 files changed
+414 -102

No files matched your search

@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>
/// 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.
/// <para>
/// Coordinates are rounded to a 1e-8 model-unit grid, far below the 0.001 chord tolerance
/// and the <see cref="OpenNest.Math.Tolerance.Epsilon"/> area threshold. Each operand is
/// its outline minus the union of its holes (outline positive, holes negative,
/// <see cref="FillRule.Positive"/>).
/// Nonfinite or out-of-range coordinates throw <see cref="ArithmeticException"/>, which the
/// analyzer turns into an incomplete-check issue rather than a clear result.
/// </para>
/// </summary>
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;
/// <summary>One connected island: a counterclockwise outline and clockwise holes, both closed.</summary>
internal sealed record Island(List<Vector> Outline, List<List<Vector>> Holes, PolygonAreaMoments Moments);
internal static List<Island> Intersect(Polygon outerA, IReadOnlyList<Polygon> holesA,
Polygon outerB, IReadOnlyList<Polygon> 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<Island>();
Collect(tree, islands);
return islands;
}
private static void Collect(PolyPath64 node, List<Island> 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<List<Vector>>();
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.
}
}
/// <summary>
/// Net area and centroid of an outline minus its holes. Fails for degenerate,
/// nonfinite or nonpositive results.
/// </summary>
private static bool TryMoments(List<Vector> outline, List<List<Vector>> holes, out PolygonAreaMoments moments)
{
moments = default;
if (!PolygonAreaMoments.TryCompute(outline, out var outer))
return false;
var removed = new List<PolygonAreaMoments>(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<Polygon> 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<Vector> ToVectors(Path64 path, bool positive)
{
var points = new List<Vector>(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;
}
}
@@ -140,7 +140,7 @@ public static class PlateOverlapAnalyzer
Analyze(Capture(parts, cancellationToken), cancellationToken);
/// <summary>
/// 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.
/// </summary>
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<PolygonAreaMoments>();
var regions = new List<PlateOverlapRegion>();
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<PolygonAreaMoments>();
var regions = new List<PlateOverlapRegion>();
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;
}
/// <summary>
/// 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 <see cref="OpenNest.Math.Tolerance.Epsilon"/>: contact and rounding
/// dust are not overlap. Invalid moments throw, so the pair becomes incomplete rather
/// than clear or marked at the origin.
/// </summary>
private static bool TryShare(OverlapMaterial localA, Polygon outerB, List<Polygon> holesB, Vector origin,
CancellationToken cancellationToken, out List<PlateOverlapRegion> 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<PlateOverlapRegion>(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;
@@ -80,7 +80,7 @@ public sealed class PlateOverlapPair
public int PartBId { get; }
public string PartAName { get; }
public string PartBName { get; }
/// <summary>Convex fragments, not connected islands; no mutable kernel polygons are exposed.</summary>
/// <summary>Connected islands of shared material; no mutable kernel polygons are exposed.</summary>
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
public double Area { get; }
/// <summary>
@@ -92,17 +92,34 @@ public sealed class PlateOverlapPair
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
}
/// <summary>A positive-area, hole-subtracted convex polygon in world coordinates.</summary>
/// <summary>
/// One connected island of shared material in world coordinates, after hole subtraction:
/// a counterclockwise outline and its clockwise holes.
/// </summary>
public sealed class PlateOverlapRegion
{
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area)
private static readonly IReadOnlyList<IReadOnlyList<Vector>> NoHoles = System.Array.AsReadOnly(
System.Array.Empty<IReadOnlyList<Vector>>());
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area,
IEnumerable<IEnumerable<Vector>> holes = null)
{
Vertices = System.Array.AsReadOnly(vertices.ToArray());
Area = area;
Holes = holes == null
? NoHoles
: System.Array.AsReadOnly(holes
.Select(hole => (IReadOnlyList<Vector>)System.Array.AsReadOnly(hole.ToArray()))
.ToArray());
}
/// <summary>Read-only vertices with an exactly repeated closing vertex.</summary>
/// <summary>Read-only counterclockwise outline with an exactly repeated closing vertex.</summary>
public IReadOnlyList<Vector> Vertices { get; }
/// <summary>Read-only clockwise hole rings, each with an exactly repeated closing vertex.</summary>
public IReadOnlyList<IReadOnlyList<Vector>> Holes { get; }
/// <summary>Positive net area: the outline's area minus its holes'.</summary>
public double Area { get; }
}
@@ -97,5 +97,35 @@ public readonly struct PolygonAreaMoments
return true;
}
/// <summary>
/// Net moments of an outline minus nonoverlapping holes that lie inside it, all given as
/// positive moments (for example from <see cref="TryCompute"/>). Moments are taken about
/// the outline's centroid. Fails for an invalid input or a nonpositive or nonfinite result.
/// </summary>
public static bool TrySubtract(PolygonAreaMoments outline, IEnumerable<PolygonAreaMoments> 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);
}
@@ -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)
@@ -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<NotSupportedException>(() => ((IList<IReadOnlyList<Vector>>)region.Holes).Clear());
Assert.Throws<NotSupportedException>(() => ((IList<Vector>)hole)[0] = new Vector(9, 9));
}
private static double SignedArea(IReadOnlyList<Vector> 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)]
@@ -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<PolygonAreaMoments>(), 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) };
+27 -15
View File
@@ -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
}
}
/// <summary>
/// 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.
/// </summary>
private PointF[] GraphRing(IReadOnlyList<Vector> 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;
+6
View File
@@ -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));
+53 -40
View File
@@ -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.