mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-06 03:52:10 -04:00
feat(plateview): annotate overlap centroids
This commit is contained in:
1 parent
e4d07121c8
commit
a8204bb51f
19 files changed
+1608
-46
No files matched your search
@@ -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;
|
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 }
|
public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Failed, Canceled, Stale }
|
||||||
|
|
||||||
/// <summary>UI-thread lifecycle policy, independent of workers, GDI and view transforms.</summary>
|
/// <summary>UI-thread lifecycle policy, independent of workers, GDI and view transforms.</summary>
|
||||||
@@ -34,7 +34,8 @@ public sealed class OverlapReportState
|
|||||||
{
|
{
|
||||||
Clear(OverlapCheckStatus.Checking);
|
Clear(OverlapCheckStatus.Checking);
|
||||||
stamp = OverlapGeometryStamp.Capture(plate);
|
stamp = OverlapGeometryStamp.Capture(plate);
|
||||||
DisplayMode = OverlapDisplayMode.Areas;
|
if (DisplayMode == OverlapDisplayMode.Off)
|
||||||
|
DisplayMode = OverlapDisplayMode.Areas;
|
||||||
return Generation;
|
return Generation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,14 +150,26 @@ public static class PlateOverlapAnalyzer
|
|||||||
var result = Collision.Check(localA.Outer, localB.Outer, localA.Holes, localB.Holes);
|
var result = Collision.Check(localA.Outer, localB.Outer, localA.Holes, localB.Holes);
|
||||||
if (!result.Overlaps)
|
if (!result.Overlaps)
|
||||||
continue;
|
continue;
|
||||||
var regions = result.OverlapRegions.Select(region => new PlateOverlapRegion(
|
// Evaluate every hole-subtracted fragment before restoring world space.
|
||||||
region.Vertices.Select(point => point + origin),
|
// A failed moment must make this pair incomplete, never an origin marker.
|
||||||
OverlapMaterial.Area(region.Vertices))).ToList();
|
var moments = new List<PolygonAreaMoments>();
|
||||||
var area = regions.Sum(region => region.Area);
|
var regions = new List<PlateOverlapRegion>();
|
||||||
if (!double.IsFinite(area) || area <= 0)
|
foreach (var region in result.OverlapRegions)
|
||||||
throw new ArithmeticException("Overlap area is not finite and positive.");
|
{
|
||||||
|
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,
|
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))
|
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public sealed class PlateOverlapPair
|
|||||||
private readonly Box bounds;
|
private readonly Box bounds;
|
||||||
|
|
||||||
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
|
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
|
||||||
List<PlateOverlapRegion> regions)
|
List<PlateOverlapRegion> regions, Vector centroid)
|
||||||
{
|
{
|
||||||
PartAId = partAId;
|
PartAId = partAId;
|
||||||
PartBId = partBId;
|
PartBId = partBId;
|
||||||
@@ -33,6 +33,7 @@ public sealed class PlateOverlapPair
|
|||||||
PartBName = partBName;
|
PartBName = partBName;
|
||||||
Regions = regions.AsReadOnly();
|
Regions = regions.AsReadOnly();
|
||||||
Area = regions.Sum(region => region.Area);
|
Area = regions.Sum(region => region.Area);
|
||||||
|
Centroid = centroid;
|
||||||
var points = regions.SelectMany(region => region.Vertices).ToArray();
|
var points = regions.SelectMany(region => region.Vertices).ToArray();
|
||||||
var left = points.Min(point => point.X);
|
var left = points.Min(point => point.X);
|
||||||
var bottom = points.Min(point => point.Y);
|
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>
|
/// <summary>Convex fragments, not connected islands; no mutable kernel polygons are exposed.</summary>
|
||||||
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
|
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
|
||||||
public double Area { 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>
|
/// <summary>A fresh world-coordinate bounds copy.</summary>
|
||||||
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Diagnostics;
|
||||||
|
|
||||||
|
public class OverlapHoverPagesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CrowdedCoincidentPairsRemainAccessibleInOrderWithinEveryPageBudget()
|
||||||
|
{
|
||||||
|
var parts = Enumerable.Range(1, 12).Select(i => Rectangle($"part-{i}")).ToArray();
|
||||||
|
var report = PlateOverlapAnalyzer.Analyze(parts);
|
||||||
|
var pairs = OverlapPairPresentation.HitTest(report.Pairs.Reverse(), p => p, new Vector(2, 2), 96);
|
||||||
|
var text = string.Join("\n\n", pairs.Select(p => OverlapPairPresentation.Details(p, Units.Millimeters, CultureInfo.InvariantCulture)));
|
||||||
|
var pages = OverlapHoverPages.Create(text, pairs.Count, 10, 45, s => s.Length);
|
||||||
|
|
||||||
|
Assert.True(pages.PageCount > 1);
|
||||||
|
Assert.False(pages.NeedsLargerViewport);
|
||||||
|
var displayed = ReadAll(pages, 10, 45);
|
||||||
|
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.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));
|
||||||
|
Assert.Contains("PgUp/PgDn", string.Join("", pages.NavigationLines));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(8, 12)]
|
||||||
|
[InlineData(4, 40)]
|
||||||
|
public void OneDetailWithLongUnbrokenNamesPagesWithoutDroppingText(int rows, int width)
|
||||||
|
{
|
||||||
|
var name = string.Concat(Enumerable.Repeat("veryLongName😀e\u0301", 30));
|
||||||
|
var pair = Assert.Single(PlateOverlapAnalyzer.Analyze(new[] { Rectangle(name), Rectangle("tail") }).Pairs);
|
||||||
|
var text = OverlapPairPresentation.Details(pair, Units.Inches, CultureInfo.InvariantCulture);
|
||||||
|
var pages = OverlapHoverPages.Create(text, 1, rows, width, s => new StringInfo(s).LengthInTextElements);
|
||||||
|
|
||||||
|
Assert.True(pages.PageCount > 1);
|
||||||
|
Assert.False(pages.NeedsLargerViewport);
|
||||||
|
Assert.Equal(text.Replace("\n", ""), ReadAll(pages, rows, width, s => new StringInfo(s).LengthInTextElements));
|
||||||
|
Assert.All(AllLines(text, rows, width), line =>
|
||||||
|
{
|
||||||
|
Assert.False(line.Length > 0 && char.IsLowSurrogate(line[0]));
|
||||||
|
Assert.False(line.StartsWith("\u0301"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavigationClampsAtBothEndsAndNewHoverStartsAtFirstPage()
|
||||||
|
{
|
||||||
|
const string text = "one\ntwo\nthree\nfour\nfive\nsix";
|
||||||
|
var pages = OverlapHoverPages.Create(text, 2, 4, 40, s => s.Length);
|
||||||
|
Assert.True(pages.PageCount > 1);
|
||||||
|
pages.MovePage(int.MaxValue);
|
||||||
|
Assert.Equal(pages.PageCount - 1, pages.PageIndex);
|
||||||
|
pages.MovePage(1);
|
||||||
|
Assert.Equal(pages.PageCount - 1, pages.PageIndex);
|
||||||
|
pages.MovePage(int.MinValue);
|
||||||
|
Assert.Equal(0, pages.PageIndex);
|
||||||
|
pages.MovePage(-1);
|
||||||
|
Assert.Equal(0, pages.PageIndex);
|
||||||
|
pages.MovePage(1);
|
||||||
|
Assert.Equal(0, OverlapHoverPages.Create(text, 2, 4, 40, s => s.Length).PageIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FittingDetailsDoNotSpendSpaceOnNavigation()
|
||||||
|
{
|
||||||
|
var pages = OverlapHoverPages.Create("Pair 1/2\narea\ncentroid", 1, 3, 40, s => s.Length);
|
||||||
|
Assert.Equal(1, pages.PageCount);
|
||||||
|
Assert.Empty(pages.NavigationLines);
|
||||||
|
Assert.Equal(new[] { "Pair 1/2", "area", "centroid" }, pages.Lines);
|
||||||
|
pages.MovePage(1);
|
||||||
|
Assert.Equal(0, pages.PageIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 40)]
|
||||||
|
[InlineData(3, 0)]
|
||||||
|
[InlineData(1, 5)]
|
||||||
|
[InlineData(4, 0.5)]
|
||||||
|
public void ImpossibleViewportExplicitlyRequestsMoreSpaceInsteadOfDroppingDetails(int rows, double width)
|
||||||
|
{
|
||||||
|
var pages = OverlapHoverPages.Create("Pair 1/2\narea\ncentroid", 1, rows, width, s => s.Length);
|
||||||
|
Assert.True(pages.NeedsLargerViewport);
|
||||||
|
Assert.Empty(pages.Lines);
|
||||||
|
Assert.Equal(0, pages.PageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadAll(OverlapHoverPages pages, int rows, double width, Func<string, double>? measure = null)
|
||||||
|
{
|
||||||
|
measure ??= s => s.Length;
|
||||||
|
var content = new List<string>();
|
||||||
|
for (var index = 0; index < pages.PageCount; index++)
|
||||||
|
{
|
||||||
|
Assert.Equal(index, pages.PageIndex);
|
||||||
|
Assert.InRange(pages.Lines.Count + pages.NavigationLines.Count, 1, rows);
|
||||||
|
Assert.All(pages.Lines.Concat(pages.NavigationLines), line => Assert.InRange(measure(line), 0, width));
|
||||||
|
content.AddRange(pages.Lines);
|
||||||
|
pages.MovePage(1);
|
||||||
|
}
|
||||||
|
return string.Concat(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> AllLines(string text, int rows, int width)
|
||||||
|
{
|
||||||
|
var pages = OverlapHoverPages.Create(text, 1, rows, width, s => new StringInfo(s).LengthInTextElements);
|
||||||
|
for (var i = 0; i < pages.PageCount; i++)
|
||||||
|
{
|
||||||
|
foreach (var line in pages.Lines)
|
||||||
|
yield return line;
|
||||||
|
pages.MovePage(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Part Rectangle(string name)
|
||||||
|
{
|
||||||
|
var program = new Program(Mode.Absolute);
|
||||||
|
program.Codes.Add(new RapidMove(0, 0));
|
||||||
|
program.Codes.Add(new LinearMove(4, 0));
|
||||||
|
program.Codes.Add(new LinearMove(4, 4));
|
||||||
|
program.Codes.Add(new LinearMove(0, 4));
|
||||||
|
program.Codes.Add(new LinearMove(0, 0));
|
||||||
|
return new Part(new Drawing(name, program));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Diagnostics;
|
||||||
|
|
||||||
|
public class OverlapPairPresentationTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(Units.Inches, "in")]
|
||||||
|
[InlineData(Units.Millimeters, "mm")]
|
||||||
|
public void DetailsUseCapturedNamesSequenceIdsAndSuppliedUnits(Units units, string symbol)
|
||||||
|
{
|
||||||
|
var parts = new[] { Rectangle("cutoff"), Rectangle("left"), Rectangle("right") };
|
||||||
|
parts[0].BaseDrawing.IsCutOff = true;
|
||||||
|
var snapshot = PlateOverlapAnalyzer.Capture(parts);
|
||||||
|
parts[1].BaseDrawing.Name = "changed";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1e-15)]
|
||||||
|
[InlineData(1e-100)]
|
||||||
|
[InlineData(double.Epsilon)]
|
||||||
|
[InlineData(0.000000123456)]
|
||||||
|
public void PositiveTinyValuesNeverFormatAsZero(double value)
|
||||||
|
{
|
||||||
|
var text = OverlapPairPresentation.Number(value, CultureInfo.InvariantCulture);
|
||||||
|
Assert.True(double.Parse(text, CultureInfo.InvariantCulture) > 0, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(96, 1)]
|
||||||
|
[InlineData(144, 1.5)]
|
||||||
|
[InlineData(192, 2)]
|
||||||
|
public void HitRadiusIsScreenSizedDpiScaledAndInclusive(int dpi, double factor)
|
||||||
|
{
|
||||||
|
Assert.Equal(6 * factor, OverlapPairPresentation.MarkerHalfSize(dpi));
|
||||||
|
Assert.Equal(10 * factor, OverlapPairPresentation.HitRadius(dpi));
|
||||||
|
var pairs = PlateOverlapAnalyzer.Analyze(new[] { Rectangle("a"), Rectangle("b") }).Pairs;
|
||||||
|
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 radius = OverlapPairPresentation.HitRadius(dpi);
|
||||||
|
Assert.Single(OverlapPairPresentation.HitTest(pairs, ToScreen,
|
||||||
|
center + new Vector(radius, 0), dpi));
|
||||||
|
Assert.Empty(OverlapPairPresentation.HitTest(pairs, ToScreen,
|
||||||
|
center + new Vector(radius + 0.01, 0), dpi));
|
||||||
|
Assert.Empty(OverlapPairPresentation.HitTest(pairs, ToScreen,
|
||||||
|
center + new Vector(radius, radius), dpi));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CoincidentMarkersReturnEveryPairInIdOrderNotFragmentOrder()
|
||||||
|
{
|
||||||
|
var pairs = PlateOverlapAnalyzer.Analyze(new[] { Rectangle("a"), Rectangle("b"), Rectangle("c") }).Pairs;
|
||||||
|
var hits = OverlapPairPresentation.HitTest(pairs.Reverse(), p => p, new Vector(2, 2), 96);
|
||||||
|
Assert.Equal(new[] { "1/2", "1/3", "2/3" }, hits.Select(OverlapPairPresentation.Label));
|
||||||
|
Assert.All(hits, p => Assert.True(p.Regions.Count > 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Part Rectangle(string name)
|
||||||
|
{
|
||||||
|
var program = new Program(Mode.Absolute);
|
||||||
|
program.Codes.Add(new RapidMove(0, 0));
|
||||||
|
program.Codes.Add(new LinearMove(4, 0));
|
||||||
|
program.Codes.Add(new LinearMove(4, 4));
|
||||||
|
program.Codes.Add(new LinearMove(0, 4));
|
||||||
|
program.Codes.Add(new LinearMove(0, 0));
|
||||||
|
return new Part(new Drawing(name, program));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,6 +134,22 @@ public class OverlapReportStateTests
|
|||||||
Assert.Null(state.Report);
|
Assert.Null(state.Report);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(OverlapDisplayMode.Off, OverlapDisplayMode.Areas)]
|
||||||
|
[InlineData(OverlapDisplayMode.Areas, OverlapDisplayMode.Areas)]
|
||||||
|
[InlineData(OverlapDisplayMode.Centroids, OverlapDisplayMode.Centroids)]
|
||||||
|
[InlineData(OverlapDisplayMode.Both, OverlapDisplayMode.Both)]
|
||||||
|
public void CheckAndRecheckPreserveVisibleMode(OverlapDisplayMode chosen, OverlapDisplayMode expected)
|
||||||
|
{
|
||||||
|
var plate = PlateWithParts();
|
||||||
|
var state = new OverlapReportState { DisplayMode = chosen };
|
||||||
|
var request = state.Begin(plate);
|
||||||
|
Assert.Equal(expected, state.DisplayMode);
|
||||||
|
Assert.True(state.TryPublish(request, plate, Analyze(plate)));
|
||||||
|
state.Begin(plate);
|
||||||
|
Assert.Equal(expected, state.DisplayMode);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NewPlateResetsStateButRetainsDocumentDisplayPreference()
|
public void NewPlateResetsStateButRetainsDocumentDisplayPreference()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class PlateOverlapAnalyzerTests
|
|||||||
var pair = Assert.Single(report.Pairs);
|
var pair = Assert.Single(report.Pairs);
|
||||||
Assert.Equal((0, 1), (pair.PartAId, pair.PartBId));
|
Assert.Equal((0, 1), (pair.PartAId, pair.PartBId));
|
||||||
Assert.Equal(0.5, pair.Area, 9);
|
Assert.Equal(0.5, pair.Area, 9);
|
||||||
|
AssertCentroid(pair, 0.75, 0.5);
|
||||||
Assert.NotEmpty(pair.Regions);
|
Assert.NotEmpty(pair.Regions);
|
||||||
Assert.All(pair.Regions, region =>
|
Assert.All(pair.Regions, region =>
|
||||||
{
|
{
|
||||||
@@ -43,6 +44,7 @@ public class PlateOverlapAnalyzerTests
|
|||||||
Rectangle(0, 0, 4, 4), Rectangle(1, 1, 1, 1)
|
Rectangle(0, 0, 4, 4), Rectangle(1, 1, 1, 1)
|
||||||
}).Pairs);
|
}).Pairs);
|
||||||
Assert.Equal(1, pair.Area, 9);
|
Assert.Equal(1, pair.Area, 9);
|
||||||
|
AssertCentroid(pair, 1.5, 1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -84,7 +86,9 @@ public class PlateOverlapAnalyzerTests
|
|||||||
insert.Location = new Vector(0.5, 1.5);
|
insert.Location = new Vector(0.5, 1.5);
|
||||||
report = PlateOverlapAnalyzer.Analyze(swap ? new[] { insert, frame } : new[] { frame, insert });
|
report = PlateOverlapAnalyzer.Analyze(swap ? new[] { insert, frame } : new[] { frame, insert });
|
||||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||||
Assert.Equal(0.5, Assert.Single(report.Pairs).Area, 9);
|
var pair = Assert.Single(report.Pairs);
|
||||||
|
Assert.Equal(0.5, pair.Area, 9);
|
||||||
|
AssertCentroid(pair, 0.75, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -94,7 +98,9 @@ public class PlateOverlapAnalyzerTests
|
|||||||
var b = WithContours(Square(0, 0, 4), Square(2, 2, 1));
|
var b = WithContours(Square(0, 0, 4), Square(2, 2, 1));
|
||||||
var report = PlateOverlapAnalyzer.Analyze(new[] { a, b });
|
var report = PlateOverlapAnalyzer.Analyze(new[] { a, b });
|
||||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||||
Assert.Equal(14, Assert.Single(report.Pairs).Area, 8);
|
var pair = Assert.Single(report.Pairs);
|
||||||
|
Assert.Equal(14, pair.Area, 8);
|
||||||
|
AssertCentroid(pair, 28.5 / 14, 28.5 / 14);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -109,6 +115,7 @@ public class PlateOverlapAnalyzerTests
|
|||||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||||
var pair = Assert.Single(report.Pairs);
|
var pair = Assert.Single(report.Pairs);
|
||||||
Assert.Equal(2, pair.Area, 8);
|
Assert.Equal(2, pair.Area, 8);
|
||||||
|
AssertCentroid(pair, 1.5, 2.5);
|
||||||
Assert.All(pair.Regions, region => Assert.True(
|
Assert.All(pair.Regions, region => Assert.True(
|
||||||
region.Vertices.All(p => p.X <= 1) || region.Vertices.All(p => p.X >= 2)));
|
region.Vertices.All(p => p.X <= 1) || region.Vertices.All(p => p.X >= 2)));
|
||||||
}
|
}
|
||||||
@@ -127,6 +134,7 @@ public class PlateOverlapAnalyzerTests
|
|||||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||||
var pair = Assert.Single(report.Pairs);
|
var pair = Assert.Single(report.Pairs);
|
||||||
Assert.Equal(0.5, pair.Area, 7);
|
Assert.Equal(0.5, pair.Area, 7);
|
||||||
|
AssertCentroid(pair, x + 0.75, y + 0.5);
|
||||||
Assert.Equal(x + 0.5, pair.Bounds.Left, 7);
|
Assert.Equal(x + 0.5, pair.Bounds.Left, 7);
|
||||||
Assert.Equal(y + 1, pair.Bounds.Top, 7);
|
Assert.Equal(y + 1, pair.Bounds.Top, 7);
|
||||||
}
|
}
|
||||||
@@ -141,7 +149,9 @@ public class PlateOverlapAnalyzerTests
|
|||||||
part.Location = new Vector(10, 10);
|
part.Location = new Vector(10, 10);
|
||||||
var report = PlateOverlapAnalyzer.Analyze(new[] { part, Rectangle(8, 9, 2, 1) });
|
var report = PlateOverlapAnalyzer.Analyze(new[] { part, Rectangle(8, 9, 2, 1) });
|
||||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||||
Assert.Equal(2, Assert.Single(report.Pairs).Area, 8);
|
var pair = Assert.Single(report.Pairs);
|
||||||
|
Assert.Equal(2, pair.Area, 8);
|
||||||
|
AssertCentroid(pair, 9, 9.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -297,6 +307,10 @@ public class PlateOverlapAnalyzerTests
|
|||||||
Assert.Throws<NotSupportedException>(() => ((IList<Vector>)region.Vertices)[0] = new Vector(99, 99));
|
Assert.Throws<NotSupportedException>(() => ((IList<Vector>)region.Vertices)[0] = new Vector(99, 99));
|
||||||
pair.Bounds.X = 99;
|
pair.Bounds.X = 99;
|
||||||
Assert.Equal(0, pair.Bounds.X);
|
Assert.Equal(0, pair.Bounds.X);
|
||||||
|
var centroid = pair.Centroid;
|
||||||
|
centroid.X = 99;
|
||||||
|
centroid.Y = 99;
|
||||||
|
AssertCentroid(pair, 0.5, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -368,7 +382,9 @@ public class PlateOverlapAnalyzerTests
|
|||||||
small.Location = new Vector(999999998, 999999998);
|
small.Location = new Vector(999999998, 999999998);
|
||||||
var report = PlateOverlapAnalyzer.Analyze(swap ? new[] { small, outer } : new[] { outer, small });
|
var report = PlateOverlapAnalyzer.Analyze(swap ? new[] { small, outer } : new[] { outer, small });
|
||||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||||
Assert.Equal(1, Assert.Single(report.Pairs).Area, 8);
|
var pair = Assert.Single(report.Pairs);
|
||||||
|
Assert.Equal(1, pair.Area, 8);
|
||||||
|
AssertCentroid(pair, 999999998.5, 999999998.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -423,6 +439,68 @@ public class PlateOverlapAnalyzerTests
|
|||||||
Assert.Throws<OperationCanceledException>(() => PlateOverlapAnalyzer.Analyze(snapshot, cancellation.Token));
|
Assert.Throws<OperationCanceledException>(() => PlateOverlapAnalyzer.Analyze(snapshot, cancellation.Token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(1000000000)]
|
||||||
|
[InlineData(-1000000000)]
|
||||||
|
public void Analyze_UnequalDisconnectedOverlapWeightsAllFragments(double offset)
|
||||||
|
{
|
||||||
|
var u = WithContours(new[]
|
||||||
|
{
|
||||||
|
new Vector(0, 0), new Vector(6, 0), new Vector(6, 3), new Vector(3, 3),
|
||||||
|
new Vector(3, 1), new Vector(1, 1), new Vector(1, 3), new Vector(0, 3)
|
||||||
|
});
|
||||||
|
u.Location = new Vector(offset, offset);
|
||||||
|
|
||||||
|
var report = PlateOverlapAnalyzer.Analyze(new[] { u, Rectangle(offset, offset + 2, 6, 1) });
|
||||||
|
|
||||||
|
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||||
|
var pair = Assert.Single(report.Pairs);
|
||||||
|
Assert.Equal(4, pair.Area, 8);
|
||||||
|
AssertCentroid(pair, offset + 3.5, offset + 2.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_RotatedTriangleHasTrueMaterialCentroid()
|
||||||
|
{
|
||||||
|
var triangle = WithContours(new[] { new Vector(0, 0), new Vector(6, 0), new Vector(0, 3) });
|
||||||
|
triangle.Rotate(System.Math.PI / 2);
|
||||||
|
triangle.Location = new Vector(10, 20);
|
||||||
|
|
||||||
|
var report = PlateOverlapAnalyzer.Analyze(new[] { Rectangle(0, 0, 30, 30), triangle });
|
||||||
|
|
||||||
|
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||||
|
var pair = Assert.Single(report.Pairs);
|
||||||
|
Assert.Equal(9, pair.Area, 8);
|
||||||
|
AssertCentroid(pair, 9, 22);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_OverflowingPairMomentsAreIncompleteAndRetainOtherPairs()
|
||||||
|
{
|
||||||
|
var huge = Rectangle(0, 0, 1e103, 1e103);
|
||||||
|
var report = PlateOverlapAnalyzer.Analyze(new[]
|
||||||
|
{
|
||||||
|
huge, new Part(huge.BaseDrawing), Rectangle(-2, -2, 1, 1), Rectangle(-2, -2, 1, 1)
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.False(report.IsComplete);
|
||||||
|
var issue = Assert.Single(report.Issues);
|
||||||
|
Assert.Equal((0, (int?)1), (issue.PartAId, issue.PartBId));
|
||||||
|
Assert.Contains("moment", issue.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var valid = Assert.Single(report.Pairs);
|
||||||
|
Assert.Equal((2, 3), (valid.PartAId, valid.PartBId));
|
||||||
|
AssertCentroid(valid, -1.5, -1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertCentroid(PlateOverlapPair pair, double x, double y)
|
||||||
|
{
|
||||||
|
Assert.True(double.IsFinite(pair.Centroid.X));
|
||||||
|
Assert.True(double.IsFinite(pair.Centroid.Y));
|
||||||
|
Assert.Equal(x, pair.Centroid.X, 7);
|
||||||
|
Assert.Equal(y, pair.Centroid.Y, 7);
|
||||||
|
}
|
||||||
|
|
||||||
private static Part Rectangle(double x, double y, double width, double height) =>
|
private static Part Rectangle(double x, double y, double width, double height) =>
|
||||||
new(WithContours(new[] { new Vector(0, 0), new Vector(width, 0),
|
new(WithContours(new[] { new Vector(0, 0), new Vector(width, 0),
|
||||||
new Vector(width, height), new Vector(0, height) }).BaseDrawing, new Vector(x, y));
|
new Vector(width, height), new Vector(0, height) }).BaseDrawing, new Vector(x, y));
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Geometry;
|
||||||
|
|
||||||
|
public class PolygonAreaMomentsTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, false, false)]
|
||||||
|
[InlineData(0, true, false)]
|
||||||
|
[InlineData(0, false, true)]
|
||||||
|
[InlineData(0, true, true)]
|
||||||
|
[InlineData(1000000000, false, false)]
|
||||||
|
[InlineData(1000000000, true, true)]
|
||||||
|
[InlineData(-1000000000, false, true)]
|
||||||
|
[InlineData(-1000000000, true, false)]
|
||||||
|
public void TryCompute_RectangleUsesSignedMomentsWithLocalOrigin(double offset, bool reverse, bool closed)
|
||||||
|
{
|
||||||
|
var vertices = Rectangle(offset + 0.5, offset, 0.5, 1);
|
||||||
|
if (reverse)
|
||||||
|
Array.Reverse(vertices);
|
||||||
|
if (closed)
|
||||||
|
vertices = vertices.Append(vertices[0]).ToArray();
|
||||||
|
|
||||||
|
Assert.True(PolygonAreaMoments.TryCompute(vertices, out var moments));
|
||||||
|
|
||||||
|
Assert.Equal(0.5, moments.Area);
|
||||||
|
Assert.Equal(offset + 0.75, moments.Centroid.X);
|
||||||
|
Assert.Equal(offset + 0.5, moments.Centroid.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void TryCompute_ConcavePolygonUsesAreaNotVertexAverage(bool reverse)
|
||||||
|
{
|
||||||
|
var vertices = new[]
|
||||||
|
{
|
||||||
|
new Vector(0, 0), new Vector(3, 0), new Vector(3, 1),
|
||||||
|
new Vector(1, 1), new Vector(1, 3), new Vector(0, 3)
|
||||||
|
};
|
||||||
|
if (reverse)
|
||||||
|
Array.Reverse(vertices);
|
||||||
|
|
||||||
|
Assert.True(PolygonAreaMoments.TryCompute(vertices, out var moments));
|
||||||
|
|
||||||
|
Assert.Equal(5, moments.Area);
|
||||||
|
Assert.Equal(1.1, moments.Centroid.X, 12);
|
||||||
|
Assert.Equal(1.1, moments.Centroid.Y, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompute_CollinearVerticesDoNotBiasCentroid()
|
||||||
|
{
|
||||||
|
var vertices = new[]
|
||||||
|
{
|
||||||
|
new Vector(0, 0), new Vector(1, 0), new Vector(2, 0), new Vector(3, 0),
|
||||||
|
new Vector(3, 2), new Vector(0, 2), new Vector(0, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(PolygonAreaMoments.TryCompute(vertices, out var moments));
|
||||||
|
|
||||||
|
Assert.Equal(6, moments.Area);
|
||||||
|
Assert.Equal(1.5, moments.Centroid.X);
|
||||||
|
Assert.Equal(1, moments.Centroid.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCompute_NearButNotExactClosingVertexIsNotDiscarded()
|
||||||
|
{
|
||||||
|
var vertices = new[]
|
||||||
|
{
|
||||||
|
new Vector(0, 0), new Vector(1, 0), new Vector(1, 1),
|
||||||
|
new Vector(0, 1), new Vector(0.000001, 0.000001)
|
||||||
|
};
|
||||||
|
// The final triangle removes area d/2 and has center (d/3, (1+d)/3).
|
||||||
|
const double d = 0.000001;
|
||||||
|
var expectedArea = 1 - d / 2;
|
||||||
|
|
||||||
|
Assert.True(PolygonAreaMoments.TryCompute(vertices, out var moments));
|
||||||
|
|
||||||
|
Assert.Equal(expectedArea, moments.Area, 14);
|
||||||
|
Assert.Equal((0.5 - d * d / 6) / expectedArea, moments.Centroid.X, 14);
|
||||||
|
Assert.Equal((0.5 - d * (1 + d) / 6) / expectedArea, moments.Centroid.Y, 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, false)]
|
||||||
|
[InlineData(0, true)]
|
||||||
|
[InlineData(1000000000, false)]
|
||||||
|
[InlineData(1000000000, true)]
|
||||||
|
[InlineData(-1000000000, false)]
|
||||||
|
public void TryCombine_UnequalDisconnectedFragmentsUsePositiveAreaWeights(double offset, bool swap)
|
||||||
|
{
|
||||||
|
Assert.True(PolygonAreaMoments.TryCompute(Rectangle(offset, offset, 1, 1), out var small));
|
||||||
|
Assert.True(PolygonAreaMoments.TryCompute(
|
||||||
|
Rectangle(offset + 4, offset, 1, 3).Reverse().ToArray(), out var large));
|
||||||
|
|
||||||
|
Assert.True(PolygonAreaMoments.TryCombine(swap ? new[] { large, small } : new[] { small, large },
|
||||||
|
out var combined));
|
||||||
|
|
||||||
|
Assert.Equal(4, combined.Area);
|
||||||
|
Assert.Equal(offset + 3.5, combined.Centroid.X);
|
||||||
|
Assert.Equal(offset + 1.25, combined.Centroid.Y);
|
||||||
|
// The centroid is in the gap, not on either material fragment.
|
||||||
|
Assert.InRange(combined.Centroid.X - offset, 1.01, 3.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> InvalidPolygons()
|
||||||
|
{
|
||||||
|
yield return new object[] { Array.Empty<Vector>() };
|
||||||
|
yield return new object[] { new[] { new Vector(1, 1), new Vector(2, 2) } };
|
||||||
|
yield return new object[] { new[] { new Vector(1, 1), new Vector(2, 2), new Vector(3, 3) } };
|
||||||
|
yield return new object[] { new[] { new Vector(1, 1), new Vector(1, 1), new Vector(1, 1) } };
|
||||||
|
yield return new object[] { new[] { new Vector(0, 0), new Vector(double.NaN, 0), new Vector(0, 1) } };
|
||||||
|
yield return new object[] { new[] { new Vector(0, 0), new Vector(1, double.PositiveInfinity), new Vector(0, 1) } };
|
||||||
|
yield return new object[] { new[] { new Vector(double.NegativeInfinity, 0), new Vector(1, 0), new Vector(0, 1) } };
|
||||||
|
yield return new object[] { Rectangle(0, 0, 1e200, 1e200) };
|
||||||
|
yield return new object[] { Rectangle(0, 0, 1e103, 1e103) };
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(InvalidPolygons))]
|
||||||
|
public void TryCompute_DegenerateNonfiniteOrOverflowingMomentsAreRejected(Vector[] vertices)
|
||||||
|
{
|
||||||
|
Assert.False(PolygonAreaMoments.TryCompute(vertices, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryCombine_EmptyOrInvalidFragmentRejectsWholeResult()
|
||||||
|
{
|
||||||
|
Assert.True(PolygonAreaMoments.TryCompute(Rectangle(1, 1, 1, 1), out var valid));
|
||||||
|
|
||||||
|
Assert.False(PolygonAreaMoments.TryCombine(Array.Empty<PolygonAreaMoments>(), out _));
|
||||||
|
Assert.False(PolygonAreaMoments.TryCombine(new[] { valid, default(PolygonAreaMoments) }, out _));
|
||||||
|
Assert.False(PolygonAreaMoments.TryCombine(new[] { default(PolygonAreaMoments), valid }, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector[] Rectangle(double x, double y, double width, double height) =>
|
||||||
|
new[] { new Vector(x, y), new Vector(x + width, y),
|
||||||
|
new Vector(x + width, y + height), new Vector(x, y + height) };
|
||||||
|
}
|
||||||
@@ -266,11 +266,15 @@ public class PlateOverlapOverlayTests
|
|||||||
var cancel = Menu(host, "mnuOverlapCancel");
|
var cancel = Menu(host, "mnuOverlapCancel");
|
||||||
var off = Menu(host, "mnuOverlapOff");
|
var off = Menu(host, "mnuOverlapOff");
|
||||||
var areas = Menu(host, "mnuOverlapAreas");
|
var areas = Menu(host, "mnuOverlapAreas");
|
||||||
|
var centroids = Menu(host, "mnuOverlapCentroids");
|
||||||
|
var both = Menu(host, "mnuOverlapBoth");
|
||||||
Assert.False(check.Enabled);
|
Assert.False(check.Enabled);
|
||||||
Assert.False(cancel.Enabled);
|
Assert.False(cancel.Enabled);
|
||||||
Assert.False(off.Enabled);
|
Assert.False(off.Enabled);
|
||||||
Assert.False(areas.Enabled);
|
Assert.False(areas.Enabled);
|
||||||
Assert.All(new[] { check, cancel, off, areas }, item => Assert.Equal(Keys.None, item.ShortcutKeys));
|
Assert.False(centroids.Enabled);
|
||||||
|
Assert.False(both.Enabled);
|
||||||
|
Assert.All(new[] { check, cancel, off, areas, centroids, both }, item => Assert.Equal(Keys.None, item.ShortcutKeys));
|
||||||
|
|
||||||
using var first = new EditNestForm(new Nest("first")) { MdiParent = host };
|
using var first = new EditNestForm(new Nest("first")) { MdiParent = host };
|
||||||
first.Show();
|
first.Show();
|
||||||
@@ -286,6 +290,19 @@ public class PlateOverlapOverlayTests
|
|||||||
Assert.True(off.Checked);
|
Assert.True(off.Checked);
|
||||||
Assert.False(areas.Checked);
|
Assert.False(areas.Checked);
|
||||||
Assert.Equal(OverlapDisplayMode.Areas, second.OverlapDisplay);
|
Assert.Equal(OverlapDisplayMode.Areas, second.OverlapDisplay);
|
||||||
|
centroids.PerformClick();
|
||||||
|
Assert.True(centroids.Checked);
|
||||||
|
Assert.False(off.Checked);
|
||||||
|
Assert.Equal(OverlapDisplayMode.Centroids, first.OverlapDisplay);
|
||||||
|
second.Activate();
|
||||||
|
both.PerformClick();
|
||||||
|
Assert.True(both.Checked);
|
||||||
|
Assert.False(centroids.Checked);
|
||||||
|
Assert.Equal(OverlapDisplayMode.Both, second.OverlapDisplay);
|
||||||
|
first.Activate();
|
||||||
|
Assert.True(centroids.Checked);
|
||||||
|
Assert.False(both.Checked);
|
||||||
|
off.PerformClick();
|
||||||
|
|
||||||
var previous = SynchronizationContext.Current;
|
var previous = SynchronizationContext.Current;
|
||||||
var context = new PumpContext();
|
var context = new PumpContext();
|
||||||
@@ -320,6 +337,458 @@ public class PlateOverlapOverlayTests
|
|||||||
Assert.False(cancel.Enabled);
|
Assert.False(cancel.Enabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CheckCommandCapturesUnitsAndNamesUntilTheNextCheck() => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var form = new EditNestForm(new Nest("units") { Units = Units.Millimeters });
|
||||||
|
form.Show();
|
||||||
|
form.PlateView.Plate.Parts.Add(Rectangle());
|
||||||
|
form.PlateView.Plate.Parts.Add(Rectangle(1));
|
||||||
|
form.OverlapDisplay = OverlapDisplayMode.Both;
|
||||||
|
var previous = SynchronizationContext.Current;
|
||||||
|
var context = new PumpContext();
|
||||||
|
var started = new TaskCompletionSource<PlateOverlapSnapshot>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var result = new TaskCompletionSource<PlateOverlapReport>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
Task? task = null;
|
||||||
|
SynchronizationContext.SetSynchronizationContext(context);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
form.PlateView.OverlapOverlay.Analyze = (snapshot, _) =>
|
||||||
|
{
|
||||||
|
started.TrySetResult(snapshot);
|
||||||
|
return WaitForWorkerResult(result.Task);
|
||||||
|
};
|
||||||
|
task = form.CheckOverlapsAsync();
|
||||||
|
var snapshot = WaitForSnapshot(started.Task);
|
||||||
|
form.Nest.Units = Units.Inches;
|
||||||
|
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);
|
||||||
|
form.PlateView.OverlapOverlay.UpdateHover(center);
|
||||||
|
Assert.Contains(" mm²", form.PlateView.OverlapOverlay.HoverText);
|
||||||
|
Assert.Contains("same name / same name", form.PlateView.OverlapOverlay.HoverText);
|
||||||
|
Assert.DoesNotContain("changed", form.PlateView.OverlapOverlay.HoverText);
|
||||||
|
form.PlateView.OverlapOverlay.Analyze = PlateOverlapAnalyzer.Analyze;
|
||||||
|
task = form.CheckOverlapsAsync();
|
||||||
|
Assert.Null(form.PlateView.OverlapOverlay.HoverText);
|
||||||
|
context.Pump(task);
|
||||||
|
form.PlateView.OverlapOverlay.UpdateHover(center);
|
||||||
|
Assert.Contains(" in²", form.PlateView.OverlapOverlay.HoverText);
|
||||||
|
Assert.Contains("changed / same name", form.PlateView.OverlapOverlay.HoverText);
|
||||||
|
Assert.Equal(OverlapDisplayMode.Both, form.OverlapDisplay);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
form.CancelOverlapCheck();
|
||||||
|
result.TrySetCanceled();
|
||||||
|
try { if (task != null) context.Pump(task); }
|
||||||
|
finally { SynchronizationContext.SetSynchronizationContext(previous); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CleanupReleasesDequeuedWorkersEvenWithoutCompleteOrFail() => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
var first = run.Start();
|
||||||
|
var old = run.Next();
|
||||||
|
var second = run.Start();
|
||||||
|
var current = run.Next();
|
||||||
|
run.Dispose();
|
||||||
|
Assert.True(old.Result.Task.IsCompleted);
|
||||||
|
Assert.True(current.Result.Task.IsCompleted);
|
||||||
|
Assert.True(first.IsCompletedSuccessfully);
|
||||||
|
Assert.True(second.IsCompletedSuccessfully);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(OverlapDisplayMode.Areas)]
|
||||||
|
[InlineData(OverlapDisplayMode.Centroids)]
|
||||||
|
[InlineData(OverlapDisplayMode.Both)]
|
||||||
|
public void RecheckPreservesEachVisibleModeAndDisplayOnlyRepaints(OverlapDisplayMode mode) => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
run.View.OverlapDisplay = mode;
|
||||||
|
run.Finish();
|
||||||
|
Assert.Equal(mode, run.View.OverlapDisplay);
|
||||||
|
run.Finish();
|
||||||
|
Assert.Equal(mode, run.View.OverlapDisplay);
|
||||||
|
var report = run.View.OverlapReport;
|
||||||
|
using var image = new Bitmap(300, 300);
|
||||||
|
using var graphics = Graphics.FromImage(image);
|
||||||
|
foreach (var display in Enum.GetValues<OverlapDisplayMode>())
|
||||||
|
{
|
||||||
|
run.View.OverlapDisplay = display;
|
||||||
|
run.View.OverlapOverlay.Draw(graphics);
|
||||||
|
Assert.Same(report, run.View.OverlapReport);
|
||||||
|
}
|
||||||
|
Assert.Equal(2, run.Calls);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CentroidMarkerUsesGraphCoordinatesAndFixedScreenSizeAcrossZoom() => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
run.View.OverlapDisplay = OverlapDisplayMode.Centroids;
|
||||||
|
run.Finish();
|
||||||
|
var pair = Assert.Single(run.View.OverlapReport.Pairs);
|
||||||
|
using var image = new Bitmap(500, 500);
|
||||||
|
using var graphics = Graphics.FromImage(image);
|
||||||
|
foreach (var zoom in new[] { 20f, 2f })
|
||||||
|
{
|
||||||
|
run.View.ZoomToPoint(new Vector(), zoom);
|
||||||
|
run.View.SetOrigin(60, 320);
|
||||||
|
graphics.ResetTransform();
|
||||||
|
graphics.Clear(Color.White);
|
||||||
|
graphics.TranslateTransform(60, 320);
|
||||||
|
run.View.OverlapOverlay.Draw(graphics);
|
||||||
|
Assert.Null(run.View.OverlapOverlay.CachedPath);
|
||||||
|
var center = run.View.PointWorldToControl(pair.Centroid);
|
||||||
|
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());
|
||||||
|
Assert.Equal(Color.White.ToArgb(), image.GetPixel(center.X - 60, center.Y).ToArgb());
|
||||||
|
run.View.MoveTo(new Point(center.X + (int)OverlapPairPresentation.HitRadius(run.View.DeviceDpi), center.Y));
|
||||||
|
Assert.Contains("Pair 1/2", run.View.OverlapOverlay.HoverText);
|
||||||
|
run.View.MoveTo(new Point(center.X + (int)OverlapPairPresentation.HitRadius(run.View.DeviceDpi) + 2, center.Y));
|
||||||
|
Assert.Null(run.View.OverlapOverlay.HoverText);
|
||||||
|
}
|
||||||
|
Assert.Equal(1, run.Calls);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MarkerAndHoverUsePairMaterialCentroidRatherThanBoundsCenter() => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
run.View.Plate.Parts.Clear();
|
||||||
|
var program = new Program(Mode.Absolute);
|
||||||
|
program.Codes.Add(new RapidMove(0, 0));
|
||||||
|
program.Codes.Add(new LinearMove(6, 0));
|
||||||
|
program.Codes.Add(new LinearMove(0, 3));
|
||||||
|
program.Codes.Add(new LinearMove(0, 0));
|
||||||
|
var drawing = new Drawing("triangle", program);
|
||||||
|
run.View.Plate.Parts.Add(new Part(drawing));
|
||||||
|
run.View.Plate.Parts.Add(new Part(drawing));
|
||||||
|
run.View.OverlapDisplay = OverlapDisplayMode.Centroids;
|
||||||
|
run.Finish();
|
||||||
|
run.View.ZoomToPoint(new Vector(), 30);
|
||||||
|
run.View.SetOrigin(60, 220);
|
||||||
|
using var image = new Bitmap(300, 300);
|
||||||
|
using var graphics = Graphics.FromImage(image);
|
||||||
|
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));
|
||||||
|
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());
|
||||||
|
run.View.MoveTo(materialCenter);
|
||||||
|
Assert.Contains("Pair 1/2", run.View.OverlapOverlay.HoverText);
|
||||||
|
run.View.MoveTo(boundsCenter);
|
||||||
|
Assert.Null(run.View.OverlapOverlay.HoverText);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BothRetainsAreaPathAndCoincidentHoverListsEveryPairWithoutChangingSelection() => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
run.View.Plate.Parts.Clear();
|
||||||
|
run.View.Plate.Parts.Add(Rectangle());
|
||||||
|
run.View.Plate.Parts.Add(Rectangle());
|
||||||
|
run.View.Plate.Parts.Add(Rectangle());
|
||||||
|
run.Finish();
|
||||||
|
run.View.ZoomToPoint(new Vector(), 20);
|
||||||
|
run.View.SetOrigin(60, 220);
|
||||||
|
using var image = new Bitmap(300, 300);
|
||||||
|
using var graphics = Graphics.FromImage(image);
|
||||||
|
graphics.TranslateTransform(60, 220);
|
||||||
|
run.View.OverlapOverlay.Draw(graphics);
|
||||||
|
var path = run.View.OverlapOverlay.CachedPath;
|
||||||
|
run.View.OverlapDisplay = OverlapDisplayMode.Both;
|
||||||
|
run.View.OverlapOverlay.Draw(graphics);
|
||||||
|
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
|
||||||
|
var center = run.View.PointWorldToControl(new Vector(2, 2));
|
||||||
|
run.View.MoveTo(center);
|
||||||
|
var text = run.View.OverlapOverlay.HoverText!;
|
||||||
|
Assert.True(text.IndexOf("Pair 1/2", StringComparison.Ordinal) < text.IndexOf("Pair 1/3", StringComparison.Ordinal));
|
||||||
|
Assert.True(text.IndexOf("Pair 1/3", StringComparison.Ordinal) < text.IndexOf("Pair 2/3", StringComparison.Ordinal));
|
||||||
|
Assert.Empty(run.View.SelectedParts);
|
||||||
|
run.View.ClickAt(center);
|
||||||
|
Assert.NotEmpty(run.View.SelectedParts); // Informational hover does not consume the selection click.
|
||||||
|
Assert.Null(run.View.OverlapOverlay.HoverText);
|
||||||
|
Assert.Equal(1, run.Calls);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PairDetailsOnlySuppressNormalTooltipWhileVisible() => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
run.View.OverlapDisplay = OverlapDisplayMode.Both;
|
||||||
|
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));
|
||||||
|
var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic;
|
||||||
|
var hoverCheck = typeof(PlateView).GetMethod("HoverCheck", flags)!;
|
||||||
|
var normalVisible = typeof(PlateView).GetField("showTooltip", flags)!;
|
||||||
|
hoverCheck.Invoke(run.View, null); // Fire the existing normal-tooltip timer deterministically.
|
||||||
|
Assert.True((bool)normalVisible.GetValue(run.View)!);
|
||||||
|
using var image = new Bitmap(300, 300);
|
||||||
|
using var graphics = Graphics.FromImage(image);
|
||||||
|
Assert.True(run.View.OverlapOverlay.DrawHover(graphics));
|
||||||
|
Assert.True((bool)normalVisible.GetValue(run.View)!);
|
||||||
|
run.View.OverlapDisplay = OverlapDisplayMode.Areas;
|
||||||
|
Assert.False(run.View.OverlapOverlay.DrawHover(graphics));
|
||||||
|
Assert.True((bool)normalVisible.GetValue(run.View)!); // PlateView now draws the original tooltip.
|
||||||
|
run.View.LeaveView();
|
||||||
|
hoverCheck.Invoke(run.View, null); // A queued timer cannot resurrect it after leave.
|
||||||
|
Assert.False((bool)normalVisible.GetValue(run.View)!);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("leave")]
|
||||||
|
[InlineData("stale-move")]
|
||||||
|
[InlineData("stale-paint")]
|
||||||
|
[InlineData("invalidate")]
|
||||||
|
[InlineData("mode")]
|
||||||
|
[InlineData("zoom")]
|
||||||
|
[InlineData("pan")]
|
||||||
|
[InlineData("resize")]
|
||||||
|
[InlineData("plate")]
|
||||||
|
[InlineData("restart-cancel")]
|
||||||
|
[InlineData("recreate")]
|
||||||
|
public void HoverClearsBeforeStaleDetailsCanBeShown(string change) => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
run.View.OverlapDisplay = OverlapDisplayMode.Both;
|
||||||
|
run.Finish();
|
||||||
|
run.View.ZoomToPoint(new Vector(), 20);
|
||||||
|
run.View.SetOrigin(60, 220);
|
||||||
|
var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid);
|
||||||
|
run.View.MoveTo(center);
|
||||||
|
Assert.NotNull(run.View.OverlapOverlay.HoverText);
|
||||||
|
switch (change)
|
||||||
|
{
|
||||||
|
case "leave": run.View.LeaveView(); break;
|
||||||
|
case "stale-move":
|
||||||
|
run.View.Plate.Parts[0].Offset(1e-10, 0);
|
||||||
|
run.View.MoveTo(center);
|
||||||
|
break;
|
||||||
|
case "stale-paint":
|
||||||
|
run.View.Plate.Parts[0].Offset(1e-10, 0);
|
||||||
|
using (var image = new Bitmap(300, 300))
|
||||||
|
using (var graphics = Graphics.FromImage(image))
|
||||||
|
Assert.False(run.View.OverlapOverlay.DrawHover(graphics));
|
||||||
|
break;
|
||||||
|
case "invalidate": run.View.InvalidateOverlapCheck(); break;
|
||||||
|
case "mode": run.View.OverlapDisplay = OverlapDisplayMode.Centroids; break;
|
||||||
|
case "zoom": run.View.ZoomToPoint(new Vector(), 2); break;
|
||||||
|
case "pan": run.View.Pan(20, 10); break;
|
||||||
|
case "resize": run.View.Size = new System.Drawing.Size(300, 200); break;
|
||||||
|
case "plate": run.View.Plate = new Plate(); break;
|
||||||
|
case "restart-cancel":
|
||||||
|
var task = run.Start();
|
||||||
|
var work = run.Next();
|
||||||
|
Assert.Null(run.View.OverlapOverlay.HoverText);
|
||||||
|
run.View.CancelOverlapCheck();
|
||||||
|
work.Complete();
|
||||||
|
run.Pump(task);
|
||||||
|
break;
|
||||||
|
case "recreate": run.View.Recreate(); break;
|
||||||
|
}
|
||||||
|
Assert.Null(run.View.OverlapOverlay.HoverText);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CrowdedHoverPagesStayInViewportAndReachLastPairWithoutSelectionOrWheelCapture() => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
PrepareCrowdedHover(run);
|
||||||
|
var report = run.View.OverlapReport;
|
||||||
|
var positions = run.View.Plate.Parts.Select(p => p.Location).ToArray();
|
||||||
|
run.View.SelectAll();
|
||||||
|
var selection = run.View.SelectedParts.ToArray();
|
||||||
|
using var image = new Bitmap(run.View.Width, run.View.Height);
|
||||||
|
using var graphics = Graphics.FromImage(image);
|
||||||
|
var overlay = run.View.OverlapOverlay;
|
||||||
|
Assert.True(overlay.DrawHover(graphics));
|
||||||
|
var pages = overlay.HoverPages;
|
||||||
|
Assert.True(pages.PageCount > 1);
|
||||||
|
var content = new List<string>();
|
||||||
|
for (var page = 0; page < pages.PageCount; page++)
|
||||||
|
{
|
||||||
|
Assert.True(overlay.DrawHover(graphics));
|
||||||
|
Assert.True(new RectangleF(PointF.Empty, run.View.ClientSize).Contains(overlay.HoverBounds));
|
||||||
|
Assert.Equal(page, pages.PageIndex);
|
||||||
|
content.AddRange(pages.Lines);
|
||||||
|
Assert.Contains("PgUp/PgDn", string.Join("", pages.NavigationLines));
|
||||||
|
Assert.True(run.View.Command(Keys.PageDown));
|
||||||
|
}
|
||||||
|
Assert.Contains("Pair 11/12", string.Concat(content));
|
||||||
|
Assert.Contains(" in²", string.Concat(content));
|
||||||
|
Assert.Equal(overlay.HoverText.Replace("\n", ""), string.Concat(content));
|
||||||
|
Assert.Equal(pages.PageCount - 1, pages.PageIndex);
|
||||||
|
Assert.True(run.View.Command(Keys.PageUp));
|
||||||
|
Assert.Equal(pages.PageCount - 2, pages.PageIndex);
|
||||||
|
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));
|
||||||
|
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));
|
||||||
|
Assert.Same(report, run.View.OverlapReport);
|
||||||
|
Assert.Equal(1, run.Calls);
|
||||||
|
|
||||||
|
var scale = run.View.ViewScale;
|
||||||
|
run.View.WheelAt(run.View.PointWorldToControl(report.Pairs[0].Centroid));
|
||||||
|
Assert.True(run.View.ViewScale > scale); // Wheel remains zoom, never paging.
|
||||||
|
Assert.Null(overlay.HoverText);
|
||||||
|
Assert.Null(overlay.HoverPages);
|
||||||
|
Assert.False(run.View.Command(Keys.PageDown));
|
||||||
|
Assert.Equal(selection, run.View.SelectedParts);
|
||||||
|
Assert.Equal(1, run.Calls);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MovingDirectlyToAnotherCoincidentGroupStartsItsFirstPage() => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
run.View.Size = new System.Drawing.Size(300, 160);
|
||||||
|
run.View.Plate.Parts.Clear();
|
||||||
|
for (var i = 0; i < 12; i++)
|
||||||
|
run.View.Plate.Parts.Add(Rectangle(i < 6 ? 0 : 8));
|
||||||
|
run.View.OverlapDisplay = OverlapDisplayMode.Both;
|
||||||
|
run.Finish();
|
||||||
|
run.View.ZoomToPoint(new Vector(), 15);
|
||||||
|
run.View.SetOrigin(60, 120);
|
||||||
|
run.View.MoveTo(run.View.PointWorldToControl(new Vector(2, 2)));
|
||||||
|
using var image = new Bitmap(300, 160);
|
||||||
|
using var graphics = Graphics.FromImage(image);
|
||||||
|
var overlay = run.View.OverlapOverlay;
|
||||||
|
Assert.True(overlay.DrawHover(graphics));
|
||||||
|
Assert.True(run.View.Command(Keys.PageDown));
|
||||||
|
Assert.Equal(1, overlay.HoverPages.PageIndex);
|
||||||
|
run.View.MoveTo(run.View.PointWorldToControl(new Vector(10, 2)));
|
||||||
|
Assert.Null(overlay.HoverPages);
|
||||||
|
Assert.True(overlay.DrawHover(graphics));
|
||||||
|
Assert.NotNull(overlay.HoverPages);
|
||||||
|
Assert.Equal(0, overlay.HoverPages.PageIndex);
|
||||||
|
Assert.Contains("Pair 7/8", string.Concat(overlay.HoverPages.Lines));
|
||||||
|
Assert.Equal(1, run.Calls);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("hover")]
|
||||||
|
[InlineData("leave")]
|
||||||
|
[InlineData("mode")]
|
||||||
|
[InlineData("stale-key")]
|
||||||
|
[InlineData("report")]
|
||||||
|
[InlineData("resize")]
|
||||||
|
[InlineData("font")]
|
||||||
|
[InlineData("click")]
|
||||||
|
[InlineData("drag")]
|
||||||
|
public void PagingResetsWithHoverLifetimeAndInactiveKeysAreNotConsumed(string change) => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
Assert.False(run.View.Command(Keys.PageDown));
|
||||||
|
PrepareCrowdedHover(run);
|
||||||
|
using var image = new Bitmap(run.View.Width, run.View.Height);
|
||||||
|
using var graphics = Graphics.FromImage(image);
|
||||||
|
var overlay = run.View.OverlapOverlay;
|
||||||
|
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);
|
||||||
|
using var largerFont = new Font(run.View.Font.FontFamily, run.View.Font.Size + 1);
|
||||||
|
switch (change)
|
||||||
|
{
|
||||||
|
case "hover": run.View.MoveTo(new Point(5, 5)); break;
|
||||||
|
case "leave": run.View.LeaveView(); break;
|
||||||
|
case "mode": run.View.OverlapDisplay = OverlapDisplayMode.Areas; break;
|
||||||
|
case "stale-key":
|
||||||
|
run.View.Plate.Parts[0].Offset(1e-10, 0);
|
||||||
|
Assert.False(run.View.Command(Keys.PageDown));
|
||||||
|
break;
|
||||||
|
case "report": run.Finish(); break;
|
||||||
|
case "resize": run.View.Width += 10; break;
|
||||||
|
case "font": run.View.Font = largerFont; break;
|
||||||
|
case "click":
|
||||||
|
run.View.ClickAt(center);
|
||||||
|
Assert.NotEmpty(run.View.SelectedParts);
|
||||||
|
break;
|
||||||
|
case "drag": run.View.DragAt(center); break;
|
||||||
|
}
|
||||||
|
Assert.Null(overlay.HoverText);
|
||||||
|
Assert.Null(overlay.HoverPages);
|
||||||
|
Assert.False(run.View.Command(Keys.PageDown));
|
||||||
|
if (change == "stale-key")
|
||||||
|
run.Finish();
|
||||||
|
run.View.OverlapDisplay = OverlapDisplayMode.Both;
|
||||||
|
run.View.MoveTo(center);
|
||||||
|
Assert.True(overlay.DrawHover(graphics));
|
||||||
|
Assert.NotNull(overlay.HoverPages);
|
||||||
|
Assert.Equal(0, overlay.HoverPages.PageIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LongNameAndTinyViewNeverSilentlyElideTheOnlyPair() => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
run.View.Size = new System.Drawing.Size(150, 120);
|
||||||
|
run.View.Plate.Parts[0].BaseDrawing.Name = new string('W', 250);
|
||||||
|
run.View.OverlapDisplay = OverlapDisplayMode.Both;
|
||||||
|
run.Finish();
|
||||||
|
run.View.ZoomToPoint(new Vector(), 10);
|
||||||
|
run.View.SetOrigin(30, 90);
|
||||||
|
var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid);
|
||||||
|
run.View.MoveTo(center);
|
||||||
|
using var image = new Bitmap(150, 120);
|
||||||
|
using var graphics = Graphics.FromImage(image);
|
||||||
|
var overlay = run.View.OverlapOverlay;
|
||||||
|
Assert.True(overlay.DrawHover(graphics));
|
||||||
|
Assert.False(overlay.HoverPages.NeedsLargerViewport);
|
||||||
|
Assert.True(overlay.HoverPages.PageCount > 1);
|
||||||
|
var content = new List<string>();
|
||||||
|
for (var i = 0; i < overlay.HoverPages.PageCount; i++)
|
||||||
|
{
|
||||||
|
Assert.True(overlay.DrawHover(graphics));
|
||||||
|
Assert.True(new RectangleF(PointF.Empty, run.View.ClientSize).Contains(overlay.HoverBounds));
|
||||||
|
content.AddRange(overlay.HoverPages.Lines);
|
||||||
|
run.View.Command(Keys.PageDown);
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
Assert.True(overlay.DrawHover(graphics));
|
||||||
|
Assert.True(overlay.HoverPages.NeedsLargerViewport);
|
||||||
|
Assert.False(run.View.Command(Keys.PageDown));
|
||||||
|
});
|
||||||
|
|
||||||
|
private static void PrepareCrowdedHover(OverlayRun run)
|
||||||
|
{
|
||||||
|
run.View.Size = new System.Drawing.Size(260, 160);
|
||||||
|
run.View.Plate.Parts.Clear();
|
||||||
|
for (var i = 0; i < 12; i++)
|
||||||
|
run.View.Plate.Parts.Add(Rectangle());
|
||||||
|
run.View.OverlapDisplay = OverlapDisplayMode.Both;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// These waits are deliberate: only Task.Run workers use the result gate; the test's
|
||||||
|
// STA waits for capture delivery, then pumps every UI continuation explicitly.
|
||||||
|
private static PlateOverlapReport WaitForWorkerResult(Task<PlateOverlapReport> task) => task.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
private static PlateOverlapSnapshot WaitForSnapshot(Task<PlateOverlapSnapshot> task)
|
||||||
|
{
|
||||||
|
Assert.True(task.Wait(TimeSpan.FromSeconds(15)));
|
||||||
|
return task.GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static ToolStripMenuItem Menu(MainForm host, string name) =>
|
private static ToolStripMenuItem Menu(MainForm host, string name) =>
|
||||||
(ToolStripMenuItem)host.MainMenuStrip!.Items.Find(name, true).Single();
|
(ToolStripMenuItem)host.MainMenuStrip!.Items.Find(name, true).Single();
|
||||||
|
|
||||||
@@ -335,6 +804,20 @@ public class PlateOverlapOverlayTests
|
|||||||
public void Pan(float x, float y) { origin.X += x; origin.Y += y; }
|
public void Pan(float x, float y) { origin.X += x; origin.Y += y; }
|
||||||
public void SetOrigin(float x, float y) => origin = new PointF(x, y);
|
public void SetOrigin(float x, float y) => origin = new PointF(x, y);
|
||||||
public void Recreate() => RecreateHandle();
|
public void Recreate() => RecreateHandle();
|
||||||
|
public void MoveTo(Point point) => OnMouseMove(new MouseEventArgs(MouseButtons.None, 0, point.X, point.Y, 0));
|
||||||
|
public void LeaveView() => OnMouseLeave(EventArgs.Empty);
|
||||||
|
public bool Command(Keys keys)
|
||||||
|
{
|
||||||
|
var message = new Message();
|
||||||
|
return ProcessCmdKey(ref message, keys);
|
||||||
|
}
|
||||||
|
public void WheelAt(Point point) => OnMouseWheel(new MouseEventArgs(MouseButtons.None, 0, point.X, point.Y, 120));
|
||||||
|
public void DragAt(Point point) => OnMouseMove(new MouseEventArgs(MouseButtons.Left, 0, point.X, point.Y, 0));
|
||||||
|
public void ClickAt(Point point)
|
||||||
|
{
|
||||||
|
OnMouseDown(new MouseEventArgs(MouseButtons.Left, 1, point.X, point.Y, 0));
|
||||||
|
OnMouseUp(new MouseEventArgs(MouseButtons.Left, 1, point.X, point.Y, 0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class OverlayRun : IDisposable
|
private sealed class OverlayRun : IDisposable
|
||||||
@@ -342,6 +825,9 @@ public class PlateOverlapOverlayTests
|
|||||||
private readonly PumpContext context = new();
|
private readonly PumpContext context = new();
|
||||||
private readonly SynchronizationContext? previous;
|
private readonly SynchronizationContext? previous;
|
||||||
private readonly BlockingCollection<Work> work = new();
|
private readonly BlockingCollection<Work> work = new();
|
||||||
|
private readonly List<Work> outstanding = new();
|
||||||
|
private readonly List<Task> tasks = new();
|
||||||
|
private bool disposed;
|
||||||
public TestView View { get; } = new();
|
public TestView View { get; } = new();
|
||||||
public int Calls;
|
public int Calls;
|
||||||
|
|
||||||
@@ -356,12 +842,24 @@ public class PlateOverlapOverlayTests
|
|||||||
{
|
{
|
||||||
Interlocked.Increment(ref Calls);
|
Interlocked.Increment(ref Calls);
|
||||||
var request = new Work(snapshot, token);
|
var request = new Work(snapshot, token);
|
||||||
work.Add(request);
|
lock (outstanding)
|
||||||
|
{
|
||||||
|
outstanding.Add(request);
|
||||||
|
if (disposed)
|
||||||
|
request.Release();
|
||||||
|
else
|
||||||
|
work.Add(request);
|
||||||
|
}
|
||||||
return request.WaitForResult();
|
return request.WaitForResult();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Start() => View.CheckOverlapsAsync();
|
public Task Start()
|
||||||
|
{
|
||||||
|
var task = View.CheckOverlapsAsync(Units.Inches);
|
||||||
|
tasks.Add(task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
public Work Next()
|
public Work Next()
|
||||||
{
|
{
|
||||||
Assert.True(work.TryTake(out var result, TimeSpan.FromSeconds(15)), "Worker did not start.");
|
Assert.True(work.TryTake(out var result, TimeSpan.FromSeconds(15)), "Worker did not start.");
|
||||||
@@ -376,9 +874,27 @@ public class PlateOverlapOverlayTests
|
|||||||
}
|
}
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
View.Dispose();
|
if (disposed)
|
||||||
SynchronizationContext.SetSynchronizationContext(previous);
|
return;
|
||||||
work.Dispose();
|
try
|
||||||
|
{
|
||||||
|
View.Dispose();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (outstanding)
|
||||||
|
{
|
||||||
|
disposed = true;
|
||||||
|
foreach (var request in outstanding)
|
||||||
|
request.Release();
|
||||||
|
}
|
||||||
|
try { context.Pump(Task.WhenAll(tasks)); }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SynchronizationContext.SetSynchronizationContext(previous);
|
||||||
|
work.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +906,9 @@ public class PlateOverlapOverlayTests
|
|||||||
public PlateOverlapReport WaitForResult() => Result.Task.GetAwaiter().GetResult();
|
public PlateOverlapReport WaitForResult() => Result.Task.GetAwaiter().GetResult();
|
||||||
public void Complete() => Result.SetResult(PlateOverlapAnalyzer.Analyze(snapshot));
|
public void Complete() => Result.SetResult(PlateOverlapAnalyzer.Analyze(snapshot));
|
||||||
public void Fail() => Result.SetException(new InvalidOperationException("test failure"));
|
public void Fail() => Result.SetException(new InvalidOperationException("test failure"));
|
||||||
|
// Cancellation is deliberately ignored while testing late completions. Cleanup alone
|
||||||
|
// releases every gate, including requests already taken from the pending queue.
|
||||||
|
public void Release() => Result.TrySetCanceled();
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class PumpContext : SynchronizationContext
|
private sealed class PumpContext : SynchronizationContext
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Drawing.Drawing2D;
|
using System.Drawing.Drawing2D;
|
||||||
@@ -7,6 +8,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using OpenNest.Collections;
|
using OpenNest.Collections;
|
||||||
using OpenNest.Diagnostics;
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Controls;
|
namespace OpenNest.Controls;
|
||||||
|
|
||||||
@@ -23,6 +25,12 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
private GraphicsPath path;
|
private GraphicsPath path;
|
||||||
private PlateOverlapReport pathReport;
|
private PlateOverlapReport pathReport;
|
||||||
private float pathScale;
|
private float pathScale;
|
||||||
|
private Units capturedUnits;
|
||||||
|
private IReadOnlyList<PlateOverlapPair> hoveredPairs = Array.Empty<PlateOverlapPair>();
|
||||||
|
private string hoverText;
|
||||||
|
private OverlapHoverPages hoverPages;
|
||||||
|
private Point hoverPoint;
|
||||||
|
private (float Scale, PointF Offset, int Dpi) hoverTransform;
|
||||||
private bool disposed;
|
private bool disposed;
|
||||||
|
|
||||||
public OverlapOverlayController(PlateView view) => this.view = view;
|
public OverlapOverlayController(PlateView view) => this.view = view;
|
||||||
@@ -36,6 +44,8 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
internal Func<PlateOverlapSnapshot, CancellationToken, PlateOverlapReport> Analyze { get; set; }
|
internal Func<PlateOverlapSnapshot, CancellationToken, PlateOverlapReport> Analyze { get; set; }
|
||||||
= PlateOverlapAnalyzer.Analyze;
|
= PlateOverlapAnalyzer.Analyze;
|
||||||
internal GraphicsPath CachedPath => path;
|
internal GraphicsPath CachedPath => path;
|
||||||
|
internal OverlapHoverPages HoverPages => hoverPages;
|
||||||
|
internal RectangleF HoverBounds { get; private set; }
|
||||||
|
|
||||||
public OverlapDisplayMode DisplayMode
|
public OverlapDisplayMode DisplayMode
|
||||||
{
|
{
|
||||||
@@ -65,7 +75,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
NotifyChanged();
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CheckAsync()
|
public async Task CheckAsync(Units units)
|
||||||
{
|
{
|
||||||
if (!CanUseView || view.Plate == null)
|
if (!CanUseView || view.Plate == null)
|
||||||
return;
|
return;
|
||||||
@@ -75,6 +85,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
CancelWorker();
|
CancelWorker();
|
||||||
var plate = view.Plate;
|
var plate = view.Plate;
|
||||||
var generation = state.Begin(plate);
|
var generation = state.Begin(plate);
|
||||||
|
capturedUnits = units; // Same request boundary as the owned geometry/names, never read live units in paint.
|
||||||
var source = new CancellationTokenSource();
|
var source = new CancellationTokenSource();
|
||||||
cancellation = source;
|
cancellation = source;
|
||||||
var token = source.Token;
|
var token = source.Token;
|
||||||
@@ -162,6 +173,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
|
|
||||||
private void NotifyChanged()
|
private void NotifyChanged()
|
||||||
{
|
{
|
||||||
|
ClearHover();
|
||||||
if (disposed || view.IsDisposed || view.Disposing)
|
if (disposed || view.IsDisposed || view.Disposing)
|
||||||
return;
|
return;
|
||||||
view.Status = state.Message;
|
view.Status = state.Message;
|
||||||
@@ -187,26 +199,194 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
{
|
{
|
||||||
if (disposed)
|
if (disposed)
|
||||||
return;
|
return;
|
||||||
|
EnsureFresh();
|
||||||
|
ValidateHoverTransform();
|
||||||
|
|
||||||
|
if (state.Report != null)
|
||||||
|
{
|
||||||
|
if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both)
|
||||||
|
{
|
||||||
|
EnsurePath();
|
||||||
|
if (path.PointCount > 0)
|
||||||
|
{
|
||||||
|
using var brush = new SolidBrush(Color.FromArgb(100, 255, 0, 80));
|
||||||
|
// A single winding fill is the union: triple overlap is not painted darker.
|
||||||
|
graphics.FillPath(brush, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ShowsCentroids)
|
||||||
|
DrawCentroids(graphics);
|
||||||
|
}
|
||||||
|
DrawStateLabel(graphics);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool EnsureFresh()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
return false;
|
||||||
var generation = state.Generation;
|
var generation = state.Generation;
|
||||||
state.EnsureFresh(view.Plate);
|
var fresh = state.EnsureFresh(view.Plate);
|
||||||
if (generation != state.Generation)
|
if (generation != state.Generation)
|
||||||
{
|
{
|
||||||
CancelWorker();
|
CancelWorker();
|
||||||
ReleasePath();
|
ReleasePath();
|
||||||
NotifyChanged();
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
if (state.DisplayMode == OverlapDisplayMode.Areas && state.Report != null)
|
private bool ShowsCentroids => state.DisplayMode is OverlapDisplayMode.Centroids or OverlapDisplayMode.Both;
|
||||||
|
private (float, PointF, int) ViewTransform => (view.ViewScale, view.PointControlToGraph(Point.Empty), view.DeviceDpi);
|
||||||
|
|
||||||
|
internal string HoverText
|
||||||
|
{
|
||||||
|
get
|
||||||
{
|
{
|
||||||
EnsurePath();
|
if (!EnsureFresh())
|
||||||
if (path.PointCount > 0)
|
ClearHover();
|
||||||
{
|
ValidateHoverTransform();
|
||||||
using var brush = new SolidBrush(Color.FromArgb(100, 255, 0, 80));
|
return hoverText;
|
||||||
// A single winding fill is the union: triple overlap is not painted darker.
|
|
||||||
graphics.FillPath(brush, path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
DrawStateLabel(graphics);
|
}
|
||||||
|
|
||||||
|
public void UpdateHover(Point point)
|
||||||
|
{
|
||||||
|
ValidateHoverTransform();
|
||||||
|
if (!EnsureFresh() || !ShowsCentroids || state.Report == null)
|
||||||
|
{
|
||||||
|
ClearHover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph coordinates are already screen-scaled; remove pan from the pointer once.
|
||||||
|
var graphPoint = view.PointControlToGraph(point);
|
||||||
|
var hits = OverlapPairPresentation.HitTest(state.Report.Pairs, world =>
|
||||||
|
{
|
||||||
|
var graph = view.PointWorldToGraph(world);
|
||||||
|
return new Vector(graph.X, graph.Y);
|
||||||
|
}, new Vector(graphPoint.X, graphPoint.Y), view.DeviceDpi);
|
||||||
|
var changed = !hits.SequenceEqual(hoveredPairs);
|
||||||
|
var moved = point != hoverPoint;
|
||||||
|
hoveredPairs = hits;
|
||||||
|
hoverPoint = point;
|
||||||
|
hoverTransform = ViewTransform;
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
hoverPages = null;
|
||||||
|
HoverBounds = RectangleF.Empty;
|
||||||
|
hoverText = hits.Count == 0 ? null : string.Join("\n\n",
|
||||||
|
hits.Select(pair => OverlapPairPresentation.Details(pair, capturedUnits)));
|
||||||
|
}
|
||||||
|
if (changed || (moved && hoverText != null))
|
||||||
|
view.Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateHoverTransform()
|
||||||
|
{
|
||||||
|
if (hoverText != null && hoverTransform != ViewTransform)
|
||||||
|
ClearHover();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearHover()
|
||||||
|
{
|
||||||
|
var visible = hoverText != null;
|
||||||
|
hoverText = null;
|
||||||
|
hoverPages = null;
|
||||||
|
HoverBounds = RectangleF.Empty;
|
||||||
|
hoveredPairs = Array.Empty<PlateOverlapPair>();
|
||||||
|
if (visible && !disposed && !view.IsDisposed && !view.Disposing)
|
||||||
|
view.Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryPageHover(int delta)
|
||||||
|
{
|
||||||
|
// Revalidate even when a key arrives before the next paint/mouse event.
|
||||||
|
if (HoverText == null || hoverPages == null || hoverPages.PageCount < 2)
|
||||||
|
return false;
|
||||||
|
hoverPages.MovePage(delta);
|
||||||
|
view.Invalidate();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawCentroids(Graphics graphics)
|
||||||
|
{
|
||||||
|
var dpiScale = view.DeviceDpi / 96f;
|
||||||
|
var radius = (float)OverlapPairPresentation.MarkerHalfSize(view.DeviceDpi);
|
||||||
|
using var halo = new Pen(Color.White, 4 * dpiScale);
|
||||||
|
using var crosshair = new Pen(Color.DarkRed, 2 * dpiScale);
|
||||||
|
// Stack coincident pair labels instead of replacing them with a fragment count.
|
||||||
|
var labelRows = new Dictionary<PointF, int>();
|
||||||
|
foreach (var pair in state.Report.Pairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId))
|
||||||
|
{
|
||||||
|
var center = view.PointWorldToGraph(pair.Centroid);
|
||||||
|
if (!float.IsFinite(center.X) || !float.IsFinite(center.Y))
|
||||||
|
continue;
|
||||||
|
foreach (var pen in new[] { halo, crosshair })
|
||||||
|
{
|
||||||
|
graphics.DrawLine(pen, center.X - radius, center.Y, center.X + radius, center.Y);
|
||||||
|
graphics.DrawLine(pen, center.X, center.Y - radius, center.X, center.Y + radius);
|
||||||
|
}
|
||||||
|
var label = OverlapPairPresentation.Label(pair);
|
||||||
|
var size = graphics.MeasureString(label, view.Font);
|
||||||
|
labelRows.TryGetValue(center, out var row);
|
||||||
|
labelRows[center] = row + 1;
|
||||||
|
var x = center.X + radius + 3 * dpiScale;
|
||||||
|
var y = center.Y - radius + row * size.Height;
|
||||||
|
graphics.FillRectangle(Brushes.White, x, y, size.Width, size.Height);
|
||||||
|
graphics.DrawString(label, view.Font, Brushes.DarkRed, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw last, over action adorners. The normal part tooltip is suppressed only while
|
||||||
|
// these details are actually visible; its independent timer/state is not stolen.
|
||||||
|
public bool DrawHover(Graphics graphics)
|
||||||
|
{
|
||||||
|
var text = HoverText; // Includes a fresh stamp check even if no mouse move/paint preceded this.
|
||||||
|
if (text == null)
|
||||||
|
return false;
|
||||||
|
var saved = graphics.Save();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
graphics.ResetTransform();
|
||||||
|
graphics.SetClip(view.ClientRectangle, CombineMode.Intersect);
|
||||||
|
var scale = view.DeviceDpi / 96f;
|
||||||
|
var padding = 4 * scale;
|
||||||
|
var maxWidth = System.Math.Min(560 * scale, view.ClientSize.Width - 2) - padding * 2;
|
||||||
|
var lineHeight = (float)System.Math.Ceiling(view.Font.GetHeight(graphics));
|
||||||
|
var maxRows = (int)((view.ClientSize.Height - 2 - padding * 2) / lineHeight);
|
||||||
|
using var format = (StringFormat)StringFormat.GenericTypographic.Clone();
|
||||||
|
format.FormatFlags |= StringFormatFlags.NoWrap | StringFormatFlags.MeasureTrailingSpaces;
|
||||||
|
float Measure(string line) => graphics.MeasureString(line, view.Font, PointF.Empty, format).Width;
|
||||||
|
hoverPages ??= OverlapHoverPages.Create(text, hoveredPairs.Count, maxRows, maxWidth, line => Measure(line));
|
||||||
|
if (hoverPages.NeedsLargerViewport)
|
||||||
|
{
|
||||||
|
// A viewport smaller than a content line plus its paging hint cannot
|
||||||
|
// show details legibly. Say so rather than presenting clipped data.
|
||||||
|
HoverBounds = new RectangleF(0, 0, view.ClientSize.Width, view.ClientSize.Height);
|
||||||
|
graphics.FillRectangle(Brushes.White, HoverBounds);
|
||||||
|
graphics.DrawString("Enlarge view to read overlap details", view.Font, Brushes.DarkRed, HoverBounds);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var lines = hoverPages.Lines.Concat(hoverPages.NavigationLines).ToArray();
|
||||||
|
var width = lines.Max(Measure) + padding * 2;
|
||||||
|
var height = lines.Length * lineHeight + padding * 2;
|
||||||
|
var x = System.Math.Max(0, System.Math.Min(hoverPoint.X + 16 * scale, view.ClientSize.Width - width));
|
||||||
|
var y = hoverPoint.Y - height - 6 * scale;
|
||||||
|
if (y < 0)
|
||||||
|
y = System.Math.Max(0, System.Math.Min(hoverPoint.Y + 20 * scale, view.ClientSize.Height - height));
|
||||||
|
HoverBounds = new RectangleF(x, y, width, height);
|
||||||
|
graphics.FillRectangle(Brushes.White, x, y, width, height);
|
||||||
|
graphics.DrawRectangle(Pens.DimGray, x, y, width, height);
|
||||||
|
for (var row = 0; row < lines.Length; row++)
|
||||||
|
graphics.DrawString(lines[row], view.Font,
|
||||||
|
row < hoverPages.Lines.Count ? Brushes.Black : Brushes.DarkRed,
|
||||||
|
new PointF(x + padding, y + padding + row * lineHeight), format);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
graphics.Restore(saved);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsurePath()
|
private void EnsurePath()
|
||||||
@@ -283,5 +463,6 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
CancelWorker();
|
CancelWorker();
|
||||||
state.Reset();
|
state.Reset();
|
||||||
ReleasePath();
|
ReleasePath();
|
||||||
|
ClearHover();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,7 @@ namespace OpenNest.Controls
|
|||||||
private LayoutPart hoveredPart;
|
private LayoutPart hoveredPart;
|
||||||
private Point hoverPoint;
|
private Point hoverPoint;
|
||||||
private bool showTooltip;
|
private bool showTooltip;
|
||||||
|
private bool hoverPending;
|
||||||
private Timer hoverTimer;
|
private Timer hoverTimer;
|
||||||
|
|
||||||
public Box ActiveWorkArea
|
public Box ActiveWorkArea
|
||||||
@@ -229,7 +230,7 @@ namespace OpenNest.Controls
|
|||||||
add => overlapOverlay.StateChanged += value;
|
add => overlapOverlay.StateChanged += value;
|
||||||
remove => overlapOverlay.StateChanged -= value;
|
remove => overlapOverlay.StateChanged -= value;
|
||||||
}
|
}
|
||||||
public Task CheckOverlapsAsync() => overlapOverlay.CheckAsync();
|
public Task CheckOverlapsAsync(Units units) => overlapOverlay.CheckAsync(units);
|
||||||
public void CancelOverlapCheck() => overlapOverlay.Cancel();
|
public void CancelOverlapCheck() => overlapOverlay.Cancel();
|
||||||
public void InvalidateOverlapCheck() => overlapOverlay.Invalidate();
|
public void InvalidateOverlapCheck() => overlapOverlay.Invalidate();
|
||||||
|
|
||||||
@@ -250,6 +251,40 @@ namespace OpenNest.Controls
|
|||||||
base.OnMouseEnter(e);
|
base.OnMouseEnter(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnMouseLeave(EventArgs e)
|
||||||
|
{
|
||||||
|
ClearHover();
|
||||||
|
base.OnMouseLeave(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearHover()
|
||||||
|
{
|
||||||
|
overlapOverlay?.ClearHover();
|
||||||
|
hoverTimer?.Stop();
|
||||||
|
hoverPending = false;
|
||||||
|
hoveredPart = null;
|
||||||
|
showTooltip = false;
|
||||||
|
Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnResize(EventArgs e)
|
||||||
|
{
|
||||||
|
ClearHover();
|
||||||
|
base.OnResize(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDpiChangedAfterParent(EventArgs e)
|
||||||
|
{
|
||||||
|
ClearHover();
|
||||||
|
base.OnDpiChangedAfterParent(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnFontChanged(EventArgs e)
|
||||||
|
{
|
||||||
|
ClearHover();
|
||||||
|
base.OnFontChanged(e);
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnDragEnter(DragEventArgs drgevent)
|
protected override void OnDragEnter(DragEventArgs drgevent)
|
||||||
{
|
{
|
||||||
if (drgevent.Data.GetData(typeof(Drawing)) != null)
|
if (drgevent.Data.GetData(typeof(Drawing)) != null)
|
||||||
@@ -271,6 +306,7 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
protected override void OnMouseDown(MouseEventArgs e)
|
protected override void OnMouseDown(MouseEventArgs e)
|
||||||
{
|
{
|
||||||
|
ClearHover();
|
||||||
if (!Focused)
|
if (!Focused)
|
||||||
Focus();
|
Focus();
|
||||||
|
|
||||||
@@ -359,6 +395,8 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
protected override void OnMouseMove(MouseEventArgs e)
|
protected override void OnMouseMove(MouseEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (e.Button != MouseButtons.None)
|
||||||
|
ClearHover();
|
||||||
if (e.Button == MouseButtons.Middle)
|
if (e.Button == MouseButtons.Middle)
|
||||||
{
|
{
|
||||||
if (AllowPan)
|
if (AllowPan)
|
||||||
@@ -389,19 +427,18 @@ namespace OpenNest.Controls
|
|||||||
if (e.Button == MouseButtons.None && actionManager.CurrentAction is ActionSelect)
|
if (e.Button == MouseButtons.None && actionManager.CurrentAction is ActionSelect)
|
||||||
{
|
{
|
||||||
hoverPoint = e.Location;
|
hoverPoint = e.Location;
|
||||||
|
hoverPending = true;
|
||||||
showTooltip = false;
|
showTooltip = false;
|
||||||
hoverTimer.Stop();
|
hoverTimer.Stop();
|
||||||
hoverTimer.Start();
|
hoverTimer.Start();
|
||||||
|
|
||||||
if (hoveredPart != null)
|
if (hoveredPart != null)
|
||||||
Invalidate();
|
Invalidate();
|
||||||
|
overlapOverlay.UpdateHover(e.Location);
|
||||||
}
|
}
|
||||||
else if (hoveredPart != null || showTooltip)
|
else
|
||||||
{
|
{
|
||||||
hoveredPart = null;
|
ClearHover();
|
||||||
hoverTimer.Stop();
|
|
||||||
showTooltip = false;
|
|
||||||
Invalidate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnMouseMove(e);
|
base.OnMouseMove(e);
|
||||||
@@ -415,6 +452,18 @@ namespace OpenNest.Controls
|
|||||||
ZoomToFit();
|
ZoomToFit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||||||
|
{
|
||||||
|
// Audited: DrawControl/PlateView, Action key handlers, EditNestForm,
|
||||||
|
// MainForm.ProcessCmdKey and menu shortcuts have no plain PgUp/PgDn
|
||||||
|
// binding. BestFitViewerForm owns these only in its separate dialog.
|
||||||
|
// No modifier keys, clicks, or wheel events are consumed by hover paging.
|
||||||
|
if ((keyData == Keys.PageUp || keyData == Keys.PageDown)
|
||||||
|
&& overlapOverlay.TryPageHover(keyData == Keys.PageUp ? -1 : 1))
|
||||||
|
return true;
|
||||||
|
return base.ProcessCmdKey(ref msg, keyData);
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnKeyDown(KeyEventArgs e)
|
protected override void OnKeyDown(KeyEventArgs e)
|
||||||
{
|
{
|
||||||
switch (e.KeyCode)
|
switch (e.KeyCode)
|
||||||
@@ -517,7 +566,7 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
base.OnPaint(e);
|
base.OnPaint(e);
|
||||||
|
|
||||||
if (hoveredPart != null && showTooltip)
|
if (!overlapOverlay.DrawHover(e.Graphics) && hoveredPart != null && showTooltip)
|
||||||
{
|
{
|
||||||
e.Graphics.ResetTransform();
|
e.Graphics.ResetTransform();
|
||||||
var text = hoveredPart.BasePart.BaseDrawing.Name;
|
var text = hoveredPart.BasePart.BaseDrawing.Name;
|
||||||
@@ -542,6 +591,7 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
protected override void OnHandleDestroyed(EventArgs e)
|
protected override void OnHandleDestroyed(EventArgs e)
|
||||||
{
|
{
|
||||||
|
ClearHover();
|
||||||
overlapOverlay.ReleaseHandle();
|
overlapOverlay.ReleaseHandle();
|
||||||
base.OnHandleDestroyed(e);
|
base.OnHandleDestroyed(e);
|
||||||
actionManager.Cleanup();
|
actionManager.Cleanup();
|
||||||
@@ -550,7 +600,12 @@ namespace OpenNest.Controls
|
|||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
if (disposing)
|
if (disposing)
|
||||||
|
{
|
||||||
overlapOverlay?.Dispose();
|
overlapOverlay?.Dispose();
|
||||||
|
hoverTimer?.Dispose();
|
||||||
|
hoverTimer = null;
|
||||||
|
redrawTimer?.Dispose();
|
||||||
|
}
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,6 +761,9 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
private void HoverCheck()
|
private void HoverCheck()
|
||||||
{
|
{
|
||||||
|
if (!hoverPending || IsDisposed || Disposing)
|
||||||
|
return;
|
||||||
|
hoverPending = false;
|
||||||
var graphPt = PointControlToGraph(hoverPoint);
|
var graphPt = PointControlToGraph(hoverPoint);
|
||||||
LayoutPart hitPart = null;
|
LayoutPart hitPart = null;
|
||||||
|
|
||||||
@@ -793,6 +851,7 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
protected override void UpdateMatrix()
|
protected override void UpdateMatrix()
|
||||||
{
|
{
|
||||||
|
ClearHover();
|
||||||
base.UpdateMatrix();
|
base.UpdateMatrix();
|
||||||
parts.ForEach(p => p.Update(this));
|
parts.ForEach(p => p.Update(this));
|
||||||
previewManager.Update();
|
previewManager.Update();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ namespace OpenNest.Forms
|
|||||||
public readonly Document Document;
|
public readonly Document Document;
|
||||||
public readonly PlateView PlateView;
|
public readonly PlateView PlateView;
|
||||||
|
|
||||||
public System.Threading.Tasks.Task CheckOverlapsAsync() => PlateView.CheckOverlapsAsync();
|
public System.Threading.Tasks.Task CheckOverlapsAsync() => PlateView.CheckOverlapsAsync(Nest.Units);
|
||||||
public void CancelOverlapCheck() => PlateView.CancelOverlapCheck();
|
public void CancelOverlapCheck() => PlateView.CancelOverlapCheck();
|
||||||
public OverlapDisplayMode OverlapDisplay
|
public OverlapDisplayMode OverlapDisplay
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+17
-1
@@ -54,6 +54,8 @@
|
|||||||
mnuOverlapDisplay = new System.Windows.Forms.ToolStripMenuItem();
|
mnuOverlapDisplay = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
mnuOverlapOff = new System.Windows.Forms.ToolStripMenuItem();
|
mnuOverlapOff = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
mnuOverlapAreas = new System.Windows.Forms.ToolStripMenuItem();
|
mnuOverlapAreas = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
|
mnuOverlapCentroids = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
|
mnuOverlapBoth = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
mnuViewDrawRapids = new System.Windows.Forms.ToolStripMenuItem();
|
mnuViewDrawRapids = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
mnuViewDrawPiercePoints = new System.Windows.Forms.ToolStripMenuItem();
|
mnuViewDrawPiercePoints = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem();
|
mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
@@ -353,7 +355,7 @@
|
|||||||
//
|
//
|
||||||
mnuOverlapDisplay.Name = "mnuOverlapDisplay";
|
mnuOverlapDisplay.Name = "mnuOverlapDisplay";
|
||||||
mnuOverlapDisplay.Text = "Display";
|
mnuOverlapDisplay.Text = "Display";
|
||||||
mnuOverlapDisplay.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuOverlapOff, mnuOverlapAreas });
|
mnuOverlapDisplay.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuOverlapOff, mnuOverlapAreas, mnuOverlapCentroids, mnuOverlapBoth });
|
||||||
mnuOverlapDisplay.DropDownOpening += OverlapMenu_Opening;
|
mnuOverlapDisplay.DropDownOpening += OverlapMenu_Opening;
|
||||||
//
|
//
|
||||||
// mnuOverlapOff
|
// mnuOverlapOff
|
||||||
@@ -367,6 +369,18 @@
|
|||||||
mnuOverlapAreas.Name = "mnuOverlapAreas";
|
mnuOverlapAreas.Name = "mnuOverlapAreas";
|
||||||
mnuOverlapAreas.Text = "Areas";
|
mnuOverlapAreas.Text = "Areas";
|
||||||
mnuOverlapAreas.Click += OverlapAreas_Click;
|
mnuOverlapAreas.Click += OverlapAreas_Click;
|
||||||
|
//
|
||||||
|
// mnuOverlapCentroids
|
||||||
|
//
|
||||||
|
mnuOverlapCentroids.Name = "mnuOverlapCentroids";
|
||||||
|
mnuOverlapCentroids.Text = "Centroids";
|
||||||
|
mnuOverlapCentroids.Click += OverlapCentroids_Click;
|
||||||
|
//
|
||||||
|
// mnuOverlapBoth
|
||||||
|
//
|
||||||
|
mnuOverlapBoth.Name = "mnuOverlapBoth";
|
||||||
|
mnuOverlapBoth.Text = "Both";
|
||||||
|
mnuOverlapBoth.Click += OverlapBoth_Click;
|
||||||
//
|
//
|
||||||
// mnuViewDrawRapids
|
// mnuViewDrawRapids
|
||||||
//
|
//
|
||||||
@@ -1292,6 +1306,8 @@
|
|||||||
private System.Windows.Forms.ToolStripMenuItem mnuOverlapDisplay;
|
private System.Windows.Forms.ToolStripMenuItem mnuOverlapDisplay;
|
||||||
private System.Windows.Forms.ToolStripMenuItem mnuOverlapOff;
|
private System.Windows.Forms.ToolStripMenuItem mnuOverlapOff;
|
||||||
private System.Windows.Forms.ToolStripMenuItem mnuOverlapAreas;
|
private System.Windows.Forms.ToolStripMenuItem mnuOverlapAreas;
|
||||||
|
private System.Windows.Forms.ToolStripMenuItem mnuOverlapCentroids;
|
||||||
|
private System.Windows.Forms.ToolStripMenuItem mnuOverlapBoth;
|
||||||
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawRapids;
|
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawRapids;
|
||||||
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawPiercePoints;
|
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawPiercePoints;
|
||||||
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawBounds;
|
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawBounds;
|
||||||
|
|||||||
@@ -675,8 +675,12 @@ namespace OpenNest.Forms
|
|||||||
mnuOverlapDisplay.Enabled = hasPlate;
|
mnuOverlapDisplay.Enabled = hasPlate;
|
||||||
mnuOverlapOff.Enabled = hasPlate;
|
mnuOverlapOff.Enabled = hasPlate;
|
||||||
mnuOverlapAreas.Enabled = hasPlate;
|
mnuOverlapAreas.Enabled = hasPlate;
|
||||||
|
mnuOverlapCentroids.Enabled = hasPlate;
|
||||||
|
mnuOverlapBoth.Enabled = hasPlate;
|
||||||
mnuOverlapOff.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Off;
|
mnuOverlapOff.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Off;
|
||||||
mnuOverlapAreas.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Areas;
|
mnuOverlapAreas.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Areas;
|
||||||
|
mnuOverlapCentroids.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Centroids;
|
||||||
|
mnuOverlapBoth.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Both;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void CheckOverlaps_Click(object sender, EventArgs e)
|
private async void CheckOverlaps_Click(object sender, EventArgs e)
|
||||||
@@ -700,6 +704,18 @@ namespace OpenNest.Forms
|
|||||||
activeForm.OverlapDisplay = OverlapDisplayMode.Areas;
|
activeForm.OverlapDisplay = OverlapDisplayMode.Areas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OverlapCentroids_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (activeForm != null)
|
||||||
|
activeForm.OverlapDisplay = OverlapDisplayMode.Centroids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OverlapBoth_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (activeForm != null)
|
||||||
|
activeForm.OverlapDisplay = OverlapDisplayMode.Both;
|
||||||
|
}
|
||||||
|
|
||||||
private void ToggleDrawRapids_Click(object sender, EventArgs e)
|
private void ToggleDrawRapids_Click(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (activeForm == null)
|
if (activeForm == null)
|
||||||
|
|||||||
@@ -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.
|
- **Import / export** — DXF & DWG parts (ACadSharp), Excel BOMs, bend-line detection, built-in parametric shapes; export DXF or post-processed G-code.
|
||||||
- **Nesting** — pluggable whole-job engines (Default, Strip, Vertical/Horizontal Remnant, StockLadder, plus DLL plugins), NFP-based interlocking pair evaluation, gravity compaction, rotation sweeps, multi-plate/multi-material jobs.
|
- **Nesting** — pluggable whole-job engines (Default, Strip, Vertical/Horizontal Remnant, StockLadder, plus DLL plugins), NFP-based interlocking pair evaluation, gravity compaction, rotation sweeps, multi-plate/multi-material jobs.
|
||||||
- **Plate operations** — manual sheet cut-offs, [automatic scrap cutoffs with unused-tail preservation](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.
|
- **Plate operations** — manual sheet cut-offs, [automatic scrap cutoffs with unused-tail preservation](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** — manually highlight shared material on the active plate, including containment and cutouts, through View > Overlap Check. [Usage and limitations](docs/geometry/visual-overlap-check.md).
|
- **Visual overlap check** — manually highlight shared material on the active plate, 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).
|
||||||
- **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).
|
- **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).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|||||||
@@ -6,8 +6,25 @@ Choose **View > Overlap Check > Check Active Plate** to check committed parts on
|
|||||||
this plate. Shared material is shaded red/magenta without changing the nest,
|
this plate. Shared material is shaded red/magenta without changing the nest,
|
||||||
selection, cutting paths, or export behavior. Cutoffs and temporary preview parts
|
selection, cutting paths, or export behavior. Cutoffs and temporary preview parts
|
||||||
are excluded. **Cancel Check** discards the running request. **Display > Off /
|
are excluded. **Cancel Check** discards the running request. **Display > Off /
|
||||||
Areas** changes visibility without rerunning analysis; checking from Off shows
|
Areas / Centroids / Both** changes visibility without rerunning analysis. Areas
|
||||||
Areas. Display preferences belong to the current document and are not saved.
|
is the default; rechecking preserves a visible mode, while checking from Off
|
||||||
|
shows Areas. Display preferences belong to the current document and are not saved.
|
||||||
|
|
||||||
|
Centroids 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
|
||||||
|
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
|
||||||
|
and pages them with a `Page x/y` hint: keep the pointer near the marker and use
|
||||||
|
plain **PageUp / PageDown** while PlateView has focus. Long individual details
|
||||||
|
continue across pages without truncation; an impossibly small viewport asks you
|
||||||
|
to enlarge it. These keys act only while a multipage diagnostic tooltip is
|
||||||
|
visible; wheel zoom, modified keys, selection clicks, and dragging are unchanged.
|
||||||
|
Small positive areas use significant-figure formatting rather than rounding to
|
||||||
|
zero. Selection and dragging remain ordinary plate operations, not overlay
|
||||||
|
interactions.
|
||||||
|
|
||||||
A persistent label distinguishes unchecked, checking, current, incomplete, stale,
|
A persistent label distinguishes unchecked, checking, current, incomplete, stale,
|
||||||
canceled, and failed checks. Only a completed, current, fully checked report can
|
canceled, and failed checks. Only a completed, current, fully checked report can
|
||||||
@@ -41,7 +58,7 @@ var areas = report.Pairs.SelectMany(pair => pair.Regions).ToList();
|
|||||||
foreach (var pair in report.Pairs)
|
foreach (var pair in report.Pairs)
|
||||||
{
|
{
|
||||||
// IDs are zero-based positions in the original input list, including skipped cutoffs.
|
// IDs are zero-based positions in the original input list, including skipped cutoffs.
|
||||||
Console.WriteLine($"{pair.PartAId} / {pair.PartBId}: {pair.Area}");
|
Console.WriteLine($"{pair.PartAId} / {pair.PartBId}: {pair.Area}, centroid {pair.Centroid}");
|
||||||
foreach (var region in pair.Regions)
|
foreach (var region in pair.Regions)
|
||||||
{
|
{
|
||||||
// region.Vertices: closed, read-only world-coordinate polygon
|
// region.Vertices: closed, read-only world-coordinate polygon
|
||||||
@@ -129,6 +146,20 @@ seams as physical boundaries. Areas within one pair may be summed. Areas across
|
|||||||
pairs are not a union: three coincident parts produce three overlapping pairs,
|
pairs are not a union: three coincident parts produce three overlapping pairs,
|
||||||
so summing all pair areas double-counts shared plate locations.
|
so summing all pair areas double-counts shared plate locations.
|
||||||
|
|
||||||
|
`pair.Centroid` is the true area-weighted center of every shared-material fragment
|
||||||
|
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.
|
||||||
|
|
||||||
|
`PolygonAreaMoments` evaluates signed moments about local origins and combines
|
||||||
|
fragments with positive area weights independent of winding. The analyzer does
|
||||||
|
this on rebased clipping fragments before adding the world origin back. Invalid,
|
||||||
|
degenerate, or nonfinite moments produce an incomplete pair issue, not a marker
|
||||||
|
at zero. Curved-outline centroids inherit the polygonization approximation.
|
||||||
|
|
||||||
Full containment and coincident parts are detected without relying on crossing
|
Full containment and coincident parts are detected without relying on crossing
|
||||||
points. Edge/corner contact with no positive shared material is not overlap.
|
points. Edge/corner contact with no positive shared material is not overlap.
|
||||||
There are no spacing offsets, plate-edge checks, cut-path crossing checks, automatic
|
There are no spacing offsets, plate-edge checks, cut-path crossing checks, automatic
|
||||||
@@ -165,6 +196,10 @@ is filled once, avoiding fragment outlines, internal triangulation seams, and
|
|||||||
darker triple coverage. World-to-graph conversion excludes pan, because PlateView
|
darker triple coverage. World-to-graph conversion excludes pan, because PlateView
|
||||||
already applies origin translation. Paths are rebuilt for report/scale changes,
|
already applies origin translation. Paths are rebuilt for report/scale changes,
|
||||||
not ordinary repaints or panning. The state label saves/restores graphics state.
|
not ordinary repaints or panning. The state label saves/restores graphics state.
|
||||||
|
Centroid 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.
|
||||||
|
|
||||||
Next hardening: measure real-plate capture/analysis cost and cancellation latency
|
Next hardening: measure real-plate capture/analysis cost and cancellation latency
|
||||||
before adding cached triangulations or background capture. Cancellation cannot
|
before adding cached triangulations or background capture. Cancellation cannot
|
||||||
@@ -180,11 +215,17 @@ inputs, snapshot isolation, read-only output, cutting-program independence, and
|
|||||||
cancellation. Run:
|
cancellation. Run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~PlateOverlapAnalyzerTests|FullyQualifiedName~OverlapReportStateTests'
|
dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~PlateOverlapAnalyzerTests|FullyQualifiedName~OverlapReportStateTests|FullyQualifiedName~PolygonAreaMomentsTests|FullyQualifiedName~OverlapPairPresentationTests|FullyQualifiedName~OverlapHoverPagesTests'
|
||||||
```
|
```
|
||||||
|
|
||||||
`OverlapReportStateTests` verifies request supersession, exact pose/reference
|
`OverlapReportStateTests` verifies request supersession, exact pose/reference
|
||||||
freshness, stale clearing, cancellation, and incomplete-versus-clear messaging.
|
freshness, stale clearing, cancellation, and incomplete-versus-clear messaging.
|
||||||
|
`PolygonAreaMomentsTests` covers analytic
|
||||||
|
centers, unequal/disconnected fragments, winding, closure, large translations,
|
||||||
|
and invalid/overflow cases. `OverlapPairPresentationTests` checks adaptive unit
|
||||||
|
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.
|
||||||
`OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs` adds STA worker/publication,
|
`OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs` adds STA worker/publication,
|
||||||
menu/MDI, path-cache, uniform-fill pixel, and control-lifetime checks. Run those
|
menu/MDI, path-cache, uniform-fill pixel, and control-lifetime checks. Run those
|
||||||
on Windows:
|
on Windows:
|
||||||
@@ -197,4 +238,5 @@ Linux can cross-build with `-p:EnableWindowsTargeting=true`, but that does not
|
|||||||
execute Windows tests or verify appearance, DPI, or interaction. On Windows,
|
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, pan/zoom and quadrant
|
||||||
alignment, stale clearing during edits/plate switches, converter cancellation,
|
alignment, stale clearing during edits/plate switches, converter cancellation,
|
||||||
and repeated check/toggle/close cycles without GDI/disposed-control errors.
|
crowded-marker PageUp/PageDown access to the last pair, and repeated
|
||||||
|
check/toggle/close cycles without GDI/disposed-control errors.
|
||||||
Reference in new issue
Block a user