mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 22:01:06 -04:00
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:
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) };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user