feat(plateview): annotate overlap centroids
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user