From a8204bb51f9ec4783827d006bc43d06ea6cb202d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 28 Sep 2026 22:17:49 -0400 Subject: [PATCH] feat(plateview): annotate overlap centroids --- .../Diagnostics/OverlapHoverPages.cs | 120 ++++ .../Diagnostics/OverlapPairPresentation.cs | 46 ++ .../Diagnostics/OverlapReportState.cs | 5 +- .../Diagnostics/PlateOverlapAnalyzer.cs | 26 +- .../Diagnostics/PlateOverlapReport.cs | 8 +- OpenNest.Core/Geometry/PolygonAreaMoments.cs | 101 ++++ .../Diagnostics/OverlapHoverPagesTests.cs | 130 +++++ .../OverlapPairPresentationTests.cs | 78 +++ .../Diagnostics/OverlapReportStateTests.cs | 16 + .../Diagnostics/PlateOverlapAnalyzerTests.cs | 86 ++- .../Geometry/PolygonAreaMomentsTests.cs | 141 +++++ .../PlateOverlapOverlayTests.cs | 531 +++++++++++++++++- OpenNest/Controls/OverlapOverlayController.cs | 203 ++++++- OpenNest/Controls/PlateView.cs | 73 ++- OpenNest/Forms/EditNestForm.cs | 2 +- OpenNest/Forms/MainForm.Designer.cs | 18 +- OpenNest/Forms/MainForm.cs | 16 + README.md | 2 +- docs/geometry/visual-overlap-check.md | 52 +- 19 files changed, 1608 insertions(+), 46 deletions(-) create mode 100644 OpenNest.Core/Diagnostics/OverlapHoverPages.cs create mode 100644 OpenNest.Core/Diagnostics/OverlapPairPresentation.cs create mode 100644 OpenNest.Core/Geometry/PolygonAreaMoments.cs create mode 100644 OpenNest.Tests/Diagnostics/OverlapHoverPagesTests.cs create mode 100644 OpenNest.Tests/Diagnostics/OverlapPairPresentationTests.cs create mode 100644 OpenNest.Tests/Geometry/PolygonAreaMomentsTests.cs 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 InvalidPolygons() + { + yield return new object[] { Array.Empty() }; + 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(), 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) }; +} diff --git a/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs index 3085008..b9d668f 100644 --- a/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs +++ b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs @@ -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(TaskCreationOptions.RunContinuationsAsynchronously); + var result = new TaskCompletionSource(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()) + { + 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(); + 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(); + 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 task) => task.GetAwaiter().GetResult(); + + private static PlateOverlapSnapshot WaitForSnapshot(Task 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 = new(); + private readonly List outstanding = new(); + private readonly List 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 diff --git a/OpenNest/Controls/OverlapOverlayController.cs b/OpenNest/Controls/OverlapOverlayController.cs index 61ef2cd..e858d7f 100644 --- a/OpenNest/Controls/OverlapOverlayController.cs +++ b/OpenNest/Controls/OverlapOverlayController.cs @@ -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 hoveredPairs = Array.Empty(); + 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 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(); + 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(); + 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(); } } diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index 7fb35ac..ba29848 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -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(); diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index c8f86f0..d78f729 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -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 { diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index 3ff9260..1cd8c3b 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -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; diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index fb3736a..17e759f 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -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) diff --git a/README.md b/README.md index d14130a..838734a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md index 2e013e0..a0b0970 100644 --- a/docs/geometry/visual-overlap-check.md +++ b/docs/geometry/visual-overlap-check.md @@ -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.