Files
OpenNest/OpenNest.Core/Diagnostics/OverlapMarker.cs
T
aj 80d01839bd 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.
2026-10-15 23:31:14 -04:00

120 lines
4.5 KiB
C#

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;
}
}