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.
This commit is contained in:
aj committed 2026-10-15 23:31:14 -04:00
1 parent dc0aab06fc
commit 80d01839bd
18 files changed
+315 -50

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>
/// 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.
/// </summary>
internal static class OverlapMarker
{
// Distance evaluations per island; a long sliver otherwise refines along its whole ridge.
internal const int MaxCells = 4096;
/// <summary>
/// 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
/// <paramref name="fallback"/>. Rings are closed, as the clipping returns them.
/// </summary>
internal static Vector Find(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> 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;
}
/// <summary>
/// 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.
/// </summary>
private static bool TryScanLine(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> 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<double>();
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<Vector> ring, double y, List<double> 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<Vector> outline, IReadOnlyList<List<Vector>> 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<Vector> 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<Vector> ring)
{
var polygon = new Polygon();
polygon.Vertices.AddRange(ring);
polygon.UpdateBounds();
return polygon;
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
@@ -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;
@@ -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
/// <summary>
/// Shared material of a rebased operand with another rebased outline/holes, as world
/// regions (connected islands) plus the area centroid of them all. Returns false when the
/// 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 <see cref="OpenNest.Math.Tolerance.Epsilon"/>: contact and rounding
/// dust are not overlap. Invalid moments throw, so the pair becomes incomplete rather
/// than clear or marked at the origin.
/// </summary>
private static bool TryShare(OverlapMaterial localA, Polygon outerB, List<Polygon> holesB, Vector origin,
CancellationToken cancellationToken, out List<PlateOverlapRegion> regions, out Vector centroid)
CancellationToken cancellationToken, out List<PlateOverlapRegion> 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<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, centre);
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, centre,
centre);
}
private static Polygon RectanglePolygon(Box box, Vector offset)
@@ -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<PlateOverlapRegion> regions, Vector centroid)
List<PlateOverlapRegion> 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.
/// </summary>
public Vector Centroid { get; }
/// <summary>
/// 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.
/// </summary>
public Vector Marker { get; }
/// <summary>A fresh world-coordinate bounds copy.</summary>
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
}
@@ -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));
@@ -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) =>
@@ -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));
@@ -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<Vector> Frame = Closed(new Vector(0, 0), new Vector(4, 0), new Vector(4, 4), new Vector(0, 4));
private static readonly List<List<Vector>> 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<List<Vector>>(), 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<Vector> Closed(params Vector[] points) => points.Append(points[0]).ToList();
}
@@ -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));
@@ -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<Vector> 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]
@@ -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<TextBox>(panel, "resultLabel").Text);
Assert.Contains("largest patch", Field<TextBox>(panel, "resultLabel").Text);
var scale = view.ViewScale;
InvokeView(view, "OnMouseWheel", new MouseEventArgs(MouseButtons.None, 0, 100, 100, 120));
Assert.True(view.ViewScale > scale);
@@ -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
@@ -488,7 +488,7 @@ internal sealed class OverlapOverlayController : IDisposable
var labelRows = new Dictionary<PointF, int>();
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 })
+1 -1
View File
@@ -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
+1 -1
View File
@@ -390,7 +390,7 @@ namespace OpenNest.Forms
// mnuOverlapCentroids
//
mnuOverlapCentroids.Name = "mnuOverlapCentroids";
mnuOverlapCentroids.Text = "Centroids";
mnuOverlapCentroids.Text = "Markers";
mnuOverlapCentroids.Click += OverlapCentroids_Click;
//
// mnuOverlapBoth
+1 -1
View File
@@ -12,7 +12,7 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges
- **Import / export** — DXF & DWG parts (ACadSharp), Excel BOMs, bend-line detection, built-in parametric shapes; export DXF or post-processed G-code.
- **Nesting** — 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
+25 -13
View File
@@ -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,
+2 -2
View File
@@ -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;