feat(plateview): annotate overlap centroids

This commit is contained in:
aj
2026-09-28 22:17:49 -04:00
parent e4d07121c8
commit a8204bb51f
19 changed files with 1608 additions and 46 deletions
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace OpenNest.Diagnostics;
/// <summary>
/// Viewport-sized pages of cached overlap details, including continuation pages for a
/// single long pair. The caller supplies its actual single-line font measurement.
/// No names or numeric details are elided, and no geometry is queried here.
/// </summary>
public sealed class OverlapHoverPages
{
private readonly IReadOnlyList<string>[] pages;
private readonly IReadOnlyList<string>[] navigation;
private OverlapHoverPages(IReadOnlyList<string>[] pages, IReadOnlyList<string>[] navigation,
bool needsLargerViewport = false)
{
this.pages = pages;
this.navigation = navigation;
NeedsLargerViewport = needsLargerViewport;
}
public IReadOnlyList<string> Lines => PageCount == 0 ? Array.Empty<string>() : pages[PageIndex];
public IReadOnlyList<string> NavigationLines => PageCount == 0 ? Array.Empty<string>() : navigation[PageIndex];
public int PageIndex { get; private set; }
public int PageCount => pages.Length;
public bool NeedsLargerViewport { get; }
public void MovePage(int delta) => PageIndex = (int)System.Math.Clamp((long)PageIndex + delta, 0,
System.Math.Max(0, PageCount - 1));
public static OverlapHoverPages Create(string text, int pairCount, int maxRows,
double maxWidth, Func<string, double> measure)
{
var tooSmall = new OverlapHoverPages([], [], true);
if (maxRows < 1 || !double.IsFinite(maxWidth) || maxWidth <= 0)
return tooSmall;
var lines = Wrap(text, maxWidth, measure);
if (lines == null)
return tooSmall;
if (lines.Count <= maxRows)
return new OverlapHoverPages([lines.AsReadOnly()], [Array.Empty<string>()]);
// Reserve the real, wrapped hint as well as content. Increasing the reserved
// rows can increase the page count/digit count, so converge before slicing.
for (var hintRows = 2; hintRows < maxRows;)
{
var contentRows = maxRows - hintRows;
var count = (lines.Count - 1) / contentRows + 1;
var hints = new IReadOnlyList<string>[count];
var requiredHintRows = hintRows;
for (var page = 0; page < count; page++)
{
var hint = Wrap($"Page {page + 1}/{count} · {pairCount} pairs\nPgUp/PgDn", maxWidth, measure);
if (hint == null)
return tooSmall;
hints[page] = hint.AsReadOnly();
requiredHintRows = System.Math.Max(requiredHintRows, hint.Count);
}
if (requiredHintRows > hintRows)
{
hintRows = requiredHintRows;
continue;
}
var pages = Enumerable.Range(0, count)
.Select(page => (IReadOnlyList<string>)Array.AsReadOnly(lines.Skip(page * contentRows).Take(contentRows).ToArray()))
.ToArray();
return new OverlapHoverPages(pages, hints);
}
// There must be room for at least one complete content line AND navigation.
return tooSmall;
}
private static List<string> Wrap(string text, double width, Func<string, double> measure)
{
var lines = new List<string>();
foreach (var paragraph in text.Replace("\r\n", "\n").Split('\n'))
{
if (paragraph.Length == 0)
{
lines.Add("");
continue;
}
var starts = StringInfo.ParseCombiningCharacters(paragraph).Append(paragraph.Length).ToArray();
for (var first = 0; first < starts.Length - 1;)
{
var low = first;
var high = starts.Length - 1;
while (low < high)
{
var end = low + (high - low + 1) / 2;
if (measure(paragraph[starts[first]..starts[end]]) <= width)
low = end;
else
high = end - 1;
}
if (low == first)
return null; // Even one grapheme cannot fit; do not silently clip it.
var last = low;
if (last < starts.Length - 1)
{
for (var end = last; end > first; end--)
{
if (char.IsWhiteSpace(paragraph[starts[end - 1]]))
{
last = end;
break;
}
}
}
lines.Add(paragraph[starts[first]..starts[last]]);
first = last;
}
}
return lines;
}
}
@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
public static class OverlapPairPresentation
{
public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}";
public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null)
{
var units = UnitsHelper.GetShortString(capturedUnits);
return $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}\n"
+ $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
+ $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
}
// Significant figures adapt to scale: a positive sliver must never read as zero.
public static string Number(double value, IFormatProvider provider = null) =>
value.ToString("G6", provider ?? CultureInfo.CurrentCulture);
public static double MarkerHalfSize(int deviceDpi) => 6.0 * deviceDpi / 96;
public static double HitRadius(int deviceDpi) => 10.0 * deviceDpi / 96;
/// <summary>
/// Projects cached centroids into a screen-pixel coordinate space. The pointer must
/// use that same space; view zoom never scales the DPI-adjusted hit radius.
/// Coincident or nearby markers return every matching pair in stable ID order.
/// </summary>
public static IReadOnlyList<PlateOverlapPair> HitTest(IEnumerable<PlateOverlapPair> pairs,
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi)
{
var radius = HitRadius(deviceDpi);
return pairs.Where(pair =>
{
var center = worldToScreen(pair.Centroid);
var dx = pointer.X - center.X;
var dy = pointer.Y - center.Y;
return dx * dx + dy * dy <= radius * radius;
}).OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
}
}
@@ -2,7 +2,7 @@ using System.Collections.Generic;
namespace OpenNest.Diagnostics;
public enum OverlapDisplayMode { Off, Areas }
public enum OverlapDisplayMode { Off, Areas, Centroids, Both }
public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Failed, Canceled, Stale }
/// <summary>UI-thread lifecycle policy, independent of workers, GDI and view transforms.</summary>
@@ -34,7 +34,8 @@ public sealed class OverlapReportState
{
Clear(OverlapCheckStatus.Checking);
stamp = OverlapGeometryStamp.Capture(plate);
DisplayMode = OverlapDisplayMode.Areas;
if (DisplayMode == OverlapDisplayMode.Off)
DisplayMode = OverlapDisplayMode.Areas;
return Generation;
}
@@ -150,14 +150,26 @@ public static class PlateOverlapAnalyzer
var result = Collision.Check(localA.Outer, localB.Outer, localA.Holes, localB.Holes);
if (!result.Overlaps)
continue;
var regions = result.OverlapRegions.Select(region => new PlateOverlapRegion(
region.Vertices.Select(point => point + origin),
OverlapMaterial.Area(region.Vertices))).ToList();
var area = regions.Sum(region => region.Area);
if (!double.IsFinite(area) || area <= 0)
throw new ArithmeticException("Overlap area is not finite and positive.");
// Evaluate every hole-subtracted fragment before restoring world space.
// A failed moment must make this pair incomplete, never an origin marker.
var moments = new List<PolygonAreaMoments>();
var regions = new List<PlateOverlapRegion>();
foreach (var region in result.OverlapRegions)
{
cancellationToken.ThrowIfCancellationRequested();
if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment))
throw new ArithmeticException("Overlap fragment area moments are invalid.");
moments.Add(fragment);
regions.Add(new PlateOverlapRegion(region.Vertices.Select(point => point + origin),
fragment.Area));
}
if (!PolygonAreaMoments.TryCombine(moments, out var combined))
throw new ArithmeticException("Combined overlap area moments are invalid.");
var centroid = combined.Centroid + origin;
if (!OverlapMaterial.IsFinite(centroid))
throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id,
a.Input.Name, b.Input.Name, regions));
a.Input.Name, b.Input.Name, regions, centroid));
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
@@ -25,7 +25,7 @@ public sealed class PlateOverlapPair
private readonly Box bounds;
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
List<PlateOverlapRegion> regions)
List<PlateOverlapRegion> regions, Vector centroid)
{
PartAId = partAId;
PartBId = partBId;
@@ -33,6 +33,7 @@ public sealed class PlateOverlapPair
PartBName = partBName;
Regions = regions.AsReadOnly();
Area = regions.Sum(region => region.Area);
Centroid = centroid;
var points = regions.SelectMany(region => region.Vertices).ToArray();
var left = points.Min(point => point.X);
var bottom = points.Min(point => point.Y);
@@ -47,6 +48,11 @@ public sealed class PlateOverlapPair
/// <summary>Convex fragments, not connected islands; no mutable kernel polygons are exposed.</summary>
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
public double Area { get; }
/// <summary>
/// Finite world-coordinate area centroid of all shared material, after hole subtraction.
/// This can lie outside disconnected or concave shared material. Returned by value.
/// </summary>
public Vector Centroid { get; }
/// <summary>A fresh world-coordinate bounds copy.</summary>
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
}
@@ -0,0 +1,101 @@
using System.Collections.Generic;
namespace OpenNest.Geometry;
/// <summary>
/// Positive area and area centroid of a simple polygon, independent of winding.
/// Moments are evaluated about a nearby origin rather than the world origin.
/// </summary>
public readonly struct PolygonAreaMoments
{
private PolygonAreaMoments(double area, Vector centroid)
{
Area = area;
Centroid = centroid;
}
public double Area { get; }
public Vector Centroid { get; }
/// <summary>
/// Accepts an open vertex list or an exactly repeated closing vertex. Returns false
/// for degenerate or nonfinite moments; does not validate polygon topology.
/// </summary>
public static bool TryCompute(IReadOnlyList<Vector> vertices, out PolygonAreaMoments moments)
{
moments = default;
if (vertices == null || vertices.Count < 3)
return false;
foreach (var point in vertices)
if (!IsFinite(point))
return false;
var origin = vertices[0];
var count = vertices.Count;
if (vertices[count - 1].X == origin.X && vertices[count - 1].Y == origin.Y)
count--;
if (count < 3)
return false;
var twiceArea = 0.0;
var momentX = 0.0;
var momentY = 0.0;
// The closing edges meet the local origin and contribute zero. The signed
// triangle fan also handles concavity without averaging polygon vertices.
for (var i = 1; i + 1 < count; i++)
{
var a = vertices[i] - origin;
var b = vertices[i + 1] - origin;
var cross = a.X * b.Y - a.Y * b.X;
twiceArea += cross;
momentX += (a.X + b.X) * cross;
momentY += (a.Y + b.Y) * cross;
}
var area = System.Math.Abs(twiceArea) * 0.5;
if (!double.IsFinite(area) || area <= 0
|| !double.IsFinite(momentX) || !double.IsFinite(momentY))
return false;
// Dividing signed moments by signed area cancels the winding, while Area
// stays positive so independently wound fragments always add material.
var centroid = origin + new Vector(momentX / twiceArea / 3, momentY / twiceArea / 3);
if (!IsFinite(centroid))
return false;
moments = new PolygonAreaMoments(area, centroid);
return true;
}
/// <summary>
/// Combines nonoverlapping, already hole-subtracted fragments using positive area
/// weights and another local origin. The centroid may lie outside the material.
/// Empty input or any invalid fragment fails the entire result.
/// </summary>
public static bool TryCombine(IEnumerable<PolygonAreaMoments> fragments, out PolygonAreaMoments moments)
{
moments = default;
if (fragments == null)
return false;
var area = 0.0;
var origin = Vector.Zero;
var firstMoment = Vector.Zero;
foreach (var fragment in fragments)
{
if (!double.IsFinite(fragment.Area) || fragment.Area <= 0 || !IsFinite(fragment.Centroid))
return false;
if (area == 0)
origin = fragment.Centroid;
firstMoment += (fragment.Centroid - origin) * fragment.Area;
area += fragment.Area;
}
if (!double.IsFinite(area) || area <= 0 || !IsFinite(firstMoment))
return false;
var centroid = origin + firstMoment / area;
if (!IsFinite(centroid))
return false;
moments = new PolygonAreaMoments(area, centroid);
return true;
}
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
}