diff --git a/OpenNest.Core/Diagnostics/OverlapHoverPages.cs b/OpenNest.Core/Diagnostics/OverlapHoverPages.cs
new file mode 100644
index 0000000..392ecab
--- /dev/null
+++ b/OpenNest.Core/Diagnostics/OverlapHoverPages.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+
+namespace OpenNest.Diagnostics;
+
+///
+/// Viewport-sized pages of cached overlap details, including continuation pages for a
+/// single long pair. The caller supplies its actual single-line font measurement.
+/// No names or numeric details are elided, and no geometry is queried here.
+///
+public sealed class OverlapHoverPages
+{
+ private readonly IReadOnlyList[] pages;
+ private readonly IReadOnlyList[] navigation;
+
+ private OverlapHoverPages(IReadOnlyList[] pages, IReadOnlyList[] navigation,
+ bool needsLargerViewport = false)
+ {
+ this.pages = pages;
+ this.navigation = navigation;
+ NeedsLargerViewport = needsLargerViewport;
+ }
+
+ public IReadOnlyList Lines => PageCount == 0 ? Array.Empty() : pages[PageIndex];
+ public IReadOnlyList NavigationLines => PageCount == 0 ? Array.Empty() : navigation[PageIndex];
+ public int PageIndex { get; private set; }
+ public int PageCount => pages.Length;
+ public bool NeedsLargerViewport { get; }
+
+ public void MovePage(int delta) => PageIndex = (int)System.Math.Clamp((long)PageIndex + delta, 0,
+ System.Math.Max(0, PageCount - 1));
+
+ public static OverlapHoverPages Create(string text, int pairCount, int maxRows,
+ double maxWidth, Func measure)
+ {
+ var tooSmall = new OverlapHoverPages([], [], true);
+ if (maxRows < 1 || !double.IsFinite(maxWidth) || maxWidth <= 0)
+ return tooSmall;
+ var lines = Wrap(text, maxWidth, measure);
+ if (lines == null)
+ return tooSmall;
+ if (lines.Count <= maxRows)
+ return new OverlapHoverPages([lines.AsReadOnly()], [Array.Empty()]);
+
+ // Reserve the real, wrapped hint as well as content. Increasing the reserved
+ // rows can increase the page count/digit count, so converge before slicing.
+ for (var hintRows = 2; hintRows < maxRows;)
+ {
+ var contentRows = maxRows - hintRows;
+ var count = (lines.Count - 1) / contentRows + 1;
+ var hints = new IReadOnlyList[count];
+ var requiredHintRows = hintRows;
+ for (var page = 0; page < count; page++)
+ {
+ var hint = Wrap($"Page {page + 1}/{count} · {pairCount} pairs\nPgUp/PgDn", maxWidth, measure);
+ if (hint == null)
+ return tooSmall;
+ hints[page] = hint.AsReadOnly();
+ requiredHintRows = System.Math.Max(requiredHintRows, hint.Count);
+ }
+ if (requiredHintRows > hintRows)
+ {
+ hintRows = requiredHintRows;
+ continue;
+ }
+ var pages = Enumerable.Range(0, count)
+ .Select(page => (IReadOnlyList)Array.AsReadOnly(lines.Skip(page * contentRows).Take(contentRows).ToArray()))
+ .ToArray();
+ return new OverlapHoverPages(pages, hints);
+ }
+ // There must be room for at least one complete content line AND navigation.
+ return tooSmall;
+ }
+
+ private static List Wrap(string text, double width, Func measure)
+ {
+ var lines = new List();
+ foreach (var paragraph in text.Replace("\r\n", "\n").Split('\n'))
+ {
+ if (paragraph.Length == 0)
+ {
+ lines.Add("");
+ continue;
+ }
+ var starts = StringInfo.ParseCombiningCharacters(paragraph).Append(paragraph.Length).ToArray();
+ for (var first = 0; first < starts.Length - 1;)
+ {
+ var low = first;
+ var high = starts.Length - 1;
+ while (low < high)
+ {
+ var end = low + (high - low + 1) / 2;
+ if (measure(paragraph[starts[first]..starts[end]]) <= width)
+ low = end;
+ else
+ high = end - 1;
+ }
+ if (low == first)
+ return null; // Even one grapheme cannot fit; do not silently clip it.
+ var last = low;
+ if (last < starts.Length - 1)
+ {
+ for (var end = last; end > first; end--)
+ {
+ if (char.IsWhiteSpace(paragraph[starts[end - 1]]))
+ {
+ last = end;
+ break;
+ }
+ }
+ }
+ lines.Add(paragraph[starts[first]..starts[last]]);
+ first = last;
+ }
+ }
+ return lines;
+ }
+}
diff --git a/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs b/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs
new file mode 100644
index 0000000..36669a8
--- /dev/null
+++ b/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using OpenNest.Geometry;
+
+namespace OpenNest.Diagnostics;
+
+/// Read-only pair presentation; no geometry preparation or collision queries.
+public static class OverlapPairPresentation
+{
+ public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}";
+
+ public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null)
+ {
+ var units = UnitsHelper.GetShortString(capturedUnits);
+ return $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}\n"
+ + $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
+ + $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
+ }
+
+ // Significant figures adapt to scale: a positive sliver must never read as zero.
+ public static string Number(double value, IFormatProvider provider = null) =>
+ value.ToString("G6", provider ?? CultureInfo.CurrentCulture);
+
+ public static double MarkerHalfSize(int deviceDpi) => 6.0 * deviceDpi / 96;
+ public static double HitRadius(int deviceDpi) => 10.0 * deviceDpi / 96;
+
+ ///
+ /// Projects cached centroids into a screen-pixel coordinate space. The pointer must
+ /// use that same space; view zoom never scales the DPI-adjusted hit radius.
+ /// Coincident or nearby markers return every matching pair in stable ID order.
+ ///
+ public static IReadOnlyList HitTest(IEnumerable pairs,
+ Func worldToScreen, Vector pointer, int deviceDpi)
+ {
+ var radius = HitRadius(deviceDpi);
+ return pairs.Where(pair =>
+ {
+ var center = worldToScreen(pair.Centroid);
+ var dx = pointer.X - center.X;
+ var dy = pointer.Y - center.Y;
+ return dx * dx + dy * dy <= radius * radius;
+ }).OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
+ }
+}
diff --git a/OpenNest.Core/Diagnostics/OverlapReportState.cs b/OpenNest.Core/Diagnostics/OverlapReportState.cs
index af10966..efcc15f 100644
--- a/OpenNest.Core/Diagnostics/OverlapReportState.cs
+++ b/OpenNest.Core/Diagnostics/OverlapReportState.cs
@@ -2,7 +2,7 @@ using System.Collections.Generic;
namespace OpenNest.Diagnostics;
-public enum OverlapDisplayMode { Off, Areas }
+public enum OverlapDisplayMode { Off, Areas, Centroids, Both }
public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Failed, Canceled, Stale }
/// UI-thread lifecycle policy, independent of workers, GDI and view transforms.
@@ -34,7 +34,8 @@ public sealed class OverlapReportState
{
Clear(OverlapCheckStatus.Checking);
stamp = OverlapGeometryStamp.Capture(plate);
- DisplayMode = OverlapDisplayMode.Areas;
+ if (DisplayMode == OverlapDisplayMode.Off)
+ DisplayMode = OverlapDisplayMode.Areas;
return Generation;
}
diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
index 24737c9..3692b69 100644
--- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
@@ -150,14 +150,26 @@ public static class PlateOverlapAnalyzer
var result = Collision.Check(localA.Outer, localB.Outer, localA.Holes, localB.Holes);
if (!result.Overlaps)
continue;
- var regions = result.OverlapRegions.Select(region => new PlateOverlapRegion(
- region.Vertices.Select(point => point + origin),
- OverlapMaterial.Area(region.Vertices))).ToList();
- var area = regions.Sum(region => region.Area);
- if (!double.IsFinite(area) || area <= 0)
- throw new ArithmeticException("Overlap area is not finite and positive.");
+ // 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));
+ a.Input.Name, b.Input.Name, regions, centroid));
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
diff --git a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs
index 891f8d7..8be7228 100644
--- a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs
+++ b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs
@@ -25,7 +25,7 @@ public sealed class PlateOverlapPair
private readonly Box bounds;
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
- List regions)
+ List regions, Vector centroid)
{
PartAId = partAId;
PartBId = partBId;
@@ -33,6 +33,7 @@ public sealed class PlateOverlapPair
PartBName = partBName;
Regions = regions.AsReadOnly();
Area = regions.Sum(region => region.Area);
+ Centroid = centroid;
var points = regions.SelectMany(region => region.Vertices).ToArray();
var left = points.Min(point => point.X);
var bottom = points.Min(point => point.Y);
@@ -47,6 +48,11 @@ public sealed class PlateOverlapPair
/// Convex fragments, not connected islands; no mutable kernel polygons are exposed.
public IReadOnlyList Regions { get; }
public double Area { get; }
+ ///
+ /// Finite world-coordinate area centroid of all shared material, after hole subtraction.
+ /// This can lie outside disconnected or concave shared material. Returned by value.
+ ///
+ public Vector Centroid { get; }
/// A fresh world-coordinate bounds copy.
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
}
diff --git a/OpenNest.Core/Geometry/PolygonAreaMoments.cs b/OpenNest.Core/Geometry/PolygonAreaMoments.cs
new file mode 100644
index 0000000..4bb78da
--- /dev/null
+++ b/OpenNest.Core/Geometry/PolygonAreaMoments.cs
@@ -0,0 +1,101 @@
+using System.Collections.Generic;
+
+namespace OpenNest.Geometry;
+
+///
+/// Positive area and area centroid of a simple polygon, independent of winding.
+/// Moments are evaluated about a nearby origin rather than the world origin.
+///
+public readonly struct PolygonAreaMoments
+{
+ private PolygonAreaMoments(double area, Vector centroid)
+ {
+ Area = area;
+ Centroid = centroid;
+ }
+
+ public double Area { get; }
+ public Vector Centroid { get; }
+
+ ///
+ /// Accepts an open vertex list or an exactly repeated closing vertex. Returns false
+ /// for degenerate or nonfinite moments; does not validate polygon topology.
+ ///
+ public static bool TryCompute(IReadOnlyList vertices, out PolygonAreaMoments moments)
+ {
+ moments = default;
+ if (vertices == null || vertices.Count < 3)
+ return false;
+ foreach (var point in vertices)
+ if (!IsFinite(point))
+ return false;
+
+ var origin = vertices[0];
+ var count = vertices.Count;
+ if (vertices[count - 1].X == origin.X && vertices[count - 1].Y == origin.Y)
+ count--;
+ if (count < 3)
+ return false;
+
+ var twiceArea = 0.0;
+ var momentX = 0.0;
+ var momentY = 0.0;
+ // The closing edges meet the local origin and contribute zero. The signed
+ // triangle fan also handles concavity without averaging polygon vertices.
+ for (var i = 1; i + 1 < count; i++)
+ {
+ var a = vertices[i] - origin;
+ var b = vertices[i + 1] - origin;
+ var cross = a.X * b.Y - a.Y * b.X;
+ twiceArea += cross;
+ momentX += (a.X + b.X) * cross;
+ momentY += (a.Y + b.Y) * cross;
+ }
+
+ var area = System.Math.Abs(twiceArea) * 0.5;
+ if (!double.IsFinite(area) || area <= 0
+ || !double.IsFinite(momentX) || !double.IsFinite(momentY))
+ return false;
+ // Dividing signed moments by signed area cancels the winding, while Area
+ // stays positive so independently wound fragments always add material.
+ var centroid = origin + new Vector(momentX / twiceArea / 3, momentY / twiceArea / 3);
+ if (!IsFinite(centroid))
+ return false;
+ moments = new PolygonAreaMoments(area, centroid);
+ return true;
+ }
+
+ ///
+ /// Combines nonoverlapping, already hole-subtracted fragments using positive area
+ /// weights and another local origin. The centroid may lie outside the material.
+ /// Empty input or any invalid fragment fails the entire result.
+ ///
+ public static bool TryCombine(IEnumerable fragments, out PolygonAreaMoments moments)
+ {
+ moments = default;
+ if (fragments == null)
+ return false;
+ var area = 0.0;
+ var origin = Vector.Zero;
+ var firstMoment = Vector.Zero;
+ foreach (var fragment in fragments)
+ {
+ if (!double.IsFinite(fragment.Area) || fragment.Area <= 0 || !IsFinite(fragment.Centroid))
+ return false;
+ if (area == 0)
+ origin = fragment.Centroid;
+ firstMoment += (fragment.Centroid - origin) * fragment.Area;
+ area += fragment.Area;
+ }
+
+ if (!double.IsFinite(area) || area <= 0 || !IsFinite(firstMoment))
+ return false;
+ var centroid = origin + 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/OverlapHoverPagesTests.cs b/OpenNest.Tests/Diagnostics/OverlapHoverPagesTests.cs
new file mode 100644
index 0000000..8a4a78f
--- /dev/null
+++ b/OpenNest.Tests/Diagnostics/OverlapHoverPagesTests.cs
@@ -0,0 +1,130 @@
+using System.Globalization;
+using OpenNest.CNC;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Diagnostics;
+
+public class OverlapHoverPagesTests
+{
+ [Fact]
+ public void CrowdedCoincidentPairsRemainAccessibleInOrderWithinEveryPageBudget()
+ {
+ var parts = Enumerable.Range(1, 12).Select(i => Rectangle($"part-{i}")).ToArray();
+ var report = PlateOverlapAnalyzer.Analyze(parts);
+ var pairs = OverlapPairPresentation.HitTest(report.Pairs.Reverse(), p => p, new Vector(2, 2), 96);
+ var text = string.Join("\n\n", pairs.Select(p => OverlapPairPresentation.Details(p, Units.Millimeters, CultureInfo.InvariantCulture)));
+ var pages = OverlapHoverPages.Create(text, pairs.Count, 10, 45, s => s.Length);
+
+ Assert.True(pages.PageCount > 1);
+ Assert.False(pages.NeedsLargerViewport);
+ var displayed = ReadAll(pages, 10, 45);
+ Assert.Equal(text.Replace("\n", ""), displayed);
+ Assert.Contains("Pair 11/12: part-11 / part-12", displayed);
+ Assert.Contains("Shared area ≈ 16 mm²", displayed);
+ Assert.Contains("Centroid: (2, 2) mm", displayed);
+ Assert.Equal(pages.PageCount - 1, pages.PageIndex);
+ Assert.Contains($"Page {pages.PageCount}/{pages.PageCount}", string.Join("", pages.NavigationLines));
+ Assert.Contains("66 pairs", string.Join("", pages.NavigationLines));
+ Assert.Contains("PgUp/PgDn", string.Join("", pages.NavigationLines));
+ }
+
+ [Theory]
+ [InlineData(8, 12)]
+ [InlineData(4, 40)]
+ public void OneDetailWithLongUnbrokenNamesPagesWithoutDroppingText(int rows, int width)
+ {
+ var name = string.Concat(Enumerable.Repeat("veryLongName😀e\u0301", 30));
+ var pair = Assert.Single(PlateOverlapAnalyzer.Analyze(new[] { Rectangle(name), Rectangle("tail") }).Pairs);
+ var text = OverlapPairPresentation.Details(pair, Units.Inches, CultureInfo.InvariantCulture);
+ var pages = OverlapHoverPages.Create(text, 1, rows, width, s => new StringInfo(s).LengthInTextElements);
+
+ Assert.True(pages.PageCount > 1);
+ Assert.False(pages.NeedsLargerViewport);
+ Assert.Equal(text.Replace("\n", ""), ReadAll(pages, rows, width, s => new StringInfo(s).LengthInTextElements));
+ Assert.All(AllLines(text, rows, width), line =>
+ {
+ Assert.False(line.Length > 0 && char.IsLowSurrogate(line[0]));
+ Assert.False(line.StartsWith("\u0301"));
+ });
+ }
+
+ [Fact]
+ public void NavigationClampsAtBothEndsAndNewHoverStartsAtFirstPage()
+ {
+ const string text = "one\ntwo\nthree\nfour\nfive\nsix";
+ var pages = OverlapHoverPages.Create(text, 2, 4, 40, s => s.Length);
+ Assert.True(pages.PageCount > 1);
+ pages.MovePage(int.MaxValue);
+ Assert.Equal(pages.PageCount - 1, pages.PageIndex);
+ pages.MovePage(1);
+ Assert.Equal(pages.PageCount - 1, pages.PageIndex);
+ pages.MovePage(int.MinValue);
+ Assert.Equal(0, pages.PageIndex);
+ pages.MovePage(-1);
+ Assert.Equal(0, pages.PageIndex);
+ pages.MovePage(1);
+ Assert.Equal(0, OverlapHoverPages.Create(text, 2, 4, 40, s => s.Length).PageIndex);
+ }
+
+ [Fact]
+ public void FittingDetailsDoNotSpendSpaceOnNavigation()
+ {
+ var pages = OverlapHoverPages.Create("Pair 1/2\narea\ncentroid", 1, 3, 40, s => s.Length);
+ Assert.Equal(1, pages.PageCount);
+ Assert.Empty(pages.NavigationLines);
+ Assert.Equal(new[] { "Pair 1/2", "area", "centroid" }, pages.Lines);
+ pages.MovePage(1);
+ Assert.Equal(0, pages.PageIndex);
+ }
+
+ [Theory]
+ [InlineData(0, 40)]
+ [InlineData(3, 0)]
+ [InlineData(1, 5)]
+ [InlineData(4, 0.5)]
+ public void ImpossibleViewportExplicitlyRequestsMoreSpaceInsteadOfDroppingDetails(int rows, double width)
+ {
+ var pages = OverlapHoverPages.Create("Pair 1/2\narea\ncentroid", 1, rows, width, s => s.Length);
+ Assert.True(pages.NeedsLargerViewport);
+ Assert.Empty(pages.Lines);
+ Assert.Equal(0, pages.PageCount);
+ }
+
+ private static string ReadAll(OverlapHoverPages pages, int rows, double width, Func? measure = null)
+ {
+ measure ??= s => s.Length;
+ var content = new List();
+ for (var index = 0; index < pages.PageCount; index++)
+ {
+ Assert.Equal(index, pages.PageIndex);
+ Assert.InRange(pages.Lines.Count + pages.NavigationLines.Count, 1, rows);
+ Assert.All(pages.Lines.Concat(pages.NavigationLines), line => Assert.InRange(measure(line), 0, width));
+ content.AddRange(pages.Lines);
+ pages.MovePage(1);
+ }
+ return string.Concat(content);
+ }
+
+ private static IEnumerable AllLines(string text, int rows, int width)
+ {
+ var pages = OverlapHoverPages.Create(text, 1, rows, width, s => new StringInfo(s).LengthInTextElements);
+ for (var i = 0; i < pages.PageCount; i++)
+ {
+ foreach (var line in pages.Lines)
+ yield return line;
+ pages.MovePage(1);
+ }
+ }
+
+ private static Part Rectangle(string name)
+ {
+ var program = new Program(Mode.Absolute);
+ program.Codes.Add(new RapidMove(0, 0));
+ program.Codes.Add(new LinearMove(4, 0));
+ program.Codes.Add(new LinearMove(4, 4));
+ program.Codes.Add(new LinearMove(0, 4));
+ program.Codes.Add(new LinearMove(0, 0));
+ return new Part(new Drawing(name, program));
+ }
+}
diff --git a/OpenNest.Tests/Diagnostics/OverlapPairPresentationTests.cs b/OpenNest.Tests/Diagnostics/OverlapPairPresentationTests.cs
new file mode 100644
index 0000000..1d2c329
--- /dev/null
+++ b/OpenNest.Tests/Diagnostics/OverlapPairPresentationTests.cs
@@ -0,0 +1,78 @@
+using System.Globalization;
+using OpenNest.CNC;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Diagnostics;
+
+public class OverlapPairPresentationTests
+{
+ [Theory]
+ [InlineData(Units.Inches, "in")]
+ [InlineData(Units.Millimeters, "mm")]
+ public void DetailsUseCapturedNamesSequenceIdsAndSuppliedUnits(Units units, string symbol)
+ {
+ var parts = new[] { Rectangle("cutoff"), Rectangle("left"), Rectangle("right") };
+ parts[0].BaseDrawing.IsCutOff = true;
+ var snapshot = PlateOverlapAnalyzer.Capture(parts);
+ parts[1].BaseDrawing.Name = "changed";
+ var pair = Assert.Single(PlateOverlapAnalyzer.Analyze(snapshot).Pairs);
+ Assert.Equal("2/3", OverlapPairPresentation.Label(pair));
+ var text = OverlapPairPresentation.Details(pair, units, CultureInfo.InvariantCulture);
+ Assert.Equal($"Pair 2/3: left / right\nShared area ≈ 16 {symbol}²\nCentroid: (2, 2) {symbol}", text);
+ }
+
+ [Theory]
+ [InlineData(1e-15)]
+ [InlineData(1e-100)]
+ [InlineData(double.Epsilon)]
+ [InlineData(0.000000123456)]
+ public void PositiveTinyValuesNeverFormatAsZero(double value)
+ {
+ var text = OverlapPairPresentation.Number(value, CultureInfo.InvariantCulture);
+ Assert.True(double.Parse(text, CultureInfo.InvariantCulture) > 0, text);
+ }
+
+ [Theory]
+ [InlineData(96, 1)]
+ [InlineData(144, 1.5)]
+ [InlineData(192, 2)]
+ public void HitRadiusIsScreenSizedDpiScaledAndInclusive(int dpi, double factor)
+ {
+ Assert.Equal(6 * factor, OverlapPairPresentation.MarkerHalfSize(dpi));
+ Assert.Equal(10 * factor, OverlapPairPresentation.HitRadius(dpi));
+ var pairs = PlateOverlapAnalyzer.Analyze(new[] { Rectangle("a"), Rectangle("b") }).Pairs;
+ foreach (var zoom in new[] { 0.5, 20.0, 500.0 })
+ {
+ Vector ToScreen(Vector world) => new(100 + world.X * zoom, 200 - world.Y * zoom);
+ var center = ToScreen(pairs[0].Centroid);
+ var radius = OverlapPairPresentation.HitRadius(dpi);
+ Assert.Single(OverlapPairPresentation.HitTest(pairs, ToScreen,
+ center + new Vector(radius, 0), dpi));
+ Assert.Empty(OverlapPairPresentation.HitTest(pairs, ToScreen,
+ center + new Vector(radius + 0.01, 0), dpi));
+ Assert.Empty(OverlapPairPresentation.HitTest(pairs, ToScreen,
+ center + new Vector(radius, radius), dpi));
+ }
+ }
+
+ [Fact]
+ public void CoincidentMarkersReturnEveryPairInIdOrderNotFragmentOrder()
+ {
+ 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));
+ }
+
+ private static Part Rectangle(string name)
+ {
+ var program = new Program(Mode.Absolute);
+ program.Codes.Add(new RapidMove(0, 0));
+ program.Codes.Add(new LinearMove(4, 0));
+ program.Codes.Add(new LinearMove(4, 4));
+ program.Codes.Add(new LinearMove(0, 4));
+ program.Codes.Add(new LinearMove(0, 0));
+ return new Part(new Drawing(name, program));
+ }
+}
diff --git a/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs b/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs
index 1073f03..8386a15 100644
--- a/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs
+++ b/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs
@@ -134,6 +134,22 @@ public class OverlapReportStateTests
Assert.Null(state.Report);
}
+ [Theory]
+ [InlineData(OverlapDisplayMode.Off, OverlapDisplayMode.Areas)]
+ [InlineData(OverlapDisplayMode.Areas, OverlapDisplayMode.Areas)]
+ [InlineData(OverlapDisplayMode.Centroids, OverlapDisplayMode.Centroids)]
+ [InlineData(OverlapDisplayMode.Both, OverlapDisplayMode.Both)]
+ public void CheckAndRecheckPreserveVisibleMode(OverlapDisplayMode chosen, OverlapDisplayMode expected)
+ {
+ var plate = PlateWithParts();
+ var state = new OverlapReportState { DisplayMode = chosen };
+ var request = state.Begin(plate);
+ Assert.Equal(expected, state.DisplayMode);
+ Assert.True(state.TryPublish(request, plate, Analyze(plate)));
+ state.Begin(plate);
+ Assert.Equal(expected, state.DisplayMode);
+ }
+
[Fact]
public void NewPlateResetsStateButRetainsDocumentDisplayPreference()
{
diff --git a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs
index 0cd5a64..7a53123 100644
--- a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs
+++ b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs
@@ -19,6 +19,7 @@ public class PlateOverlapAnalyzerTests
var pair = Assert.Single(report.Pairs);
Assert.Equal((0, 1), (pair.PartAId, pair.PartBId));
Assert.Equal(0.5, pair.Area, 9);
+ AssertCentroid(pair, 0.75, 0.5);
Assert.NotEmpty(pair.Regions);
Assert.All(pair.Regions, region =>
{
@@ -43,6 +44,7 @@ public class PlateOverlapAnalyzerTests
Rectangle(0, 0, 4, 4), Rectangle(1, 1, 1, 1)
}).Pairs);
Assert.Equal(1, pair.Area, 9);
+ AssertCentroid(pair, 1.5, 1.5);
}
[Theory]
@@ -84,7 +86,9 @@ public class PlateOverlapAnalyzerTests
insert.Location = new Vector(0.5, 1.5);
report = PlateOverlapAnalyzer.Analyze(swap ? new[] { insert, frame } : new[] { frame, insert });
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
- Assert.Equal(0.5, Assert.Single(report.Pairs).Area, 9);
+ var pair = Assert.Single(report.Pairs);
+ Assert.Equal(0.5, pair.Area, 9);
+ AssertCentroid(pair, 0.75, 2);
}
[Fact]
@@ -94,7 +98,9 @@ public class PlateOverlapAnalyzerTests
var b = WithContours(Square(0, 0, 4), Square(2, 2, 1));
var report = PlateOverlapAnalyzer.Analyze(new[] { a, b });
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
- Assert.Equal(14, Assert.Single(report.Pairs).Area, 8);
+ var pair = Assert.Single(report.Pairs);
+ Assert.Equal(14, pair.Area, 8);
+ AssertCentroid(pair, 28.5 / 14, 28.5 / 14);
}
[Fact]
@@ -109,6 +115,7 @@ public class PlateOverlapAnalyzerTests
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
var pair = Assert.Single(report.Pairs);
Assert.Equal(2, pair.Area, 8);
+ AssertCentroid(pair, 1.5, 2.5);
Assert.All(pair.Regions, region => Assert.True(
region.Vertices.All(p => p.X <= 1) || region.Vertices.All(p => p.X >= 2)));
}
@@ -127,6 +134,7 @@ public class PlateOverlapAnalyzerTests
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
var pair = Assert.Single(report.Pairs);
Assert.Equal(0.5, pair.Area, 7);
+ AssertCentroid(pair, x + 0.75, y + 0.5);
Assert.Equal(x + 0.5, pair.Bounds.Left, 7);
Assert.Equal(y + 1, pair.Bounds.Top, 7);
}
@@ -141,7 +149,9 @@ public class PlateOverlapAnalyzerTests
part.Location = new Vector(10, 10);
var report = PlateOverlapAnalyzer.Analyze(new[] { part, Rectangle(8, 9, 2, 1) });
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
- Assert.Equal(2, Assert.Single(report.Pairs).Area, 8);
+ var pair = Assert.Single(report.Pairs);
+ Assert.Equal(2, pair.Area, 8);
+ AssertCentroid(pair, 9, 9.5);
}
[Fact]
@@ -297,6 +307,10 @@ public class PlateOverlapAnalyzerTests
Assert.Throws(() => ((IList)region.Vertices)[0] = new Vector(99, 99));
pair.Bounds.X = 99;
Assert.Equal(0, pair.Bounds.X);
+ var centroid = pair.Centroid;
+ centroid.X = 99;
+ centroid.Y = 99;
+ AssertCentroid(pair, 0.5, 0.5);
}
[Fact]
@@ -368,7 +382,9 @@ public class PlateOverlapAnalyzerTests
small.Location = new Vector(999999998, 999999998);
var report = PlateOverlapAnalyzer.Analyze(swap ? new[] { small, outer } : new[] { outer, small });
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
- Assert.Equal(1, Assert.Single(report.Pairs).Area, 8);
+ var pair = Assert.Single(report.Pairs);
+ Assert.Equal(1, pair.Area, 8);
+ AssertCentroid(pair, 999999998.5, 999999998.5);
}
[Fact]
@@ -423,6 +439,68 @@ public class PlateOverlapAnalyzerTests
Assert.Throws(() => PlateOverlapAnalyzer.Analyze(snapshot, cancellation.Token));
}
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1000000000)]
+ [InlineData(-1000000000)]
+ public void Analyze_UnequalDisconnectedOverlapWeightsAllFragments(double offset)
+ {
+ var u = WithContours(new[]
+ {
+ new Vector(0, 0), new Vector(6, 0), new Vector(6, 3), new Vector(3, 3),
+ new Vector(3, 1), new Vector(1, 1), new Vector(1, 3), new Vector(0, 3)
+ });
+ u.Location = new Vector(offset, offset);
+
+ var report = PlateOverlapAnalyzer.Analyze(new[] { u, Rectangle(offset, offset + 2, 6, 1) });
+
+ Assert.True(report.IsComplete, string.Join("; ", report.Issues));
+ var pair = Assert.Single(report.Pairs);
+ Assert.Equal(4, pair.Area, 8);
+ AssertCentroid(pair, offset + 3.5, offset + 2.5);
+ }
+
+ [Fact]
+ public void Analyze_RotatedTriangleHasTrueMaterialCentroid()
+ {
+ var triangle = WithContours(new[] { new Vector(0, 0), new Vector(6, 0), new Vector(0, 3) });
+ triangle.Rotate(System.Math.PI / 2);
+ triangle.Location = new Vector(10, 20);
+
+ var report = PlateOverlapAnalyzer.Analyze(new[] { Rectangle(0, 0, 30, 30), triangle });
+
+ Assert.True(report.IsComplete, string.Join("; ", report.Issues));
+ var pair = Assert.Single(report.Pairs);
+ Assert.Equal(9, pair.Area, 8);
+ AssertCentroid(pair, 9, 22);
+ }
+
+ [Fact]
+ public void Analyze_OverflowingPairMomentsAreIncompleteAndRetainOtherPairs()
+ {
+ var huge = Rectangle(0, 0, 1e103, 1e103);
+ var report = PlateOverlapAnalyzer.Analyze(new[]
+ {
+ huge, new Part(huge.BaseDrawing), Rectangle(-2, -2, 1, 1), Rectangle(-2, -2, 1, 1)
+ });
+
+ 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);
+ var valid = Assert.Single(report.Pairs);
+ Assert.Equal((2, 3), (valid.PartAId, valid.PartBId));
+ AssertCentroid(valid, -1.5, -1.5);
+ }
+
+ private static void AssertCentroid(PlateOverlapPair pair, double x, double y)
+ {
+ Assert.True(double.IsFinite(pair.Centroid.X));
+ Assert.True(double.IsFinite(pair.Centroid.Y));
+ Assert.Equal(x, pair.Centroid.X, 7);
+ Assert.Equal(y, pair.Centroid.Y, 7);
+ }
+
private static Part Rectangle(double x, double y, double width, double height) =>
new(WithContours(new[] { new Vector(0, 0), new Vector(width, 0),
new Vector(width, height), new Vector(0, height) }).BaseDrawing, new Vector(x, y));
diff --git a/OpenNest.Tests/Geometry/PolygonAreaMomentsTests.cs b/OpenNest.Tests/Geometry/PolygonAreaMomentsTests.cs
new file mode 100644
index 0000000..5b59de6
--- /dev/null
+++ b/OpenNest.Tests/Geometry/PolygonAreaMomentsTests.cs
@@ -0,0 +1,141 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Geometry;
+
+public class PolygonAreaMomentsTests
+{
+ [Theory]
+ [InlineData(0, false, false)]
+ [InlineData(0, true, false)]
+ [InlineData(0, false, true)]
+ [InlineData(0, true, true)]
+ [InlineData(1000000000, false, false)]
+ [InlineData(1000000000, true, true)]
+ [InlineData(-1000000000, false, true)]
+ [InlineData(-1000000000, true, false)]
+ public void TryCompute_RectangleUsesSignedMomentsWithLocalOrigin(double offset, bool reverse, bool closed)
+ {
+ var vertices = Rectangle(offset + 0.5, offset, 0.5, 1);
+ if (reverse)
+ Array.Reverse(vertices);
+ if (closed)
+ vertices = vertices.Append(vertices[0]).ToArray();
+
+ Assert.True(PolygonAreaMoments.TryCompute(vertices, out var moments));
+
+ Assert.Equal(0.5, moments.Area);
+ Assert.Equal(offset + 0.75, moments.Centroid.X);
+ Assert.Equal(offset + 0.5, moments.Centroid.Y);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void TryCompute_ConcavePolygonUsesAreaNotVertexAverage(bool reverse)
+ {
+ var vertices = new[]
+ {
+ new Vector(0, 0), new Vector(3, 0), new Vector(3, 1),
+ new Vector(1, 1), new Vector(1, 3), new Vector(0, 3)
+ };
+ if (reverse)
+ Array.Reverse(vertices);
+
+ Assert.True(PolygonAreaMoments.TryCompute(vertices, out var moments));
+
+ Assert.Equal(5, moments.Area);
+ Assert.Equal(1.1, moments.Centroid.X, 12);
+ Assert.Equal(1.1, moments.Centroid.Y, 12);
+ }
+
+ [Fact]
+ public void TryCompute_CollinearVerticesDoNotBiasCentroid()
+ {
+ var vertices = new[]
+ {
+ new Vector(0, 0), new Vector(1, 0), new Vector(2, 0), new Vector(3, 0),
+ new Vector(3, 2), new Vector(0, 2), new Vector(0, 0)
+ };
+
+ Assert.True(PolygonAreaMoments.TryCompute(vertices, out var moments));
+
+ Assert.Equal(6, moments.Area);
+ Assert.Equal(1.5, moments.Centroid.X);
+ Assert.Equal(1, moments.Centroid.Y);
+ }
+
+ [Fact]
+ public void TryCompute_NearButNotExactClosingVertexIsNotDiscarded()
+ {
+ var vertices = new[]
+ {
+ new Vector(0, 0), new Vector(1, 0), new Vector(1, 1),
+ new Vector(0, 1), new Vector(0.000001, 0.000001)
+ };
+ // The final triangle removes area d/2 and has center (d/3, (1+d)/3).
+ const double d = 0.000001;
+ var expectedArea = 1 - d / 2;
+
+ Assert.True(PolygonAreaMoments.TryCompute(vertices, out var moments));
+
+ Assert.Equal(expectedArea, moments.Area, 14);
+ Assert.Equal((0.5 - d * d / 6) / expectedArea, moments.Centroid.X, 14);
+ Assert.Equal((0.5 - d * (1 + d) / 6) / expectedArea, moments.Centroid.Y, 14);
+ }
+
+ [Theory]
+ [InlineData(0, false)]
+ [InlineData(0, true)]
+ [InlineData(1000000000, false)]
+ [InlineData(1000000000, true)]
+ [InlineData(-1000000000, false)]
+ public void TryCombine_UnequalDisconnectedFragmentsUsePositiveAreaWeights(double offset, bool swap)
+ {
+ Assert.True(PolygonAreaMoments.TryCompute(Rectangle(offset, offset, 1, 1), out var small));
+ Assert.True(PolygonAreaMoments.TryCompute(
+ Rectangle(offset + 4, offset, 1, 3).Reverse().ToArray(), out var large));
+
+ Assert.True(PolygonAreaMoments.TryCombine(swap ? new[] { large, small } : new[] { small, large },
+ out var combined));
+
+ Assert.Equal(4, combined.Area);
+ Assert.Equal(offset + 3.5, combined.Centroid.X);
+ Assert.Equal(offset + 1.25, combined.Centroid.Y);
+ // The centroid is in the gap, not on either material fragment.
+ Assert.InRange(combined.Centroid.X - offset, 1.01, 3.99);
+ }
+
+ public static IEnumerable