From 80d01839bdc60f9fa292d566d65775af00b191ea Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 14 Oct 2026 17:44:49 -0400 Subject: [PATCH] feat(diagnostics): place overlap markers on the shared material The overlap crosshair sat on the pair's area centroid. For a split, holed or concave overlap, that point can be off the red material: between two patches, in a hole, or inside a curve. PlateOverlapPair.Marker is a point inside the pair's largest island. It is the island's pole of inaccessibility (PolyLabel), searched to a tenth of the island's area/perimeter within a 4096-cell budget. If that bounded search does not land inside (a long thin sliver), the marker falls back to the midpoint of the widest interior run of a horizontal scan line placed between vertex heights. The overlay crosshair, marker hit testing, hover coordinates ("Marker:") and the cutting-inspection ring now use the marker. Centroid stays the true area centroid. View > Overlap Check > Display > Centroids is now labelled Markers. Before the marker existed, its call sites used the centroid. Two patches, a covered frame, a half annulus and a thin crescent then put it off the material; now all four land on it. Disabling the scan-line fallback fails its test. On the 389-plate corpus, markers add about 0.4 ms per overlapping pair; pair and area results are unchanged. --- OpenNest.Core/Diagnostics/OverlapMarker.cs | 119 ++++++++++++++++++ .../Diagnostics/OverlapPairPresentation.cs | 6 +- .../Diagnostics/PlateOverlapAnalyzer.cs | 25 ++-- .../Diagnostics/PlateOverlapReport.cs | 11 +- .../Diagnostics/PostVerificationAnalyzer.cs | 4 +- .../IncrementalOverlapAnalysisTests.cs | 5 +- .../Diagnostics/OverlapHoverPagesTests.cs | 2 +- .../Diagnostics/OverlapMarkerTests.cs | 48 +++++++ .../OverlapPairPresentationTests.cs | 4 +- .../Diagnostics/PlateOverlapAnalyzerTests.cs | 60 +++++++++ .../Forms/CuttingValidationPanelTests.cs | 2 +- .../PlateOverlapOverlayTests.cs | 29 +++-- OpenNest/Controls/OverlapOverlayController.cs | 2 +- OpenNest/Forms/CuttingInspectionPanel.cs | 2 +- OpenNest/Forms/MainForm.Designer.cs | 2 +- README.md | 2 +- docs/geometry/visual-overlap-check.md | 38 ++++-- docs/post-verification.md | 4 +- 18 files changed, 315 insertions(+), 50 deletions(-) create mode 100644 OpenNest.Core/Diagnostics/OverlapMarker.cs create mode 100644 OpenNest.Tests/Diagnostics/OverlapMarkerTests.cs diff --git a/OpenNest.Core/Diagnostics/OverlapMarker.cs b/OpenNest.Core/Diagnostics/OverlapMarker.cs new file mode 100644 index 00000000..35b2747e --- /dev/null +++ b/OpenNest.Core/Diagnostics/OverlapMarker.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.Diagnostics; + +/// +/// A marker point on one island of shared material. The area centroid of a concave, +/// holed or split overlap can lie off the material, so markers use the island's pole of +/// inaccessibility instead, with a guaranteed-interior fallback. +/// +internal static class OverlapMarker +{ + // Distance evaluations per island; a long sliver otherwise refines along its whole ridge. + internal const int MaxCells = 4096; + + /// + /// The island's pole of inaccessibility (searched to a tenth of its area/perimeter, a + /// lower bound on a thin island's half-width) when that bounded search lands inside; + /// otherwise the midpoint of the widest interior run of a horizontal scan line; otherwise + /// . Rings are closed, as the clipping returns them. + /// + internal static Vector Find(IReadOnlyList outline, IReadOnlyList> holes, double area, + Vector fallback, int maxCells = MaxCells) + { + var outer = ToPolygon(outline); + var hollow = holes.Select(ToPolygon).ToList(); + var perimeter = outer.Perimeter() + hollow.Sum(hole => hole.Perimeter()); + var precision = 0.1 * area / perimeter; + if (double.IsFinite(precision) && precision > 0) + { + var pole = PolyLabel.Find(outer, hollow, precision, maxCells); + if (Inside(outline, holes, pole)) + return pole; + } + return TryScanLine(outline, holes, out var point) ? point : fallback; + } + + /// + /// Midpoint of the widest interior run along a horizontal line through the middle of the + /// island, placed between vertex heights so crossings are unambiguous (as JTS's + /// interior-point-of-area). Even-odd pairing of outline and hole crossings keeps it + /// inside the material. + /// + private static bool TryScanLine(IReadOnlyList outline, IReadOnlyList> holes, out Vector point) + { + point = default; + var minY = outline.Min(vertex => vertex.Y); + var maxY = outline.Max(vertex => vertex.Y); + var centre = (minY + maxY) / 2; + double below = minY, above = maxY; + foreach (var vertex in outline.Concat(holes.SelectMany(hole => hole))) + { + if (vertex.Y <= centre && vertex.Y > below) + below = vertex.Y; + else if (vertex.Y > centre && vertex.Y < above) + above = vertex.Y; + } + var y = (below + above) / 2; + if (!(y > minY && y < maxY)) + return false; + + var crossings = new List(); + AddCrossings(outline, y, crossings); + foreach (var hole in holes) + AddCrossings(hole, y, crossings); + crossings.Sort(); + var widest = 0.0; + for (var i = 0; i + 1 < crossings.Count; i += 2) + { + var width = crossings[i + 1] - crossings[i]; + if (width > widest) + { + widest = width; + point = new Vector((crossings[i] + crossings[i + 1]) / 2, y); + } + } + return widest > 0; + } + + private static void AddCrossings(IReadOnlyList ring, double y, List crossings) + { + for (var i = 0; i + 1 < ring.Count; i++) + { + var a = ring[i]; + var b = ring[i + 1]; + if ((a.Y > y) != (b.Y > y)) + crossings.Add(a.X + (y - a.Y) * (b.X - a.X) / (b.Y - a.Y)); + } + } + + private static bool Inside(IReadOnlyList outline, IReadOnlyList> holes, Vector point) => + double.IsFinite(point.X) && double.IsFinite(point.Y) + && InsideRing(outline, point) && !holes.Any(hole => InsideRing(hole, point)); + + // Crossing-number test over a closed ring (half-open edges, so a vertex counts once). + private static bool InsideRing(IReadOnlyList ring, Vector point) + { + var inside = false; + for (var i = 0; i + 1 < ring.Count; i++) + { + var a = ring[i]; + var b = ring[i + 1]; + if ((a.Y > point.Y) != (b.Y > point.Y) + && point.X < a.X + (point.Y - a.Y) * (b.X - a.X) / (b.Y - a.Y)) + inside = !inside; + } + return inside; + } + + private static Polygon ToPolygon(IReadOnlyList ring) + { + var polygon = new Polygon(); + polygon.Vertices.AddRange(ring); + polygon.UpdateBounds(); + return polygon; + } +} diff --git a/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs b/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs index ee4fc473..8a3b11c3 100644 --- a/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs +++ b/OpenNest.Core/Diagnostics/OverlapPairPresentation.cs @@ -23,7 +23,7 @@ public static class OverlapPairPresentation : $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}"; return head + "\n" + $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n" - + $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}"; + + $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}"; } // Significant figures adapt to scale: a positive sliver must never read as zero. @@ -34,7 +34,7 @@ public static class OverlapPairPresentation public static double HitRadius(int deviceDpi) => 10.0 * deviceDpi / 96; /// - /// Projects cached centroids into a screen-pixel coordinate space. The pointer must + /// Projects cached markers 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. /// @@ -44,7 +44,7 @@ public static class OverlapPairPresentation var radius = HitRadius(deviceDpi); return pairs.Where(pair => { - var center = worldToScreen(pair.Centroid); + var center = worldToScreen(pair.Marker); var dx = pointer.X - center.X; var dy = pointer.Y - center.Y; return dx * dx + dy * dy <= radius * radius; diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs index 43487a64..ffa0d9e6 100644 --- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs @@ -210,10 +210,10 @@ public static class PlateOverlapAnalyzer var localA = a.Material.Transform(0, origin * -1); var localB = b.Material.Transform(0, origin * -1); if (!TryShare(localA, localB.Outer, localB.Holes, origin, cancellationToken, - out var regions, out var centroid)) + out var regions, out var centroid, out var marker)) continue; pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id, - a.Input.Name, b.Input.Name, regions, centroid)); + a.Input.Name, b.Input.Name, regions, centroid, marker)); } catch (Exception exception) when (IsGeometryFailure(exception)) { @@ -280,11 +280,12 @@ public static class PlateOverlapAnalyzer var origin = part.Material.Outer.Vertices[0]; var local = part.Material.Transform(0, origin * -1); var rect = RectanglePolygon(zone, origin * -1); - if (!TryShare(local, rect, null, origin, cancellationToken, out var regions, out var centroid)) + if (!TryShare(local, rect, null, origin, cancellationToken, + out var regions, out var centroid, out var marker)) continue; findings.Add(new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name, - $"Clamp '{clamp.Id}'", regions, centroid)); + $"Clamp '{clamp.Id}'", regions, centroid, marker)); } catch (Exception exception) when (IsGeometryFailure(exception)) { @@ -298,16 +299,19 @@ public static class PlateOverlapAnalyzer /// /// Shared material of a rebased operand with another rebased outline/holes, as world - /// regions (connected islands) plus the area centroid of them all. Returns false when the + /// regions (connected islands) plus the area centroid of them all and a marker point + /// inside the largest island. Returns false when the /// total shared area does not exceed : contact and rounding /// dust are not overlap. Invalid moments throw, so the pair becomes incomplete rather /// than clear or marked at the origin. /// private static bool TryShare(OverlapMaterial localA, Polygon outerB, List holesB, Vector origin, - CancellationToken cancellationToken, out List regions, out Vector centroid) + CancellationToken cancellationToken, out List regions, out Vector centroid, + out Vector marker) { regions = null; centroid = default; + marker = default; var islands = OverlapClipping.Intersect(localA.Outer, localA.Holes, outerB, holesB); var area = islands.Sum(island => island.Moments.Area); if (!double.IsFinite(area)) @@ -326,6 +330,12 @@ public static class PlateOverlapAnalyzer centroid = combined.Centroid + origin; if (!OverlapMaterial.IsFinite(centroid)) throw new ArithmeticException("Overlap centroid is not finite in world coordinates."); + // The area centroid can lie between islands or in a hole: mark the largest island. + var largest = islands.Aggregate((best, island) => island.Moments.Area > best.Moments.Area ? island : best); + marker = OverlapMarker.Find(largest.Outline, largest.Holes, largest.Moments.Area, largest.Moments.Centroid) + + origin; + if (!OverlapMaterial.IsFinite(marker)) + throw new ArithmeticException("Overlap marker is not finite in world coordinates."); return true; } @@ -340,7 +350,8 @@ public static class PlateOverlapAnalyzer }; var centre = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2); return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name, - $"Clamp '{clamp.Id}'", new List { new(corners, zone.Length * zone.Width) }, centre); + $"Clamp '{clamp.Id}'", new List { new(corners, zone.Length * zone.Width) }, centre, + centre); } private static Polygon RectanglePolygon(Box box, Vector offset) diff --git a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs index 136c0764..f93d2245 100644 --- a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs +++ b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs @@ -52,6 +52,7 @@ public sealed class PlateOverlapPair Regions = source.Regions; Area = source.Area; Centroid = source.Centroid; + Marker = source.Marker; bounds = source.bounds; } @@ -60,7 +61,7 @@ public sealed class PlateOverlapPair new(this, partAId, partBId, partAName, partBName); internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName, - List regions, Vector centroid) + List regions, Vector centroid, Vector marker) { PartAId = partAId; PartBId = partBId; @@ -69,6 +70,7 @@ public sealed class PlateOverlapPair Regions = regions.AsReadOnly(); Area = regions.Sum(region => region.Area); Centroid = centroid; + Marker = marker; var points = regions.SelectMany(region => region.Vertices).ToArray(); var left = points.Min(point => point.X); var bottom = points.Min(point => point.Y); @@ -88,6 +90,13 @@ public sealed class PlateOverlapPair /// This can lie outside disconnected or concave shared material. Returned by value. /// public Vector Centroid { get; } + /// + /// Finite world-coordinate point on the shared material, inside the pair's largest island: + /// its pole of inaccessibility, or an interior scan-line point when a thin island defeats + /// the bounded pole search. Markers, hit testing and inspection rings use this point. + /// Returned by value. + /// + public Vector Marker { get; } /// A fresh world-coordinate bounds copy. public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width); } diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs index 8cb64cf8..2d7ba3c1 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs @@ -201,11 +201,11 @@ public static class PostVerificationAnalyzer foreach (var pair in overlap.Pairs) findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId], indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).") - { Location = pair.Centroid, Overlap = pair }); + { Location = pair.Marker, Overlap = pair }); foreach (var pair in overlap.ClampFindings) findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId], null, $"Clean drawing material overlaps {pair.PartBName} (holes subtracted).") - { Location = pair.Centroid, Overlap = pair }); + { Location = pair.Marker, Overlap = pair }); foreach (var issue in overlap.Issues) findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId], issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message)); diff --git a/OpenNest.Tests/Diagnostics/IncrementalOverlapAnalysisTests.cs b/OpenNest.Tests/Diagnostics/IncrementalOverlapAnalysisTests.cs index 0f29522c..7a791e3f 100644 --- a/OpenNest.Tests/Diagnostics/IncrementalOverlapAnalysisTests.cs +++ b/OpenNest.Tests/Diagnostics/IncrementalOverlapAnalysisTests.cs @@ -142,8 +142,9 @@ public class IncrementalOverlapAnalysisTests private static string Describe(PlateOverlapReport report) => string.Join(";", report.Pairs.Select(pair => $"{pair.PartAId}/{pair.PartBId}[{pair.PartAName}|{pair.PartBName}]:{pair.Area:R}:" - + $"{pair.Centroid.X:R},{pair.Centroid.Y:R}:" - + string.Join(",", pair.Regions.SelectMany(region => region.Vertices).Select(v => $"{v.X:R} {v.Y:R}")))) + + $"{pair.Centroid.X:R},{pair.Centroid.Y:R}:{pair.Marker.X:R},{pair.Marker.Y:R}:" + + string.Join(",", pair.Regions.SelectMany(region => region.Vertices + .Concat(region.Holes.SelectMany(hole => hole))).Select(v => $"{v.X:R} {v.Y:R}")))) + "|" + string.Join(";", report.Issues.Select(issue => $"{issue.PartAId}/{issue.PartBId}:{issue.Message}")); private static Drawing Square(string name, double size) => diff --git a/OpenNest.Tests/Diagnostics/OverlapHoverPagesTests.cs b/OpenNest.Tests/Diagnostics/OverlapHoverPagesTests.cs index 8a4a78fe..0cf92b5f 100644 --- a/OpenNest.Tests/Diagnostics/OverlapHoverPagesTests.cs +++ b/OpenNest.Tests/Diagnostics/OverlapHoverPagesTests.cs @@ -22,7 +22,7 @@ public class OverlapHoverPagesTests 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.Contains("Marker: (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)); diff --git a/OpenNest.Tests/Diagnostics/OverlapMarkerTests.cs b/OpenNest.Tests/Diagnostics/OverlapMarkerTests.cs new file mode 100644 index 00000000..2b11dbb6 --- /dev/null +++ b/OpenNest.Tests/Diagnostics/OverlapMarkerTests.cs @@ -0,0 +1,48 @@ +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Diagnostics; + +public class OverlapMarkerTests +{ + // 4x4 frame with a 2x2 hole: the area centroid (2, 2) is in the hole. + private static readonly List Frame = Closed(new Vector(0, 0), new Vector(4, 0), new Vector(4, 4), new Vector(0, 4)); + private static readonly List> FrameHole = + new() { Closed(new Vector(1, 1), new Vector(1, 3), new Vector(3, 3), new Vector(3, 1)) }; + + [Fact] + public void PoleIsDeepInsideTheMaterial() + { + var marker = OverlapMarker.Find(Frame, FrameHole, 12, new Vector(2, 2)); + + // The frame's bars are 1 wide: the pole sits on a bar's centre line. + Assert.True(Inside(marker), $"{marker.X:R}, {marker.Y:R}"); + var depth = new[] { marker.X, 4 - marker.X, marker.Y, 4 - marker.Y }.Min(); + Assert.InRange(depth, 0.4, 0.6); + } + + [Fact] + public void ExhaustedSearchFallsBackToAnInteriorScanLinePoint() + { + // One cell of budget: the search cannot leave the hole-centred bounding-box cell. + var marker = OverlapMarker.Find(Frame, FrameHole, 12, new Vector(2, 2), maxCells: 1); + + Assert.True(Inside(marker), $"{marker.X:R}, {marker.Y:R}"); + } + + [Fact] + public void NoInteriorRunReturnsTheFallback() + { + var fallback = new Vector(7, 7); + var marker = OverlapMarker.Find(Closed(new Vector(0, 0), new Vector(1, 0), new Vector(2, 0)), + new List>(), 0, fallback, maxCells: 1); + + Assert.Equal(fallback, marker); + } + + private static bool Inside(Vector point) => + point.X > 0 && point.X < 4 && point.Y > 0 && point.Y < 4 + && !(point.X >= 1 && point.X <= 3 && point.Y >= 1 && point.Y <= 3); + + private static List Closed(params Vector[] points) => points.Append(points[0]).ToList(); +} diff --git a/OpenNest.Tests/Diagnostics/OverlapPairPresentationTests.cs b/OpenNest.Tests/Diagnostics/OverlapPairPresentationTests.cs index f0bb185a..773f1eab 100644 --- a/OpenNest.Tests/Diagnostics/OverlapPairPresentationTests.cs +++ b/OpenNest.Tests/Diagnostics/OverlapPairPresentationTests.cs @@ -19,7 +19,7 @@ public class OverlapPairPresentationTests 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); + Assert.Equal($"Pair 2/3: left / right\nShared area ≈ 16 {symbol}²\nMarker: (2, 2) {symbol}", text); } [Theory] @@ -45,7 +45,7 @@ public class OverlapPairPresentationTests 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 center = ToScreen(pairs[0].Marker); var radius = OverlapPairPresentation.HitRadius(dpi); Assert.Single(OverlapPairPresentation.HitTest(pairs, ToScreen, center + new Vector(radius, 0), dpi)); diff --git a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs index ceca6dc0..e60fbdef 100644 --- a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs +++ b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs @@ -553,6 +553,66 @@ public class PlateOverlapAnalyzerTests return twice / 2; } + // Each row's area centroid lies off the red material (between patches, in a hole, or + // inside a curve); the marker must sit on the shared material instead. + [Theory] + [InlineData("two patches")] + [InlineData("frame covered")] + [InlineData("half annulus")] + [InlineData("thin crescent")] + public void Analyze_MarkerLiesOnSharedMaterialWhereTheCentroidDoesNot(string scenario) + { + var parts = scenario switch + { + "two patches" => new[] + { + WithContours(new[] + { + new Vector(0, 0), new Vector(3, 0), new Vector(3, 3), new Vector(2, 3), + new Vector(2, 1), new Vector(1, 1), new Vector(1, 3), new Vector(0, 3) + }), + Rectangle(0, 2, 3, 1) + }, + "frame covered" => new[] { Rectangle(-1, -1, 6, 6), WithContours(Square(0, 0, 4), Square(1, 1, 2)) }, + "half annulus" => new[] { WithContours(Circle(0, 0, 5), Circle(0, 0, 4.5)), Rectangle(0, -10, 10, 20) }, + _ => new[] { WithContours(Circle(0, 0, 10)), WithContours(Square(-15, -15, 30), Circle(0.01, 0, 10)) }, + }; + + var report = PlateOverlapAnalyzer.Analyze(parts); + + Assert.True(report.IsComplete, string.Join("; ", report.Issues)); + var pair = Assert.Single(report.Pairs); + Assert.False(OnMaterial(pair, pair.Centroid), "precondition: the centroid is off the material"); + Assert.True(OnMaterial(pair, pair.Marker), $"marker {pair.Marker.X:R}, {pair.Marker.Y:R}"); + } + + [Fact] + public void Analyze_LongStraightSliverMarkerIsOnTheSliver() + { + var report = PlateOverlapAnalyzer.Analyze(new[] { Rectangle(0, 0, 48, 24), Rectangle(0, 23.998, 48, 24) }); + + var pair = Assert.Single(report.Pairs); + Assert.True(OnMaterial(pair, pair.Marker), $"marker {pair.Marker.X:R}, {pair.Marker.Y:R}"); + } + + private static bool OnMaterial(PlateOverlapPair pair, Vector point) => + pair.Regions.Any(region => InsideRing(region.Vertices, point) + && !region.Holes.Any(hole => InsideRing(hole, point))); + + private static bool InsideRing(IReadOnlyList ring, Vector point) + { + var inside = false; + for (int i = 0, j = ring.Count - 1; i < ring.Count; j = i++) + if ((ring[i].Y > point.Y) != (ring[j].Y > point.Y) + && point.X < (ring[j].X - ring[i].X) * (point.Y - ring[i].Y) / (ring[j].Y - ring[i].Y) + ring[i].X) + inside = !inside; + return inside; + } + + private static Vector[] Circle(double x, double y, double radius) => + Enumerable.Range(0, 256).Select(i => new Vector(x + radius * System.Math.Cos(i * System.Math.PI / 128), + y + radius * System.Math.Sin(i * System.Math.PI / 128))).ToArray(); + // A's square hole closes the mouth of B's C-shaped hole, so the material enclosed by // both holes is a separate island inside the shared hole and must not be dropped. [Fact] diff --git a/OpenNest.WinForms.Tests/Forms/CuttingValidationPanelTests.cs b/OpenNest.WinForms.Tests/Forms/CuttingValidationPanelTests.cs index 794ec5b3..d983c1f1 100644 --- a/OpenNest.WinForms.Tests/Forms/CuttingValidationPanelTests.cs +++ b/OpenNest.WinForms.Tests/Forms/CuttingValidationPanelTests.cs @@ -251,7 +251,7 @@ public class CuttingValidationPanelTests var view = editor.PlateView; Assert.Same(plate, view.Plate); Assert.NotNull(view.ValidationOverlay.Finding.Overlap); - Assert.Contains("centroid", Field(panel, "resultLabel").Text); + Assert.Contains("largest patch", Field(panel, "resultLabel").Text); var scale = view.ViewScale; InvokeView(view, "OnMouseWheel", new MouseEventArgs(MouseButtons.None, 0, 100, 100, 120)); Assert.True(view.ViewScale > scale); diff --git a/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs index a06857c4..0d1a26ae 100644 --- a/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs +++ b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs @@ -456,7 +456,7 @@ public class PlateOverlapOverlayTests 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); + var center = form.PlateView.PointWorldToControl(form.PlateView.OverlapReport.Pairs[0].Marker); form.PlateView.OverlapOverlay.UpdateHover(center); Assert.Contains(" mm²", form.PlateView.OverlapOverlay.HoverText); Assert.Contains("same name / same name", form.PlateView.OverlapOverlay.HoverText); @@ -536,7 +536,7 @@ public class PlateOverlapOverlayTests graphics.TranslateTransform(60, 320); run.View.OverlapOverlay.Draw(graphics); Assert.Null(run.View.OverlapOverlay.CachedPath); - var center = run.View.PointWorldToControl(pair.Centroid); + var center = run.View.PointWorldToControl(pair.Marker); 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()); @@ -550,7 +550,7 @@ public class PlateOverlapOverlayTests }); [Fact] - public void MarkerAndHoverUsePairMaterialCentroidRatherThanBoundsCenter() => RunSta(() => + public void MarkerAndHoverUseThePoleOfTheSharedMaterialRatherThanBoundsCenter() => RunSta(() => { using var run = new OverlayRun(); run.View.Plate.Parts.Clear(); @@ -571,7 +571,12 @@ public class PlateOverlapOverlayTests 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)); + // The marker is the shared triangle's pole: its incenter (r, r), r = 2A / P. + var marker = Assert.Single(run.View.OverlapReport.Pairs).Marker; + var inradius = 2 * 9 / (6 + 3 + System.Math.Sqrt(45)); + Assert.InRange(marker.X, inradius - 0.06, inradius + 0.06); + Assert.InRange(marker.Y, inradius - 0.06, inradius + 0.06); + var materialCenter = run.View.PointWorldToControl(marker); 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()); @@ -620,7 +625,7 @@ public class PlateOverlapOverlayTests 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)); + run.View.MoveTo(run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Marker)); var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic; var hoverCheck = typeof(PlateView).GetMethod("HoverCheck", flags)!; var normalVisible = typeof(PlateView).GetField("showTooltip", flags)!; @@ -657,7 +662,7 @@ public class PlateOverlapOverlayTests run.Finish(); run.View.ZoomToPoint(new Vector(), 20); run.View.SetOrigin(60, 220); - var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid); + var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Marker); run.View.MoveTo(center); Assert.NotNull(run.View.OverlapOverlay.HoverText); switch (change) @@ -726,7 +731,7 @@ public class PlateOverlapOverlayTests 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)); + run.View.MoveTo(run.View.PointWorldToControl(report.Pairs[0].Marker)); 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)); @@ -734,7 +739,7 @@ public class PlateOverlapOverlayTests Assert.Equal(1, run.Calls); var scale = run.View.ViewScale; - run.View.WheelAt(run.View.PointWorldToControl(report.Pairs[0].Centroid)); + run.View.WheelAt(run.View.PointWorldToControl(report.Pairs[0].Marker)); Assert.True(run.View.ViewScale > scale); // Wheel remains zoom, never paging. Assert.Null(overlay.HoverText); Assert.Null(overlay.HoverPages); @@ -792,7 +797,7 @@ public class PlateOverlapOverlayTests 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); + var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Marker); using var largerFont = new Font(run.View.Font.FontFamily, run.View.Font.Size + 1); switch (change) { @@ -834,7 +839,7 @@ public class PlateOverlapOverlayTests run.Finish(); run.View.ZoomToPoint(new Vector(), 10); run.View.SetOrigin(30, 90); - var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid); + var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Marker); run.View.MoveTo(center); using var image = new Bitmap(150, 120); using var graphics = Graphics.FromImage(image); @@ -852,7 +857,7 @@ public class PlateOverlapOverlayTests } 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)); + overlay.UpdateHover(run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Marker)); Assert.True(overlay.DrawHover(graphics)); Assert.True(overlay.HoverPages.NeedsLargerViewport); Assert.False(run.View.Command(Keys.PageDown)); @@ -1004,7 +1009,7 @@ public class PlateOverlapOverlayTests 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)); + run.View.MoveTo(run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Marker)); } // These waits are deliberate: only Task.Run workers use the result gate; the test's diff --git a/OpenNest/Controls/OverlapOverlayController.cs b/OpenNest/Controls/OverlapOverlayController.cs index d46c7761..8caba0b8 100644 --- a/OpenNest/Controls/OverlapOverlayController.cs +++ b/OpenNest/Controls/OverlapOverlayController.cs @@ -488,7 +488,7 @@ internal sealed class OverlapOverlayController : IDisposable var labelRows = new Dictionary(); foreach (var pair in state.DisplayPairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId)) { - var center = view.PointWorldToGraph(pair.Centroid); + var center = view.PointWorldToGraph(pair.Marker); if (!float.IsFinite(center.X) || !float.IsFinite(center.Y)) continue; foreach (var pen in new[] { halo, crosshair }) diff --git a/OpenNest/Forms/CuttingInspectionPanel.cs b/OpenNest/Forms/CuttingInspectionPanel.cs index 9ebfb486..880bc2ec 100644 --- a/OpenNest/Forms/CuttingInspectionPanel.cs +++ b/OpenNest/Forms/CuttingInspectionPanel.cs @@ -448,7 +448,7 @@ internal sealed class CuttingInspectionPanel : UserControl { PostVerificationKind.Overlap when finding.ClampId != null => "The ring marks a nominal cut-off contacting a clamp; no material-overlap area is implied.", - PostVerificationKind.Overlap => "Shaded material is the overlap. The ring marks its area centroid, which can lie between separate patches.", + PostVerificationKind.Overlap => "Shaded material is the overlap. The ring marks a point inside its largest patch.", PostVerificationKind.RapidCrossing when finding.ClampId != null => "The crosshair marks the rapid's contact with the clamp. Head height is not modeled.", PostVerificationKind.RapidCrossing => finding.ContactPoints.Count > 0 diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index e6995ada..79832ea8 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -390,7 +390,7 @@ namespace OpenNest.Forms // mnuOverlapCentroids // mnuOverlapCentroids.Name = "mnuOverlapCentroids"; - mnuOverlapCentroids.Text = "Centroids"; + mnuOverlapCentroids.Text = "Markers"; mnuOverlapCentroids.Click += OverlapCentroids_Click; // // mnuOverlapBoth diff --git a/README.md b/README.md index 9ad776af..0f1ce193 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** — automatic multi-plate/multi-material jobs, interlocking pair evaluation, and built-in or plug-in engines. [Choose an engine](docs/nesting-engines.md). - **Plate operations** — manual sheet cut-offs, [plate- or nest-wide automatic scrap cutoffs with a minimum tail-to-keep setting](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** — highlight shared material on the active plate, rechecked automatically after edits, 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). +- **Visual overlap check** — highlight shared material on the active plate, rechecked automatically after edits, including containment and cutouts, with area shading, pair markers, 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). [Pre-post verification](docs/post-verification.md) checks overlaps, missing lead-ins, and rapid crossings, with explicit risk acknowledgment required to bypass warnings. ## Requirements diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md index 233408c2..52128b6f 100644 --- a/docs/geometry/visual-overlap-check.md +++ b/docs/geometry/visual-overlap-check.md @@ -6,17 +6,17 @@ 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 / Centroids / Both** changes visibility without rerunning analysis. Areas +Areas / Markers / Both** changes visibility without rerunning analysis. Areas is the default; rechecking preserves a visible mode, while checking from Off shows Areas. Clamp overlaps are shown as part/clamp findings with the clamp ID in hover details; clamps are not selectable parts and are not added to quantity or machining. A clamp edit invalidates the diagnostic and schedules a fresh check. Display preferences belong to the current document and are not saved. -Centroids and Both show fixed-screen-size, DPI-scaled pair crosshairs with a +Markers 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 +captured names, approximate shared area in in² or mm², and marker 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 @@ -73,7 +73,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}, centroid {pair.Centroid}"); + Console.WriteLine($"{pair.PartAId} / {pair.PartBId}: {pair.Area}, marker {pair.Marker}"); foreach (var region in pair.Regions) { // region.Vertices: closed, read-only, counterclockwise world-coordinate outline @@ -183,11 +183,17 @@ so summing all pair areas double-counts shared plate locations. `pair.Centroid` is the true area-weighted center of every shared-material island for that pair, after hole subtraction. It is not an average of crossings or -vertices. For a disconnected or concave overlap, the mathematical centroid can -lie outside the red material (for example, between two separate patches). This -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. +vertices. For a disconnected, holed or concave overlap it can lie outside the red +material (between two patches, in a hole, or inside a curve). + +`pair.Marker` is where markers, hover hit testing and cutting-inspection rings go: +a point on the shared material, inside the pair's largest island. It is that +island's pole of inaccessibility (`PolyLabel`), searched to a tenth of the island's +area/perimeter with a budget of 4096 distance evaluations. If that bounded search +does not land inside (a long thin sliver), the marker is the midpoint of the widest +interior run of a horizontal scan line through the island, placed between vertex +heights. There is one marker per pair, not per island: a second patch has shading +but no crosshair. `PolygonAreaMoments` evaluates signed moments about local origins. The analyzer subtracts each island's hole moments from its outline's (`TrySubtract`), then @@ -256,7 +262,8 @@ failure clear all retained highlights. Measured on 389 real PEP-converted plates (252 nests), a from-scratch check takes median 0.8 ms, p99 97 ms and max 3.2 s. Nearly all of the slow cases are first-time material preparation for a hole-heavy drawing. With material prepared, clipping every -candidate pair takes median 0.05 ms, p99 27 ms and max 52 ms. The triangle-pair kernel +candidate pair and placing markers takes median 0.05 ms, p99 34 ms and max 76 ms +(markers added about 0.4 ms per overlapping pair). The triangle-pair kernel took up to 2.6 s on the same plates. On those plates Clipper2 and the previous kernel found identical pair, clamp and issue sets. Areas differed by at most 0.0002 square units, always smaller from the kernel, consistent with its dropping fragments below @@ -275,7 +282,7 @@ and triple coverage does not darken. World-to-graph conversion excludes pan, because PlateView already applies origin translation. Paths are rebuilt for displayed-pair/scale changes, not ordinary repaints or panning. The state label saves/restores graphics state. -Centroid hit tests use only cached report coordinates and DPI-scaled screen +Marker 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. @@ -295,7 +302,7 @@ 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|FullyQualifiedName~PolygonAreaMomentsTests|FullyQualifiedName~OverlapPairPresentationTests|FullyQualifiedName~OverlapHoverPagesTests|FullyQualifiedName~IncrementalOverlapAnalysisTests|FullyQualifiedName~OverlapAutoCheckSchedulerTests' +dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~PlateOverlapAnalyzerTests|FullyQualifiedName~OverlapReportStateTests|FullyQualifiedName~PolygonAreaMomentsTests|FullyQualifiedName~OverlapPairPresentationTests|FullyQualifiedName~OverlapHoverPagesTests|FullyQualifiedName~IncrementalOverlapAnalysisTests|FullyQualifiedName~OverlapAutoCheckSchedulerTests|FullyQualifiedName~OverlapMarkerTests|FullyQualifiedName~PolyLabel' ``` `IncrementalOverlapAnalysisTests` compares incremental rechecks with uncached full @@ -312,6 +319,10 @@ translations, and invalid/overflow cases. `OverlapPairPresentationTests` checks 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. +`OverlapMarkerTests` covers the pole, the scan-line fallback after an exhausted search +budget, and the last-resort fallback; `PolyLabelDifferentialTests` proves the heap +queue returns bit-identical poles to the former sorted list, so label positions +elsewhere do not move. `OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs` adds STA worker/publication, menu/MDI, path-cache, uniform-fill pixel, control-lifetime, and automatic-recheck (debounce, drag detection, busy wait, supersession, display/status) checks. Run those @@ -323,7 +334,8 @@ dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj 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 +check partial overlap, containment, inside-hole placement, markers on concave, +holed and two-patch overlaps, pan/zoom and quadrant alignment, changed-pair clearing and unchanged-pair retention during edits and pending rechecks, full clearing on plate switches and converter cancellation, the pending label and automatic recheck after dragging a part onto another, diff --git a/docs/post-verification.md b/docs/post-verification.md index f83e2415..9662ea36 100644 --- a/docs/post-verification.md +++ b/docs/post-verification.md @@ -18,8 +18,8 @@ restores normal rendering without changing drawing colors or selection. Overlaps use the same magenta material shading as **View > Overlap Check**. A DPI-scaled ring expands and fades continuously while the finding is selected; -both its color and contrasting halo fade as it grows. For overlaps the ring marks the area centroid (possibly between -separate patches); for missing lead-ins it marks the contour start. Rapid warnings +both its color and contrasting halo fade as it grows. For overlaps the ring marks a point inside the largest +shared patch; for missing lead-ins it marks the contour start. Rapid warnings pulse the offending orange dashed segment continuously and mark its contacts with the completed contour using fixed-screen-size magenta crosshairs with white halos. Contacts use native lines/arcs and the rapid check's existing numerical tolerances;