feat(plateview): annotate overlap centroids

This commit is contained in:
aj
2026-09-28 22:17:49 -04:00
parent e4d07121c8
commit a8204bb51f
19 changed files with 1608 additions and 46 deletions
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace OpenNest.Diagnostics;
/// <summary>
/// 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.
/// </summary>
public sealed class OverlapHoverPages
{
private readonly IReadOnlyList<string>[] pages;
private readonly IReadOnlyList<string>[] navigation;
private OverlapHoverPages(IReadOnlyList<string>[] pages, IReadOnlyList<string>[] navigation,
bool needsLargerViewport = false)
{
this.pages = pages;
this.navigation = navigation;
NeedsLargerViewport = needsLargerViewport;
}
public IReadOnlyList<string> Lines => PageCount == 0 ? Array.Empty<string>() : pages[PageIndex];
public IReadOnlyList<string> NavigationLines => PageCount == 0 ? Array.Empty<string>() : 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<string, double> 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<string>()]);
// 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<string>[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<string>)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<string> Wrap(string text, double width, Func<string, double> measure)
{
var lines = new List<string>();
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;
}
}
@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
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;
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<PlateOverlapPair> HitTest(IEnumerable<PlateOverlapPair> pairs,
Func<Vector, Vector> 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();
}
}
@@ -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 }
/// <summary>UI-thread lifecycle policy, independent of workers, GDI and view transforms.</summary>
@@ -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;
}
@@ -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<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));
a.Input.Name, b.Input.Name, regions, centroid));
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
@@ -25,7 +25,7 @@ public sealed class PlateOverlapPair
private readonly Box bounds;
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
List<PlateOverlapRegion> regions)
List<PlateOverlapRegion> 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
/// <summary>Convex fragments, not connected islands; no mutable kernel polygons are exposed.</summary>
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
public double Area { get; }
/// <summary>
/// Finite world-coordinate area centroid of all shared material, after hole subtraction.
/// This can lie outside disconnected or concave shared material. Returned by value.
/// </summary>
public Vector Centroid { get; }
/// <summary>A fresh world-coordinate bounds copy.</summary>
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
}
@@ -0,0 +1,101 @@
using System.Collections.Generic;
namespace OpenNest.Geometry;
/// <summary>
/// Positive area and area centroid of a simple polygon, independent of winding.
/// Moments are evaluated about a nearby origin rather than the world origin.
/// </summary>
public readonly struct PolygonAreaMoments
{
private PolygonAreaMoments(double area, Vector centroid)
{
Area = area;
Centroid = centroid;
}
public double Area { get; }
public Vector Centroid { get; }
/// <summary>
/// Accepts an open vertex list or an exactly repeated closing vertex. Returns false
/// for degenerate or nonfinite moments; does not validate polygon topology.
/// </summary>
public static bool TryCompute(IReadOnlyList<Vector> 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;
}
/// <summary>
/// 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.
/// </summary>
public static bool TryCombine(IEnumerable<PolygonAreaMoments> 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);
}
@@ -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<string, double>? measure = null)
{
measure ??= s => s.Length;
var content = new List<string>();
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<string> 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));
}
}
@@ -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));
}
}
@@ -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()
{
@@ -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<NotSupportedException>(() => ((IList<Vector>)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<OperationCanceledException>(() => 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));
@@ -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<object[]> InvalidPolygons()
{
yield return new object[] { Array.Empty<Vector>() };
yield return new object[] { new[] { new Vector(1, 1), new Vector(2, 2) } };
yield return new object[] { new[] { new Vector(1, 1), new Vector(2, 2), new Vector(3, 3) } };
yield return new object[] { new[] { new Vector(1, 1), new Vector(1, 1), new Vector(1, 1) } };
yield return new object[] { new[] { new Vector(0, 0), new Vector(double.NaN, 0), new Vector(0, 1) } };
yield return new object[] { new[] { new Vector(0, 0), new Vector(1, double.PositiveInfinity), new Vector(0, 1) } };
yield return new object[] { new[] { new Vector(double.NegativeInfinity, 0), new Vector(1, 0), new Vector(0, 1) } };
yield return new object[] { Rectangle(0, 0, 1e200, 1e200) };
yield return new object[] { Rectangle(0, 0, 1e103, 1e103) };
}
[Theory]
[MemberData(nameof(InvalidPolygons))]
public void TryCompute_DegenerateNonfiniteOrOverflowingMomentsAreRejected(Vector[] vertices)
{
Assert.False(PolygonAreaMoments.TryCompute(vertices, out _));
}
[Fact]
public void TryCombine_EmptyOrInvalidFragmentRejectsWholeResult()
{
Assert.True(PolygonAreaMoments.TryCompute(Rectangle(1, 1, 1, 1), out var valid));
Assert.False(PolygonAreaMoments.TryCombine(Array.Empty<PolygonAreaMoments>(), out _));
Assert.False(PolygonAreaMoments.TryCombine(new[] { valid, default(PolygonAreaMoments) }, out _));
Assert.False(PolygonAreaMoments.TryCombine(new[] { default(PolygonAreaMoments), valid }, 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) };
}
@@ -266,11 +266,15 @@ public class PlateOverlapOverlayTests
var cancel = Menu(host, "mnuOverlapCancel");
var off = Menu(host, "mnuOverlapOff");
var areas = Menu(host, "mnuOverlapAreas");
var centroids = Menu(host, "mnuOverlapCentroids");
var both = Menu(host, "mnuOverlapBoth");
Assert.False(check.Enabled);
Assert.False(cancel.Enabled);
Assert.False(off.Enabled);
Assert.False(areas.Enabled);
Assert.All(new[] { check, cancel, off, areas }, item => Assert.Equal(Keys.None, item.ShortcutKeys));
Assert.False(centroids.Enabled);
Assert.False(both.Enabled);
Assert.All(new[] { check, cancel, off, areas, centroids, both }, item => Assert.Equal(Keys.None, item.ShortcutKeys));
using var first = new EditNestForm(new Nest("first")) { MdiParent = host };
first.Show();
@@ -286,6 +290,19 @@ public class PlateOverlapOverlayTests
Assert.True(off.Checked);
Assert.False(areas.Checked);
Assert.Equal(OverlapDisplayMode.Areas, second.OverlapDisplay);
centroids.PerformClick();
Assert.True(centroids.Checked);
Assert.False(off.Checked);
Assert.Equal(OverlapDisplayMode.Centroids, first.OverlapDisplay);
second.Activate();
both.PerformClick();
Assert.True(both.Checked);
Assert.False(centroids.Checked);
Assert.Equal(OverlapDisplayMode.Both, second.OverlapDisplay);
first.Activate();
Assert.True(centroids.Checked);
Assert.False(both.Checked);
off.PerformClick();
var previous = SynchronizationContext.Current;
var context = new PumpContext();
@@ -320,6 +337,458 @@ public class PlateOverlapOverlayTests
Assert.False(cancel.Enabled);
});
[Fact]
public void CheckCommandCapturesUnitsAndNamesUntilTheNextCheck() => RunSta(() =>
{
using var form = new EditNestForm(new Nest("units") { Units = Units.Millimeters });
form.Show();
form.PlateView.Plate.Parts.Add(Rectangle());
form.PlateView.Plate.Parts.Add(Rectangle(1));
form.OverlapDisplay = OverlapDisplayMode.Both;
var previous = SynchronizationContext.Current;
var context = new PumpContext();
var started = new TaskCompletionSource<PlateOverlapSnapshot>(TaskCreationOptions.RunContinuationsAsynchronously);
var result = new TaskCompletionSource<PlateOverlapReport>(TaskCreationOptions.RunContinuationsAsynchronously);
Task? task = null;
SynchronizationContext.SetSynchronizationContext(context);
try
{
form.PlateView.OverlapOverlay.Analyze = (snapshot, _) =>
{
started.TrySetResult(snapshot);
return WaitForWorkerResult(result.Task);
};
task = form.CheckOverlapsAsync();
var snapshot = WaitForSnapshot(started.Task);
form.Nest.Units = Units.Inches;
form.PlateView.Plate.Parts[0].BaseDrawing.Name = "changed";
result.SetResult(PlateOverlapAnalyzer.Analyze(snapshot));
context.Pump(task);
var center = form.PlateView.PointWorldToControl(form.PlateView.OverlapReport.Pairs[0].Centroid);
form.PlateView.OverlapOverlay.UpdateHover(center);
Assert.Contains(" mm²", form.PlateView.OverlapOverlay.HoverText);
Assert.Contains("same name / same name", form.PlateView.OverlapOverlay.HoverText);
Assert.DoesNotContain("changed", form.PlateView.OverlapOverlay.HoverText);
form.PlateView.OverlapOverlay.Analyze = PlateOverlapAnalyzer.Analyze;
task = form.CheckOverlapsAsync();
Assert.Null(form.PlateView.OverlapOverlay.HoverText);
context.Pump(task);
form.PlateView.OverlapOverlay.UpdateHover(center);
Assert.Contains(" in²", form.PlateView.OverlapOverlay.HoverText);
Assert.Contains("changed / same name", form.PlateView.OverlapOverlay.HoverText);
Assert.Equal(OverlapDisplayMode.Both, form.OverlapDisplay);
}
finally
{
form.CancelOverlapCheck();
result.TrySetCanceled();
try { if (task != null) context.Pump(task); }
finally { SynchronizationContext.SetSynchronizationContext(previous); }
}
});
[Fact]
public void CleanupReleasesDequeuedWorkersEvenWithoutCompleteOrFail() => RunSta(() =>
{
using var run = new OverlayRun();
var first = run.Start();
var old = run.Next();
var second = run.Start();
var current = run.Next();
run.Dispose();
Assert.True(old.Result.Task.IsCompleted);
Assert.True(current.Result.Task.IsCompleted);
Assert.True(first.IsCompletedSuccessfully);
Assert.True(second.IsCompletedSuccessfully);
});
[Theory]
[InlineData(OverlapDisplayMode.Areas)]
[InlineData(OverlapDisplayMode.Centroids)]
[InlineData(OverlapDisplayMode.Both)]
public void RecheckPreservesEachVisibleModeAndDisplayOnlyRepaints(OverlapDisplayMode mode) => RunSta(() =>
{
using var run = new OverlayRun();
run.View.OverlapDisplay = mode;
run.Finish();
Assert.Equal(mode, run.View.OverlapDisplay);
run.Finish();
Assert.Equal(mode, run.View.OverlapDisplay);
var report = run.View.OverlapReport;
using var image = new Bitmap(300, 300);
using var graphics = Graphics.FromImage(image);
foreach (var display in Enum.GetValues<OverlapDisplayMode>())
{
run.View.OverlapDisplay = display;
run.View.OverlapOverlay.Draw(graphics);
Assert.Same(report, run.View.OverlapReport);
}
Assert.Equal(2, run.Calls);
});
[Fact]
public void CentroidMarkerUsesGraphCoordinatesAndFixedScreenSizeAcrossZoom() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.OverlapDisplay = OverlapDisplayMode.Centroids;
run.Finish();
var pair = Assert.Single(run.View.OverlapReport.Pairs);
using var image = new Bitmap(500, 500);
using var graphics = Graphics.FromImage(image);
foreach (var zoom in new[] { 20f, 2f })
{
run.View.ZoomToPoint(new Vector(), zoom);
run.View.SetOrigin(60, 320);
graphics.ResetTransform();
graphics.Clear(Color.White);
graphics.TranslateTransform(60, 320);
run.View.OverlapOverlay.Draw(graphics);
Assert.Null(run.View.OverlapOverlay.CachedPath);
var center = run.View.PointWorldToControl(pair.Centroid);
var radius = (int)OverlapPairPresentation.MarkerHalfSize(run.View.DeviceDpi);
Assert.NotEqual(Color.White.ToArgb(), image.GetPixel(center.X - radius + 1, center.Y).ToArgb());
Assert.Equal(Color.White.ToArgb(), image.GetPixel(center.X - radius - 4, center.Y).ToArgb());
Assert.Equal(Color.White.ToArgb(), image.GetPixel(center.X - 60, center.Y).ToArgb());
run.View.MoveTo(new Point(center.X + (int)OverlapPairPresentation.HitRadius(run.View.DeviceDpi), center.Y));
Assert.Contains("Pair 1/2", run.View.OverlapOverlay.HoverText);
run.View.MoveTo(new Point(center.X + (int)OverlapPairPresentation.HitRadius(run.View.DeviceDpi) + 2, center.Y));
Assert.Null(run.View.OverlapOverlay.HoverText);
}
Assert.Equal(1, run.Calls);
});
[Fact]
public void MarkerAndHoverUsePairMaterialCentroidRatherThanBoundsCenter() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.Plate.Parts.Clear();
var program = new Program(Mode.Absolute);
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(6, 0));
program.Codes.Add(new LinearMove(0, 3));
program.Codes.Add(new LinearMove(0, 0));
var drawing = new Drawing("triangle", program);
run.View.Plate.Parts.Add(new Part(drawing));
run.View.Plate.Parts.Add(new Part(drawing));
run.View.OverlapDisplay = OverlapDisplayMode.Centroids;
run.Finish();
run.View.ZoomToPoint(new Vector(), 30);
run.View.SetOrigin(60, 220);
using var image = new Bitmap(300, 300);
using var graphics = Graphics.FromImage(image);
graphics.Clear(Color.White);
graphics.TranslateTransform(60, 220);
run.View.OverlapOverlay.Draw(graphics);
var materialCenter = run.View.PointWorldToControl(new Vector(6.0 / 3, 3.0 / 3));
var boundsCenter = run.View.PointWorldToControl(new Vector(6.0 / 2, 3.0 / 2));
Assert.NotEqual(Color.White.ToArgb(), image.GetPixel(materialCenter.X, materialCenter.Y).ToArgb());
Assert.Equal(Color.White.ToArgb(), image.GetPixel(boundsCenter.X, boundsCenter.Y).ToArgb());
run.View.MoveTo(materialCenter);
Assert.Contains("Pair 1/2", run.View.OverlapOverlay.HoverText);
run.View.MoveTo(boundsCenter);
Assert.Null(run.View.OverlapOverlay.HoverText);
});
[Fact]
public void BothRetainsAreaPathAndCoincidentHoverListsEveryPairWithoutChangingSelection() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.Plate.Parts.Clear();
run.View.Plate.Parts.Add(Rectangle());
run.View.Plate.Parts.Add(Rectangle());
run.View.Plate.Parts.Add(Rectangle());
run.Finish();
run.View.ZoomToPoint(new Vector(), 20);
run.View.SetOrigin(60, 220);
using var image = new Bitmap(300, 300);
using var graphics = Graphics.FromImage(image);
graphics.TranslateTransform(60, 220);
run.View.OverlapOverlay.Draw(graphics);
var path = run.View.OverlapOverlay.CachedPath;
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.View.OverlapOverlay.Draw(graphics);
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
var center = run.View.PointWorldToControl(new Vector(2, 2));
run.View.MoveTo(center);
var text = run.View.OverlapOverlay.HoverText!;
Assert.True(text.IndexOf("Pair 1/2", StringComparison.Ordinal) < text.IndexOf("Pair 1/3", StringComparison.Ordinal));
Assert.True(text.IndexOf("Pair 1/3", StringComparison.Ordinal) < text.IndexOf("Pair 2/3", StringComparison.Ordinal));
Assert.Empty(run.View.SelectedParts);
run.View.ClickAt(center);
Assert.NotEmpty(run.View.SelectedParts); // Informational hover does not consume the selection click.
Assert.Null(run.View.OverlapOverlay.HoverText);
Assert.Equal(1, run.Calls);
});
[Fact]
public void PairDetailsOnlySuppressNormalTooltipWhileVisible() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.Finish();
run.View.ZoomToPoint(new Vector(), 20);
run.View.SetOrigin(60, 220);
run.View.MoveTo(run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid));
var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic;
var hoverCheck = typeof(PlateView).GetMethod("HoverCheck", flags)!;
var normalVisible = typeof(PlateView).GetField("showTooltip", flags)!;
hoverCheck.Invoke(run.View, null); // Fire the existing normal-tooltip timer deterministically.
Assert.True((bool)normalVisible.GetValue(run.View)!);
using var image = new Bitmap(300, 300);
using var graphics = Graphics.FromImage(image);
Assert.True(run.View.OverlapOverlay.DrawHover(graphics));
Assert.True((bool)normalVisible.GetValue(run.View)!);
run.View.OverlapDisplay = OverlapDisplayMode.Areas;
Assert.False(run.View.OverlapOverlay.DrawHover(graphics));
Assert.True((bool)normalVisible.GetValue(run.View)!); // PlateView now draws the original tooltip.
run.View.LeaveView();
hoverCheck.Invoke(run.View, null); // A queued timer cannot resurrect it after leave.
Assert.False((bool)normalVisible.GetValue(run.View)!);
});
[Theory]
[InlineData("leave")]
[InlineData("stale-move")]
[InlineData("stale-paint")]
[InlineData("invalidate")]
[InlineData("mode")]
[InlineData("zoom")]
[InlineData("pan")]
[InlineData("resize")]
[InlineData("plate")]
[InlineData("restart-cancel")]
[InlineData("recreate")]
public void HoverClearsBeforeStaleDetailsCanBeShown(string change) => RunSta(() =>
{
using var run = new OverlayRun();
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.Finish();
run.View.ZoomToPoint(new Vector(), 20);
run.View.SetOrigin(60, 220);
var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid);
run.View.MoveTo(center);
Assert.NotNull(run.View.OverlapOverlay.HoverText);
switch (change)
{
case "leave": run.View.LeaveView(); break;
case "stale-move":
run.View.Plate.Parts[0].Offset(1e-10, 0);
run.View.MoveTo(center);
break;
case "stale-paint":
run.View.Plate.Parts[0].Offset(1e-10, 0);
using (var image = new Bitmap(300, 300))
using (var graphics = Graphics.FromImage(image))
Assert.False(run.View.OverlapOverlay.DrawHover(graphics));
break;
case "invalidate": run.View.InvalidateOverlapCheck(); break;
case "mode": run.View.OverlapDisplay = OverlapDisplayMode.Centroids; break;
case "zoom": run.View.ZoomToPoint(new Vector(), 2); break;
case "pan": run.View.Pan(20, 10); break;
case "resize": run.View.Size = new System.Drawing.Size(300, 200); break;
case "plate": run.View.Plate = new Plate(); break;
case "restart-cancel":
var task = run.Start();
var work = run.Next();
Assert.Null(run.View.OverlapOverlay.HoverText);
run.View.CancelOverlapCheck();
work.Complete();
run.Pump(task);
break;
case "recreate": run.View.Recreate(); break;
}
Assert.Null(run.View.OverlapOverlay.HoverText);
});
[Fact]
public void CrowdedHoverPagesStayInViewportAndReachLastPairWithoutSelectionOrWheelCapture() => RunSta(() =>
{
using var run = new OverlayRun();
PrepareCrowdedHover(run);
var report = run.View.OverlapReport;
var positions = run.View.Plate.Parts.Select(p => p.Location).ToArray();
run.View.SelectAll();
var selection = run.View.SelectedParts.ToArray();
using var image = new Bitmap(run.View.Width, run.View.Height);
using var graphics = Graphics.FromImage(image);
var overlay = run.View.OverlapOverlay;
Assert.True(overlay.DrawHover(graphics));
var pages = overlay.HoverPages;
Assert.True(pages.PageCount > 1);
var content = new List<string>();
for (var page = 0; page < pages.PageCount; page++)
{
Assert.True(overlay.DrawHover(graphics));
Assert.True(new RectangleF(PointF.Empty, run.View.ClientSize).Contains(overlay.HoverBounds));
Assert.Equal(page, pages.PageIndex);
content.AddRange(pages.Lines);
Assert.Contains("PgUp/PgDn", string.Join("", pages.NavigationLines));
Assert.True(run.View.Command(Keys.PageDown));
}
Assert.Contains("Pair 11/12", string.Concat(content));
Assert.Contains(" in²", string.Concat(content));
Assert.Equal(overlay.HoverText.Replace("\n", ""), string.Concat(content));
Assert.Equal(pages.PageCount - 1, pages.PageIndex);
Assert.True(run.View.Command(Keys.PageUp));
Assert.Equal(pages.PageCount - 2, pages.PageIndex);
var lastIndex = pages.PageIndex;
Assert.False(run.View.Command(Keys.Control | Keys.PageDown));
Assert.Equal(lastIndex, pages.PageIndex);
run.View.MoveTo(run.View.PointWorldToControl(report.Pairs[0].Centroid));
Assert.Equal(lastIndex, pages.PageIndex); // Moving within the same hit group retains the page.
Assert.Equal(selection, run.View.SelectedParts);
Assert.Equal(positions, run.View.Plate.Parts.Select(p => p.Location));
Assert.Same(report, run.View.OverlapReport);
Assert.Equal(1, run.Calls);
var scale = run.View.ViewScale;
run.View.WheelAt(run.View.PointWorldToControl(report.Pairs[0].Centroid));
Assert.True(run.View.ViewScale > scale); // Wheel remains zoom, never paging.
Assert.Null(overlay.HoverText);
Assert.Null(overlay.HoverPages);
Assert.False(run.View.Command(Keys.PageDown));
Assert.Equal(selection, run.View.SelectedParts);
Assert.Equal(1, run.Calls);
});
[Fact]
public void MovingDirectlyToAnotherCoincidentGroupStartsItsFirstPage() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.Size = new System.Drawing.Size(300, 160);
run.View.Plate.Parts.Clear();
for (var i = 0; i < 12; i++)
run.View.Plate.Parts.Add(Rectangle(i < 6 ? 0 : 8));
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.Finish();
run.View.ZoomToPoint(new Vector(), 15);
run.View.SetOrigin(60, 120);
run.View.MoveTo(run.View.PointWorldToControl(new Vector(2, 2)));
using var image = new Bitmap(300, 160);
using var graphics = Graphics.FromImage(image);
var overlay = run.View.OverlapOverlay;
Assert.True(overlay.DrawHover(graphics));
Assert.True(run.View.Command(Keys.PageDown));
Assert.Equal(1, overlay.HoverPages.PageIndex);
run.View.MoveTo(run.View.PointWorldToControl(new Vector(10, 2)));
Assert.Null(overlay.HoverPages);
Assert.True(overlay.DrawHover(graphics));
Assert.NotNull(overlay.HoverPages);
Assert.Equal(0, overlay.HoverPages.PageIndex);
Assert.Contains("Pair 7/8", string.Concat(overlay.HoverPages.Lines));
Assert.Equal(1, run.Calls);
});
[Theory]
[InlineData("hover")]
[InlineData("leave")]
[InlineData("mode")]
[InlineData("stale-key")]
[InlineData("report")]
[InlineData("resize")]
[InlineData("font")]
[InlineData("click")]
[InlineData("drag")]
public void PagingResetsWithHoverLifetimeAndInactiveKeysAreNotConsumed(string change) => RunSta(() =>
{
using var run = new OverlayRun();
Assert.False(run.View.Command(Keys.PageDown));
PrepareCrowdedHover(run);
using var image = new Bitmap(run.View.Width, run.View.Height);
using var graphics = Graphics.FromImage(image);
var overlay = run.View.OverlapOverlay;
Assert.True(overlay.DrawHover(graphics));
Assert.True(run.View.Command(Keys.PageDown));
Assert.Equal(1, overlay.HoverPages.PageIndex);
var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid);
using var largerFont = new Font(run.View.Font.FontFamily, run.View.Font.Size + 1);
switch (change)
{
case "hover": run.View.MoveTo(new Point(5, 5)); break;
case "leave": run.View.LeaveView(); break;
case "mode": run.View.OverlapDisplay = OverlapDisplayMode.Areas; break;
case "stale-key":
run.View.Plate.Parts[0].Offset(1e-10, 0);
Assert.False(run.View.Command(Keys.PageDown));
break;
case "report": run.Finish(); break;
case "resize": run.View.Width += 10; break;
case "font": run.View.Font = largerFont; break;
case "click":
run.View.ClickAt(center);
Assert.NotEmpty(run.View.SelectedParts);
break;
case "drag": run.View.DragAt(center); break;
}
Assert.Null(overlay.HoverText);
Assert.Null(overlay.HoverPages);
Assert.False(run.View.Command(Keys.PageDown));
if (change == "stale-key")
run.Finish();
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.View.MoveTo(center);
Assert.True(overlay.DrawHover(graphics));
Assert.NotNull(overlay.HoverPages);
Assert.Equal(0, overlay.HoverPages.PageIndex);
});
[Fact]
public void LongNameAndTinyViewNeverSilentlyElideTheOnlyPair() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.Size = new System.Drawing.Size(150, 120);
run.View.Plate.Parts[0].BaseDrawing.Name = new string('W', 250);
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.Finish();
run.View.ZoomToPoint(new Vector(), 10);
run.View.SetOrigin(30, 90);
var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid);
run.View.MoveTo(center);
using var image = new Bitmap(150, 120);
using var graphics = Graphics.FromImage(image);
var overlay = run.View.OverlapOverlay;
Assert.True(overlay.DrawHover(graphics));
Assert.False(overlay.HoverPages.NeedsLargerViewport);
Assert.True(overlay.HoverPages.PageCount > 1);
var content = new List<string>();
for (var i = 0; i < overlay.HoverPages.PageCount; i++)
{
Assert.True(overlay.DrawHover(graphics));
Assert.True(new RectangleF(PointF.Empty, run.View.ClientSize).Contains(overlay.HoverBounds));
content.AddRange(overlay.HoverPages.Lines);
run.View.Command(Keys.PageDown);
}
Assert.Equal(overlay.HoverText.Replace("\n", ""), string.Concat(content));
run.View.Size = new System.Drawing.Size(40, 20);
overlay.UpdateHover(run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid));
Assert.True(overlay.DrawHover(graphics));
Assert.True(overlay.HoverPages.NeedsLargerViewport);
Assert.False(run.View.Command(Keys.PageDown));
});
private static void PrepareCrowdedHover(OverlayRun run)
{
run.View.Size = new System.Drawing.Size(260, 160);
run.View.Plate.Parts.Clear();
for (var i = 0; i < 12; i++)
run.View.Plate.Parts.Add(Rectangle());
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.Finish();
run.View.ZoomToPoint(new Vector(), 15);
run.View.SetOrigin(60, 120);
run.View.MoveTo(run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid));
}
// These waits are deliberate: only Task.Run workers use the result gate; the test's
// STA waits for capture delivery, then pumps every UI continuation explicitly.
private static PlateOverlapReport WaitForWorkerResult(Task<PlateOverlapReport> task) => task.GetAwaiter().GetResult();
private static PlateOverlapSnapshot WaitForSnapshot(Task<PlateOverlapSnapshot> task)
{
Assert.True(task.Wait(TimeSpan.FromSeconds(15)));
return task.GetAwaiter().GetResult();
}
private static ToolStripMenuItem Menu(MainForm host, string name) =>
(ToolStripMenuItem)host.MainMenuStrip!.Items.Find(name, true).Single();
@@ -335,6 +804,20 @@ public class PlateOverlapOverlayTests
public void Pan(float x, float y) { origin.X += x; origin.Y += y; }
public void SetOrigin(float x, float y) => origin = new PointF(x, y);
public void Recreate() => RecreateHandle();
public void MoveTo(Point point) => OnMouseMove(new MouseEventArgs(MouseButtons.None, 0, point.X, point.Y, 0));
public void LeaveView() => OnMouseLeave(EventArgs.Empty);
public bool Command(Keys keys)
{
var message = new Message();
return ProcessCmdKey(ref message, keys);
}
public void WheelAt(Point point) => OnMouseWheel(new MouseEventArgs(MouseButtons.None, 0, point.X, point.Y, 120));
public void DragAt(Point point) => OnMouseMove(new MouseEventArgs(MouseButtons.Left, 0, point.X, point.Y, 0));
public void ClickAt(Point point)
{
OnMouseDown(new MouseEventArgs(MouseButtons.Left, 1, point.X, point.Y, 0));
OnMouseUp(new MouseEventArgs(MouseButtons.Left, 1, point.X, point.Y, 0));
}
}
private sealed class OverlayRun : IDisposable
@@ -342,6 +825,9 @@ public class PlateOverlapOverlayTests
private readonly PumpContext context = new();
private readonly SynchronizationContext? previous;
private readonly BlockingCollection<Work> work = new();
private readonly List<Work> outstanding = new();
private readonly List<Task> tasks = new();
private bool disposed;
public TestView View { get; } = new();
public int Calls;
@@ -356,12 +842,24 @@ public class PlateOverlapOverlayTests
{
Interlocked.Increment(ref Calls);
var request = new Work(snapshot, token);
work.Add(request);
lock (outstanding)
{
outstanding.Add(request);
if (disposed)
request.Release();
else
work.Add(request);
}
return request.WaitForResult();
};
}
public Task Start() => View.CheckOverlapsAsync();
public Task Start()
{
var task = View.CheckOverlapsAsync(Units.Inches);
tasks.Add(task);
return task;
}
public Work Next()
{
Assert.True(work.TryTake(out var result, TimeSpan.FromSeconds(15)), "Worker did not start.");
@@ -376,9 +874,27 @@ public class PlateOverlapOverlayTests
}
public void Dispose()
{
View.Dispose();
SynchronizationContext.SetSynchronizationContext(previous);
work.Dispose();
if (disposed)
return;
try
{
View.Dispose();
}
finally
{
lock (outstanding)
{
disposed = true;
foreach (var request in outstanding)
request.Release();
}
try { context.Pump(Task.WhenAll(tasks)); }
finally
{
SynchronizationContext.SetSynchronizationContext(previous);
work.Dispose();
}
}
}
}
@@ -390,6 +906,9 @@ public class PlateOverlapOverlayTests
public PlateOverlapReport WaitForResult() => Result.Task.GetAwaiter().GetResult();
public void Complete() => Result.SetResult(PlateOverlapAnalyzer.Analyze(snapshot));
public void Fail() => Result.SetException(new InvalidOperationException("test failure"));
// Cancellation is deliberately ignored while testing late completions. Cleanup alone
// releases every gate, including requests already taken from the pending queue.
public void Release() => Result.TrySetCanceled();
}
private sealed class PumpContext : SynchronizationContext
+192 -11
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
@@ -7,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using OpenNest.Collections;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Controls;
@@ -23,6 +25,12 @@ internal sealed class OverlapOverlayController : IDisposable
private GraphicsPath path;
private PlateOverlapReport pathReport;
private float pathScale;
private Units capturedUnits;
private IReadOnlyList<PlateOverlapPair> hoveredPairs = Array.Empty<PlateOverlapPair>();
private string hoverText;
private OverlapHoverPages hoverPages;
private Point hoverPoint;
private (float Scale, PointF Offset, int Dpi) hoverTransform;
private bool disposed;
public OverlapOverlayController(PlateView view) => this.view = view;
@@ -36,6 +44,8 @@ internal sealed class OverlapOverlayController : IDisposable
internal Func<PlateOverlapSnapshot, CancellationToken, PlateOverlapReport> Analyze { get; set; }
= PlateOverlapAnalyzer.Analyze;
internal GraphicsPath CachedPath => path;
internal OverlapHoverPages HoverPages => hoverPages;
internal RectangleF HoverBounds { get; private set; }
public OverlapDisplayMode DisplayMode
{
@@ -65,7 +75,7 @@ internal sealed class OverlapOverlayController : IDisposable
NotifyChanged();
}
public async Task CheckAsync()
public async Task CheckAsync(Units units)
{
if (!CanUseView || view.Plate == null)
return;
@@ -75,6 +85,7 @@ internal sealed class OverlapOverlayController : IDisposable
CancelWorker();
var plate = view.Plate;
var generation = state.Begin(plate);
capturedUnits = units; // Same request boundary as the owned geometry/names, never read live units in paint.
var source = new CancellationTokenSource();
cancellation = source;
var token = source.Token;
@@ -162,6 +173,7 @@ internal sealed class OverlapOverlayController : IDisposable
private void NotifyChanged()
{
ClearHover();
if (disposed || view.IsDisposed || view.Disposing)
return;
view.Status = state.Message;
@@ -187,26 +199,194 @@ internal sealed class OverlapOverlayController : IDisposable
{
if (disposed)
return;
EnsureFresh();
ValidateHoverTransform();
if (state.Report != null)
{
if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both)
{
EnsurePath();
if (path.PointCount > 0)
{
using var brush = new SolidBrush(Color.FromArgb(100, 255, 0, 80));
// A single winding fill is the union: triple overlap is not painted darker.
graphics.FillPath(brush, path);
}
}
if (ShowsCentroids)
DrawCentroids(graphics);
}
DrawStateLabel(graphics);
}
private bool EnsureFresh()
{
if (disposed)
return false;
var generation = state.Generation;
state.EnsureFresh(view.Plate);
var fresh = state.EnsureFresh(view.Plate);
if (generation != state.Generation)
{
CancelWorker();
ReleasePath();
NotifyChanged();
}
return fresh;
}
if (state.DisplayMode == OverlapDisplayMode.Areas && state.Report != null)
private bool ShowsCentroids => state.DisplayMode is OverlapDisplayMode.Centroids or OverlapDisplayMode.Both;
private (float, PointF, int) ViewTransform => (view.ViewScale, view.PointControlToGraph(Point.Empty), view.DeviceDpi);
internal string HoverText
{
get
{
EnsurePath();
if (path.PointCount > 0)
{
using var brush = new SolidBrush(Color.FromArgb(100, 255, 0, 80));
// A single winding fill is the union: triple overlap is not painted darker.
graphics.FillPath(brush, path);
}
if (!EnsureFresh())
ClearHover();
ValidateHoverTransform();
return hoverText;
}
DrawStateLabel(graphics);
}
public void UpdateHover(Point point)
{
ValidateHoverTransform();
if (!EnsureFresh() || !ShowsCentroids || state.Report == null)
{
ClearHover();
return;
}
// Graph coordinates are already screen-scaled; remove pan from the pointer once.
var graphPoint = view.PointControlToGraph(point);
var hits = OverlapPairPresentation.HitTest(state.Report.Pairs, world =>
{
var graph = view.PointWorldToGraph(world);
return new Vector(graph.X, graph.Y);
}, new Vector(graphPoint.X, graphPoint.Y), view.DeviceDpi);
var changed = !hits.SequenceEqual(hoveredPairs);
var moved = point != hoverPoint;
hoveredPairs = hits;
hoverPoint = point;
hoverTransform = ViewTransform;
if (changed)
{
hoverPages = null;
HoverBounds = RectangleF.Empty;
hoverText = hits.Count == 0 ? null : string.Join("\n\n",
hits.Select(pair => OverlapPairPresentation.Details(pair, capturedUnits)));
}
if (changed || (moved && hoverText != null))
view.Invalidate();
}
private void ValidateHoverTransform()
{
if (hoverText != null && hoverTransform != ViewTransform)
ClearHover();
}
public void ClearHover()
{
var visible = hoverText != null;
hoverText = null;
hoverPages = null;
HoverBounds = RectangleF.Empty;
hoveredPairs = Array.Empty<PlateOverlapPair>();
if (visible && !disposed && !view.IsDisposed && !view.Disposing)
view.Invalidate();
}
public bool TryPageHover(int delta)
{
// Revalidate even when a key arrives before the next paint/mouse event.
if (HoverText == null || hoverPages == null || hoverPages.PageCount < 2)
return false;
hoverPages.MovePage(delta);
view.Invalidate();
return true;
}
private void DrawCentroids(Graphics graphics)
{
var dpiScale = view.DeviceDpi / 96f;
var radius = (float)OverlapPairPresentation.MarkerHalfSize(view.DeviceDpi);
using var halo = new Pen(Color.White, 4 * dpiScale);
using var crosshair = new Pen(Color.DarkRed, 2 * dpiScale);
// Stack coincident pair labels instead of replacing them with a fragment count.
var labelRows = new Dictionary<PointF, int>();
foreach (var pair in state.Report.Pairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId))
{
var center = view.PointWorldToGraph(pair.Centroid);
if (!float.IsFinite(center.X) || !float.IsFinite(center.Y))
continue;
foreach (var pen in new[] { halo, crosshair })
{
graphics.DrawLine(pen, center.X - radius, center.Y, center.X + radius, center.Y);
graphics.DrawLine(pen, center.X, center.Y - radius, center.X, center.Y + radius);
}
var label = OverlapPairPresentation.Label(pair);
var size = graphics.MeasureString(label, view.Font);
labelRows.TryGetValue(center, out var row);
labelRows[center] = row + 1;
var x = center.X + radius + 3 * dpiScale;
var y = center.Y - radius + row * size.Height;
graphics.FillRectangle(Brushes.White, x, y, size.Width, size.Height);
graphics.DrawString(label, view.Font, Brushes.DarkRed, x, y);
}
}
// Draw last, over action adorners. The normal part tooltip is suppressed only while
// these details are actually visible; its independent timer/state is not stolen.
public bool DrawHover(Graphics graphics)
{
var text = HoverText; // Includes a fresh stamp check even if no mouse move/paint preceded this.
if (text == null)
return false;
var saved = graphics.Save();
try
{
graphics.ResetTransform();
graphics.SetClip(view.ClientRectangle, CombineMode.Intersect);
var scale = view.DeviceDpi / 96f;
var padding = 4 * scale;
var maxWidth = System.Math.Min(560 * scale, view.ClientSize.Width - 2) - padding * 2;
var lineHeight = (float)System.Math.Ceiling(view.Font.GetHeight(graphics));
var maxRows = (int)((view.ClientSize.Height - 2 - padding * 2) / lineHeight);
using var format = (StringFormat)StringFormat.GenericTypographic.Clone();
format.FormatFlags |= StringFormatFlags.NoWrap | StringFormatFlags.MeasureTrailingSpaces;
float Measure(string line) => graphics.MeasureString(line, view.Font, PointF.Empty, format).Width;
hoverPages ??= OverlapHoverPages.Create(text, hoveredPairs.Count, maxRows, maxWidth, line => Measure(line));
if (hoverPages.NeedsLargerViewport)
{
// A viewport smaller than a content line plus its paging hint cannot
// show details legibly. Say so rather than presenting clipped data.
HoverBounds = new RectangleF(0, 0, view.ClientSize.Width, view.ClientSize.Height);
graphics.FillRectangle(Brushes.White, HoverBounds);
graphics.DrawString("Enlarge view to read overlap details", view.Font, Brushes.DarkRed, HoverBounds);
return true;
}
var lines = hoverPages.Lines.Concat(hoverPages.NavigationLines).ToArray();
var width = lines.Max(Measure) + padding * 2;
var height = lines.Length * lineHeight + padding * 2;
var x = System.Math.Max(0, System.Math.Min(hoverPoint.X + 16 * scale, view.ClientSize.Width - width));
var y = hoverPoint.Y - height - 6 * scale;
if (y < 0)
y = System.Math.Max(0, System.Math.Min(hoverPoint.Y + 20 * scale, view.ClientSize.Height - height));
HoverBounds = new RectangleF(x, y, width, height);
graphics.FillRectangle(Brushes.White, x, y, width, height);
graphics.DrawRectangle(Pens.DimGray, x, y, width, height);
for (var row = 0; row < lines.Length; row++)
graphics.DrawString(lines[row], view.Font,
row < hoverPages.Lines.Count ? Brushes.Black : Brushes.DarkRed,
new PointF(x + padding, y + padding + row * lineHeight), format);
}
finally
{
graphics.Restore(saved);
}
return true;
}
private void EnsurePath()
@@ -283,5 +463,6 @@ internal sealed class OverlapOverlayController : IDisposable
CancelWorker();
state.Reset();
ReleasePath();
ClearHover();
}
}
+66 -7
View File
@@ -41,6 +41,7 @@ namespace OpenNest.Controls
private LayoutPart hoveredPart;
private Point hoverPoint;
private bool showTooltip;
private bool hoverPending;
private Timer hoverTimer;
public Box ActiveWorkArea
@@ -229,7 +230,7 @@ namespace OpenNest.Controls
add => overlapOverlay.StateChanged += value;
remove => overlapOverlay.StateChanged -= value;
}
public Task CheckOverlapsAsync() => overlapOverlay.CheckAsync();
public Task CheckOverlapsAsync(Units units) => overlapOverlay.CheckAsync(units);
public void CancelOverlapCheck() => overlapOverlay.Cancel();
public void InvalidateOverlapCheck() => overlapOverlay.Invalidate();
@@ -250,6 +251,40 @@ namespace OpenNest.Controls
base.OnMouseEnter(e);
}
protected override void OnMouseLeave(EventArgs e)
{
ClearHover();
base.OnMouseLeave(e);
}
private void ClearHover()
{
overlapOverlay?.ClearHover();
hoverTimer?.Stop();
hoverPending = false;
hoveredPart = null;
showTooltip = false;
Invalidate();
}
protected override void OnResize(EventArgs e)
{
ClearHover();
base.OnResize(e);
}
protected override void OnDpiChangedAfterParent(EventArgs e)
{
ClearHover();
base.OnDpiChangedAfterParent(e);
}
protected override void OnFontChanged(EventArgs e)
{
ClearHover();
base.OnFontChanged(e);
}
protected override void OnDragEnter(DragEventArgs drgevent)
{
if (drgevent.Data.GetData(typeof(Drawing)) != null)
@@ -271,6 +306,7 @@ namespace OpenNest.Controls
protected override void OnMouseDown(MouseEventArgs e)
{
ClearHover();
if (!Focused)
Focus();
@@ -359,6 +395,8 @@ namespace OpenNest.Controls
protected override void OnMouseMove(MouseEventArgs e)
{
if (e.Button != MouseButtons.None)
ClearHover();
if (e.Button == MouseButtons.Middle)
{
if (AllowPan)
@@ -389,19 +427,18 @@ namespace OpenNest.Controls
if (e.Button == MouseButtons.None && actionManager.CurrentAction is ActionSelect)
{
hoverPoint = e.Location;
hoverPending = true;
showTooltip = false;
hoverTimer.Stop();
hoverTimer.Start();
if (hoveredPart != null)
Invalidate();
overlapOverlay.UpdateHover(e.Location);
}
else if (hoveredPart != null || showTooltip)
else
{
hoveredPart = null;
hoverTimer.Stop();
showTooltip = false;
Invalidate();
ClearHover();
}
base.OnMouseMove(e);
@@ -415,6 +452,18 @@ namespace OpenNest.Controls
ZoomToFit();
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
// Audited: DrawControl/PlateView, Action key handlers, EditNestForm,
// MainForm.ProcessCmdKey and menu shortcuts have no plain PgUp/PgDn
// binding. BestFitViewerForm owns these only in its separate dialog.
// No modifier keys, clicks, or wheel events are consumed by hover paging.
if ((keyData == Keys.PageUp || keyData == Keys.PageDown)
&& overlapOverlay.TryPageHover(keyData == Keys.PageUp ? -1 : 1))
return true;
return base.ProcessCmdKey(ref msg, keyData);
}
protected override void OnKeyDown(KeyEventArgs e)
{
switch (e.KeyCode)
@@ -517,7 +566,7 @@ namespace OpenNest.Controls
base.OnPaint(e);
if (hoveredPart != null && showTooltip)
if (!overlapOverlay.DrawHover(e.Graphics) && hoveredPart != null && showTooltip)
{
e.Graphics.ResetTransform();
var text = hoveredPart.BasePart.BaseDrawing.Name;
@@ -542,6 +591,7 @@ namespace OpenNest.Controls
protected override void OnHandleDestroyed(EventArgs e)
{
ClearHover();
overlapOverlay.ReleaseHandle();
base.OnHandleDestroyed(e);
actionManager.Cleanup();
@@ -550,7 +600,12 @@ namespace OpenNest.Controls
protected override void Dispose(bool disposing)
{
if (disposing)
{
overlapOverlay?.Dispose();
hoverTimer?.Dispose();
hoverTimer = null;
redrawTimer?.Dispose();
}
base.Dispose(disposing);
}
@@ -706,6 +761,9 @@ namespace OpenNest.Controls
private void HoverCheck()
{
if (!hoverPending || IsDisposed || Disposing)
return;
hoverPending = false;
var graphPt = PointControlToGraph(hoverPoint);
LayoutPart hitPart = null;
@@ -793,6 +851,7 @@ namespace OpenNest.Controls
protected override void UpdateMatrix()
{
ClearHover();
base.UpdateMatrix();
parts.ForEach(p => p.Update(this));
previewManager.Update();
+1 -1
View File
@@ -28,7 +28,7 @@ namespace OpenNest.Forms
public readonly Document Document;
public readonly PlateView PlateView;
public System.Threading.Tasks.Task CheckOverlapsAsync() => PlateView.CheckOverlapsAsync();
public System.Threading.Tasks.Task CheckOverlapsAsync() => PlateView.CheckOverlapsAsync(Nest.Units);
public void CancelOverlapCheck() => PlateView.CancelOverlapCheck();
public OverlapDisplayMode OverlapDisplay
{
+17 -1
View File
@@ -54,6 +54,8 @@
mnuOverlapDisplay = new System.Windows.Forms.ToolStripMenuItem();
mnuOverlapOff = new System.Windows.Forms.ToolStripMenuItem();
mnuOverlapAreas = new System.Windows.Forms.ToolStripMenuItem();
mnuOverlapCentroids = new System.Windows.Forms.ToolStripMenuItem();
mnuOverlapBoth = new System.Windows.Forms.ToolStripMenuItem();
mnuViewDrawRapids = new System.Windows.Forms.ToolStripMenuItem();
mnuViewDrawPiercePoints = new System.Windows.Forms.ToolStripMenuItem();
mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem();
@@ -353,7 +355,7 @@
//
mnuOverlapDisplay.Name = "mnuOverlapDisplay";
mnuOverlapDisplay.Text = "Display";
mnuOverlapDisplay.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuOverlapOff, mnuOverlapAreas });
mnuOverlapDisplay.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuOverlapOff, mnuOverlapAreas, mnuOverlapCentroids, mnuOverlapBoth });
mnuOverlapDisplay.DropDownOpening += OverlapMenu_Opening;
//
// mnuOverlapOff
@@ -367,6 +369,18 @@
mnuOverlapAreas.Name = "mnuOverlapAreas";
mnuOverlapAreas.Text = "Areas";
mnuOverlapAreas.Click += OverlapAreas_Click;
//
// mnuOverlapCentroids
//
mnuOverlapCentroids.Name = "mnuOverlapCentroids";
mnuOverlapCentroids.Text = "Centroids";
mnuOverlapCentroids.Click += OverlapCentroids_Click;
//
// mnuOverlapBoth
//
mnuOverlapBoth.Name = "mnuOverlapBoth";
mnuOverlapBoth.Text = "Both";
mnuOverlapBoth.Click += OverlapBoth_Click;
//
// mnuViewDrawRapids
//
@@ -1292,6 +1306,8 @@
private System.Windows.Forms.ToolStripMenuItem mnuOverlapDisplay;
private System.Windows.Forms.ToolStripMenuItem mnuOverlapOff;
private System.Windows.Forms.ToolStripMenuItem mnuOverlapAreas;
private System.Windows.Forms.ToolStripMenuItem mnuOverlapCentroids;
private System.Windows.Forms.ToolStripMenuItem mnuOverlapBoth;
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawRapids;
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawPiercePoints;
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawBounds;
+16
View File
@@ -675,8 +675,12 @@ namespace OpenNest.Forms
mnuOverlapDisplay.Enabled = hasPlate;
mnuOverlapOff.Enabled = hasPlate;
mnuOverlapAreas.Enabled = hasPlate;
mnuOverlapCentroids.Enabled = hasPlate;
mnuOverlapBoth.Enabled = hasPlate;
mnuOverlapOff.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Off;
mnuOverlapAreas.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Areas;
mnuOverlapCentroids.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Centroids;
mnuOverlapBoth.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Both;
}
private async void CheckOverlaps_Click(object sender, EventArgs e)
@@ -700,6 +704,18 @@ namespace OpenNest.Forms
activeForm.OverlapDisplay = OverlapDisplayMode.Areas;
}
private void OverlapCentroids_Click(object sender, EventArgs e)
{
if (activeForm != null)
activeForm.OverlapDisplay = OverlapDisplayMode.Centroids;
}
private void OverlapBoth_Click(object sender, EventArgs e)
{
if (activeForm != null)
activeForm.OverlapDisplay = OverlapDisplayMode.Both;
}
private void ToggleDrawRapids_Click(object sender, EventArgs e)
{
if (activeForm == null)
+1 -1
View File
@@ -12,7 +12,7 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges
- **Import / export** — DXF & DWG parts (ACadSharp), Excel BOMs, bend-line detection, built-in parametric shapes; export DXF or post-processed G-code.
- **Nesting** — pluggable whole-job engines (Default, Strip, Vertical/Horizontal Remnant, StockLadder, plus DLL plugins), NFP-based interlocking pair evaluation, gravity compaction, rotation sweeps, multi-plate/multi-material jobs.
- **Plate operations** — manual sheet cut-offs, [automatic scrap cutoffs with unused-tail preservation](docs/automatic-scrap-cutoffs.md), oversized-part splitting (straight, weld-gap tabs, spike-groove), interactive editing, and spacing-aware pushes that can slide along or away from touching parts.
- **Visual overlap check** — manually highlight shared material on the active plate, including containment and cutouts, through View > Overlap Check. [Usage and limitations](docs/geometry/visual-overlap-check.md).
- **Visual overlap check** — manually highlight shared material on the active plate, including containment and cutouts, with area shading, pair centroids, and hover details through View > Overlap Check. [Usage and limitations](docs/geometry/visual-overlap-check.md).
- **CNC output** — configurable lead-ins/outs and tabs, contour editing, user-defined G-code variables (`$name` → `#200+` machine variables), plugin post-processors (Cincinnati CL-707/800/900/940/CLX included).
## Requirements
+47 -5
View File
@@ -6,8 +6,25 @@ Choose **View > Overlap Check > Check Active Plate** to check committed parts on
this plate. Shared material is shaded red/magenta without changing the nest,
selection, cutting paths, or export behavior. Cutoffs and temporary preview parts
are excluded. **Cancel Check** discards the running request. **Display > Off /
Areas** changes visibility without rerunning analysis; checking from Off shows
Areas. Display preferences belong to the current document and are not saved.
Areas / Centroids / Both** changes visibility without rerunning analysis. Areas
is the default; rechecking preserves a visible mode, while checking from Off
shows Areas. Display preferences belong to the current document and are not saved.
Centroids and Both show fixed-screen-size, DPI-scaled pair crosshairs with a
contrasting halo. Labels such as `1/3` identify the two plate sequence positions
at capture time, including gaps occupied by cutoffs. Hover near a marker to see
captured names, approximate shared area in in² or mm², and centroid coordinates
in the same linear units. Units are captured with the request; hover does not
relabel an old report from live settings. Nearby/coincident markers show all
matching pairs in sequence order. If details exceed the view, the tooltip wraps
and pages them with a `Page x/y` hint: keep the pointer near the marker and use
plain **PageUp / PageDown** while PlateView has focus. Long individual details
continue across pages without truncation; an impossibly small viewport asks you
to enlarge it. These keys act only while a multipage diagnostic tooltip is
visible; wheel zoom, modified keys, selection clicks, and dragging are unchanged.
Small positive areas use significant-figure formatting rather than rounding to
zero. Selection and dragging remain ordinary plate operations, not overlay
interactions.
A persistent label distinguishes unchecked, checking, current, incomplete, stale,
canceled, and failed checks. Only a completed, current, fully checked report can
@@ -41,7 +58,7 @@ var areas = report.Pairs.SelectMany(pair => pair.Regions).ToList();
foreach (var pair in report.Pairs)
{
// IDs are zero-based positions in the original input list, including skipped cutoffs.
Console.WriteLine($"{pair.PartAId} / {pair.PartBId}: {pair.Area}");
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
@@ -129,6 +146,20 @@ seams as physical boundaries. 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
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
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,
degenerate, or nonfinite moments produce an incomplete pair issue, not a marker
at zero. Curved-outline centroids inherit the polygonization approximation.
Full containment and coincident parts are detected without relying on crossing
points. Edge/corner contact with no positive shared material is not overlap.
There are no spacing offsets, plate-edge checks, cut-path crossing checks, automatic
@@ -165,6 +196,10 @@ 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 report/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
part-name tooltip only while visible.
Next hardening: measure real-plate capture/analysis cost and cancellation latency
before adding cached triangulations or background capture. Cancellation cannot
@@ -180,11 +215,17 @@ inputs, snapshot isolation, read-only output, cutting-program independence, and
cancellation. Run:
```sh
dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~PlateOverlapAnalyzerTests|FullyQualifiedName~OverlapReportStateTests'
dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~PlateOverlapAnalyzerTests|FullyQualifiedName~OverlapReportStateTests|FullyQualifiedName~PolygonAreaMomentsTests|FullyQualifiedName~OverlapPairPresentationTests|FullyQualifiedName~OverlapHoverPagesTests'
```
`OverlapReportStateTests` verifies request supersession, exact pose/reference
freshness, stale clearing, 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
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.
`OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs` adds STA worker/publication,
menu/MDI, path-cache, uniform-fill pixel, and control-lifetime checks. Run those
on Windows:
@@ -197,4 +238,5 @@ Linux can cross-build with `-p:EnableWindowsTargeting=true`, but that does not
execute Windows tests or verify appearance, DPI, or interaction. On Windows,
check partial overlap, containment, inside-hole placement, pan/zoom and quadrant
alignment, stale clearing during edits/plate switches, converter cancellation,
and repeated check/toggle/close cycles without GDI/disposed-control errors.
crowded-marker PageUp/PageDown access to the last pair, and repeated
check/toggle/close cycles without GDI/disposed-control errors.