feat(plateview): annotate overlap centroids
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Viewport-sized pages of cached overlap details, including continuation pages for a
|
||||
/// single long pair. The caller supplies its actual single-line font measurement.
|
||||
/// No names or numeric details are elided, and no geometry is queried here.
|
||||
/// </summary>
|
||||
public sealed class OverlapHoverPages
|
||||
{
|
||||
private readonly IReadOnlyList<string>[] pages;
|
||||
private readonly IReadOnlyList<string>[] navigation;
|
||||
|
||||
private OverlapHoverPages(IReadOnlyList<string>[] pages, IReadOnlyList<string>[] navigation,
|
||||
bool needsLargerViewport = false)
|
||||
{
|
||||
this.pages = pages;
|
||||
this.navigation = navigation;
|
||||
NeedsLargerViewport = needsLargerViewport;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Lines => PageCount == 0 ? Array.Empty<string>() : pages[PageIndex];
|
||||
public IReadOnlyList<string> NavigationLines => PageCount == 0 ? Array.Empty<string>() : navigation[PageIndex];
|
||||
public int PageIndex { get; private set; }
|
||||
public int PageCount => pages.Length;
|
||||
public bool NeedsLargerViewport { get; }
|
||||
|
||||
public void MovePage(int delta) => PageIndex = (int)System.Math.Clamp((long)PageIndex + delta, 0,
|
||||
System.Math.Max(0, PageCount - 1));
|
||||
|
||||
public static OverlapHoverPages Create(string text, int pairCount, int maxRows,
|
||||
double maxWidth, Func<string, double> measure)
|
||||
{
|
||||
var tooSmall = new OverlapHoverPages([], [], true);
|
||||
if (maxRows < 1 || !double.IsFinite(maxWidth) || maxWidth <= 0)
|
||||
return tooSmall;
|
||||
var lines = Wrap(text, maxWidth, measure);
|
||||
if (lines == null)
|
||||
return tooSmall;
|
||||
if (lines.Count <= maxRows)
|
||||
return new OverlapHoverPages([lines.AsReadOnly()], [Array.Empty<string>()]);
|
||||
|
||||
// Reserve the real, wrapped hint as well as content. Increasing the reserved
|
||||
// rows can increase the page count/digit count, so converge before slicing.
|
||||
for (var hintRows = 2; hintRows < maxRows;)
|
||||
{
|
||||
var contentRows = maxRows - hintRows;
|
||||
var count = (lines.Count - 1) / contentRows + 1;
|
||||
var hints = new IReadOnlyList<string>[count];
|
||||
var requiredHintRows = hintRows;
|
||||
for (var page = 0; page < count; page++)
|
||||
{
|
||||
var hint = Wrap($"Page {page + 1}/{count} · {pairCount} pairs\nPgUp/PgDn", maxWidth, measure);
|
||||
if (hint == null)
|
||||
return tooSmall;
|
||||
hints[page] = hint.AsReadOnly();
|
||||
requiredHintRows = System.Math.Max(requiredHintRows, hint.Count);
|
||||
}
|
||||
if (requiredHintRows > hintRows)
|
||||
{
|
||||
hintRows = requiredHintRows;
|
||||
continue;
|
||||
}
|
||||
var pages = Enumerable.Range(0, count)
|
||||
.Select(page => (IReadOnlyList<string>)Array.AsReadOnly(lines.Skip(page * contentRows).Take(contentRows).ToArray()))
|
||||
.ToArray();
|
||||
return new OverlapHoverPages(pages, hints);
|
||||
}
|
||||
// There must be room for at least one complete content line AND navigation.
|
||||
return tooSmall;
|
||||
}
|
||||
|
||||
private static List<string> Wrap(string text, double width, Func<string, double> measure)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var paragraph in text.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
if (paragraph.Length == 0)
|
||||
{
|
||||
lines.Add("");
|
||||
continue;
|
||||
}
|
||||
var starts = StringInfo.ParseCombiningCharacters(paragraph).Append(paragraph.Length).ToArray();
|
||||
for (var first = 0; first < starts.Length - 1;)
|
||||
{
|
||||
var low = first;
|
||||
var high = starts.Length - 1;
|
||||
while (low < high)
|
||||
{
|
||||
var end = low + (high - low + 1) / 2;
|
||||
if (measure(paragraph[starts[first]..starts[end]]) <= width)
|
||||
low = end;
|
||||
else
|
||||
high = end - 1;
|
||||
}
|
||||
if (low == first)
|
||||
return null; // Even one grapheme cannot fit; do not silently clip it.
|
||||
var last = low;
|
||||
if (last < starts.Length - 1)
|
||||
{
|
||||
for (var end = last; end > first; end--)
|
||||
{
|
||||
if (char.IsWhiteSpace(paragraph[starts[end - 1]]))
|
||||
{
|
||||
last = end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.Add(paragraph[starts[first]..starts[last]]);
|
||||
first = last;
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
|
||||
public static class OverlapPairPresentation
|
||||
{
|
||||
public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}";
|
||||
|
||||
public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null)
|
||||
{
|
||||
var units = UnitsHelper.GetShortString(capturedUnits);
|
||||
return $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}\n"
|
||||
+ $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
|
||||
+ $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
|
||||
}
|
||||
|
||||
// Significant figures adapt to scale: a positive sliver must never read as zero.
|
||||
public static string Number(double value, IFormatProvider provider = null) =>
|
||||
value.ToString("G6", provider ?? CultureInfo.CurrentCulture);
|
||||
|
||||
public static double MarkerHalfSize(int deviceDpi) => 6.0 * deviceDpi / 96;
|
||||
public static double HitRadius(int deviceDpi) => 10.0 * deviceDpi / 96;
|
||||
|
||||
/// <summary>
|
||||
/// Projects cached centroids into a screen-pixel coordinate space. The pointer must
|
||||
/// use that same space; view zoom never scales the DPI-adjusted hit radius.
|
||||
/// Coincident or nearby markers return every matching pair in stable ID order.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PlateOverlapPair> HitTest(IEnumerable<PlateOverlapPair> pairs,
|
||||
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi)
|
||||
{
|
||||
var radius = HitRadius(deviceDpi);
|
||||
return pairs.Where(pair =>
|
||||
{
|
||||
var center = worldToScreen(pair.Centroid);
|
||||
var dx = pointer.X - center.X;
|
||||
var dy = pointer.Y - center.Y;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
}).OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
public enum OverlapDisplayMode { Off, Areas }
|
||||
public enum OverlapDisplayMode { Off, Areas, Centroids, Both }
|
||||
public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Failed, Canceled, Stale }
|
||||
|
||||
/// <summary>UI-thread lifecycle policy, independent of workers, GDI and view transforms.</summary>
|
||||
@@ -34,7 +34,8 @@ public sealed class OverlapReportState
|
||||
{
|
||||
Clear(OverlapCheckStatus.Checking);
|
||||
stamp = OverlapGeometryStamp.Capture(plate);
|
||||
DisplayMode = OverlapDisplayMode.Areas;
|
||||
if (DisplayMode == OverlapDisplayMode.Off)
|
||||
DisplayMode = OverlapDisplayMode.Areas;
|
||||
return Generation;
|
||||
}
|
||||
|
||||
|
||||
@@ -150,14 +150,26 @@ public static class PlateOverlapAnalyzer
|
||||
var result = Collision.Check(localA.Outer, localB.Outer, localA.Holes, localB.Holes);
|
||||
if (!result.Overlaps)
|
||||
continue;
|
||||
var regions = result.OverlapRegions.Select(region => new PlateOverlapRegion(
|
||||
region.Vertices.Select(point => point + origin),
|
||||
OverlapMaterial.Area(region.Vertices))).ToList();
|
||||
var area = regions.Sum(region => region.Area);
|
||||
if (!double.IsFinite(area) || area <= 0)
|
||||
throw new ArithmeticException("Overlap area is not finite and positive.");
|
||||
// Evaluate every hole-subtracted fragment before restoring world space.
|
||||
// A failed moment must make this pair incomplete, never an origin marker.
|
||||
var moments = new List<PolygonAreaMoments>();
|
||||
var regions = new List<PlateOverlapRegion>();
|
||||
foreach (var region in result.OverlapRegions)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment))
|
||||
throw new ArithmeticException("Overlap fragment area moments are invalid.");
|
||||
moments.Add(fragment);
|
||||
regions.Add(new PlateOverlapRegion(region.Vertices.Select(point => point + origin),
|
||||
fragment.Area));
|
||||
}
|
||||
if (!PolygonAreaMoments.TryCombine(moments, out var combined))
|
||||
throw new ArithmeticException("Combined overlap area moments are invalid.");
|
||||
var centroid = combined.Centroid + origin;
|
||||
if (!OverlapMaterial.IsFinite(centroid))
|
||||
throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
|
||||
pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id,
|
||||
a.Input.Name, b.Input.Name, regions));
|
||||
a.Input.Name, b.Input.Name, regions, centroid));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class PlateOverlapPair
|
||||
private readonly Box bounds;
|
||||
|
||||
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
|
||||
List<PlateOverlapRegion> regions)
|
||||
List<PlateOverlapRegion> regions, Vector centroid)
|
||||
{
|
||||
PartAId = partAId;
|
||||
PartBId = partBId;
|
||||
@@ -33,6 +33,7 @@ public sealed class PlateOverlapPair
|
||||
PartBName = partBName;
|
||||
Regions = regions.AsReadOnly();
|
||||
Area = regions.Sum(region => region.Area);
|
||||
Centroid = centroid;
|
||||
var points = regions.SelectMany(region => region.Vertices).ToArray();
|
||||
var left = points.Min(point => point.X);
|
||||
var bottom = points.Min(point => point.Y);
|
||||
@@ -47,6 +48,11 @@ public sealed class PlateOverlapPair
|
||||
/// <summary>Convex fragments, not connected islands; no mutable kernel polygons are exposed.</summary>
|
||||
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
|
||||
public double Area { get; }
|
||||
/// <summary>
|
||||
/// Finite world-coordinate area centroid of all shared material, after hole subtraction.
|
||||
/// This can lie outside disconnected or concave shared material. Returned by value.
|
||||
/// </summary>
|
||||
public Vector Centroid { get; }
|
||||
/// <summary>A fresh world-coordinate bounds copy.</summary>
|
||||
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry;
|
||||
|
||||
/// <summary>
|
||||
/// Positive area and area centroid of a simple polygon, independent of winding.
|
||||
/// Moments are evaluated about a nearby origin rather than the world origin.
|
||||
/// </summary>
|
||||
public readonly struct PolygonAreaMoments
|
||||
{
|
||||
private PolygonAreaMoments(double area, Vector centroid)
|
||||
{
|
||||
Area = area;
|
||||
Centroid = centroid;
|
||||
}
|
||||
|
||||
public double Area { get; }
|
||||
public Vector Centroid { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Accepts an open vertex list or an exactly repeated closing vertex. Returns false
|
||||
/// for degenerate or nonfinite moments; does not validate polygon topology.
|
||||
/// </summary>
|
||||
public static bool TryCompute(IReadOnlyList<Vector> vertices, out PolygonAreaMoments moments)
|
||||
{
|
||||
moments = default;
|
||||
if (vertices == null || vertices.Count < 3)
|
||||
return false;
|
||||
foreach (var point in vertices)
|
||||
if (!IsFinite(point))
|
||||
return false;
|
||||
|
||||
var origin = vertices[0];
|
||||
var count = vertices.Count;
|
||||
if (vertices[count - 1].X == origin.X && vertices[count - 1].Y == origin.Y)
|
||||
count--;
|
||||
if (count < 3)
|
||||
return false;
|
||||
|
||||
var twiceArea = 0.0;
|
||||
var momentX = 0.0;
|
||||
var momentY = 0.0;
|
||||
// The closing edges meet the local origin and contribute zero. The signed
|
||||
// triangle fan also handles concavity without averaging polygon vertices.
|
||||
for (var i = 1; i + 1 < count; i++)
|
||||
{
|
||||
var a = vertices[i] - origin;
|
||||
var b = vertices[i + 1] - origin;
|
||||
var cross = a.X * b.Y - a.Y * b.X;
|
||||
twiceArea += cross;
|
||||
momentX += (a.X + b.X) * cross;
|
||||
momentY += (a.Y + b.Y) * cross;
|
||||
}
|
||||
|
||||
var area = System.Math.Abs(twiceArea) * 0.5;
|
||||
if (!double.IsFinite(area) || area <= 0
|
||||
|| !double.IsFinite(momentX) || !double.IsFinite(momentY))
|
||||
return false;
|
||||
// Dividing signed moments by signed area cancels the winding, while Area
|
||||
// stays positive so independently wound fragments always add material.
|
||||
var centroid = origin + new Vector(momentX / twiceArea / 3, momentY / twiceArea / 3);
|
||||
if (!IsFinite(centroid))
|
||||
return false;
|
||||
moments = new PolygonAreaMoments(area, centroid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines nonoverlapping, already hole-subtracted fragments using positive area
|
||||
/// weights and another local origin. The centroid may lie outside the material.
|
||||
/// Empty input or any invalid fragment fails the entire result.
|
||||
/// </summary>
|
||||
public static bool TryCombine(IEnumerable<PolygonAreaMoments> fragments, out PolygonAreaMoments moments)
|
||||
{
|
||||
moments = default;
|
||||
if (fragments == null)
|
||||
return false;
|
||||
var area = 0.0;
|
||||
var origin = Vector.Zero;
|
||||
var firstMoment = Vector.Zero;
|
||||
foreach (var fragment in fragments)
|
||||
{
|
||||
if (!double.IsFinite(fragment.Area) || fragment.Area <= 0 || !IsFinite(fragment.Centroid))
|
||||
return false;
|
||||
if (area == 0)
|
||||
origin = fragment.Centroid;
|
||||
firstMoment += (fragment.Centroid - origin) * fragment.Area;
|
||||
area += fragment.Area;
|
||||
}
|
||||
|
||||
if (!double.IsFinite(area) || area <= 0 || !IsFinite(firstMoment))
|
||||
return false;
|
||||
var centroid = origin + firstMoment / area;
|
||||
if (!IsFinite(centroid))
|
||||
return false;
|
||||
moments = new PolygonAreaMoments(area, centroid);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public void NewPlateResetsStateButRetainsDocumentDisplayPreference()
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ public class PlateOverlapAnalyzerTests
|
||||
var pair = Assert.Single(report.Pairs);
|
||||
Assert.Equal((0, 1), (pair.PartAId, pair.PartBId));
|
||||
Assert.Equal(0.5, pair.Area, 9);
|
||||
AssertCentroid(pair, 0.75, 0.5);
|
||||
Assert.NotEmpty(pair.Regions);
|
||||
Assert.All(pair.Regions, region =>
|
||||
{
|
||||
@@ -43,6 +44,7 @@ public class PlateOverlapAnalyzerTests
|
||||
Rectangle(0, 0, 4, 4), Rectangle(1, 1, 1, 1)
|
||||
}).Pairs);
|
||||
Assert.Equal(1, pair.Area, 9);
|
||||
AssertCentroid(pair, 1.5, 1.5);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -84,7 +86,9 @@ public class PlateOverlapAnalyzerTests
|
||||
insert.Location = new Vector(0.5, 1.5);
|
||||
report = PlateOverlapAnalyzer.Analyze(swap ? new[] { insert, frame } : new[] { frame, insert });
|
||||
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]
|
||||
@@ -94,7 +98,9 @@ public class PlateOverlapAnalyzerTests
|
||||
var b = WithContours(Square(0, 0, 4), Square(2, 2, 1));
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { a, b });
|
||||
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]
|
||||
@@ -109,6 +115,7 @@ public class PlateOverlapAnalyzerTests
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
var pair = Assert.Single(report.Pairs);
|
||||
Assert.Equal(2, pair.Area, 8);
|
||||
AssertCentroid(pair, 1.5, 2.5);
|
||||
Assert.All(pair.Regions, region => Assert.True(
|
||||
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));
|
||||
var pair = Assert.Single(report.Pairs);
|
||||
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(y + 1, pair.Bounds.Top, 7);
|
||||
}
|
||||
@@ -141,7 +149,9 @@ public class PlateOverlapAnalyzerTests
|
||||
part.Location = new Vector(10, 10);
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { part, Rectangle(8, 9, 2, 1) });
|
||||
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]
|
||||
@@ -297,6 +307,10 @@ public class PlateOverlapAnalyzerTests
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<Vector>)region.Vertices)[0] = new Vector(99, 99));
|
||||
pair.Bounds.X = 99;
|
||||
Assert.Equal(0, pair.Bounds.X);
|
||||
var centroid = pair.Centroid;
|
||||
centroid.X = 99;
|
||||
centroid.Y = 99;
|
||||
AssertCentroid(pair, 0.5, 0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -368,7 +382,9 @@ public class PlateOverlapAnalyzerTests
|
||||
small.Location = new Vector(999999998, 999999998);
|
||||
var report = PlateOverlapAnalyzer.Analyze(swap ? new[] { small, outer } : new[] { outer, small });
|
||||
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]
|
||||
@@ -423,6 +439,68 @@ public class PlateOverlapAnalyzerTests
|
||||
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) =>
|
||||
new(WithContours(new[] { new Vector(0, 0), new Vector(width, 0),
|
||||
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 off = Menu(host, "mnuOverlapOff");
|
||||
var areas = Menu(host, "mnuOverlapAreas");
|
||||
var centroids = Menu(host, "mnuOverlapCentroids");
|
||||
var both = Menu(host, "mnuOverlapBoth");
|
||||
Assert.False(check.Enabled);
|
||||
Assert.False(cancel.Enabled);
|
||||
Assert.False(off.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 };
|
||||
first.Show();
|
||||
@@ -286,6 +290,19 @@ public class PlateOverlapOverlayTests
|
||||
Assert.True(off.Checked);
|
||||
Assert.False(areas.Checked);
|
||||
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 context = new PumpContext();
|
||||
@@ -320,6 +337,458 @@ public class PlateOverlapOverlayTests
|
||||
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) =>
|
||||
(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 SetOrigin(float x, float y) => origin = new PointF(x, y);
|
||||
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
|
||||
@@ -342,6 +825,9 @@ public class PlateOverlapOverlayTests
|
||||
private readonly PumpContext context = new();
|
||||
private readonly SynchronizationContext? previous;
|
||||
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 int Calls;
|
||||
|
||||
@@ -356,12 +842,24 @@ public class PlateOverlapOverlayTests
|
||||
{
|
||||
Interlocked.Increment(ref Calls);
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
public Task Start() => View.CheckOverlapsAsync();
|
||||
public Task Start()
|
||||
{
|
||||
var task = View.CheckOverlapsAsync(Units.Inches);
|
||||
tasks.Add(task);
|
||||
return task;
|
||||
}
|
||||
public Work Next()
|
||||
{
|
||||
Assert.True(work.TryTake(out var result, TimeSpan.FromSeconds(15)), "Worker did not start.");
|
||||
@@ -376,9 +874,27 @@ public class PlateOverlapOverlayTests
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
View.Dispose();
|
||||
SynchronizationContext.SetSynchronizationContext(previous);
|
||||
work.Dispose();
|
||||
if (disposed)
|
||||
return;
|
||||
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 void Complete() => Result.SetResult(PlateOverlapAnalyzer.Analyze(snapshot));
|
||||
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
@@ -7,6 +8,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Controls;
|
||||
|
||||
@@ -23,6 +25,12 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
private GraphicsPath path;
|
||||
private PlateOverlapReport pathReport;
|
||||
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;
|
||||
|
||||
public OverlapOverlayController(PlateView view) => this.view = view;
|
||||
@@ -36,6 +44,8 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
internal Func<PlateOverlapSnapshot, CancellationToken, PlateOverlapReport> Analyze { get; set; }
|
||||
= PlateOverlapAnalyzer.Analyze;
|
||||
internal GraphicsPath CachedPath => path;
|
||||
internal OverlapHoverPages HoverPages => hoverPages;
|
||||
internal RectangleF HoverBounds { get; private set; }
|
||||
|
||||
public OverlapDisplayMode DisplayMode
|
||||
{
|
||||
@@ -65,7 +75,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
NotifyChanged();
|
||||
}
|
||||
|
||||
public async Task CheckAsync()
|
||||
public async Task CheckAsync(Units units)
|
||||
{
|
||||
if (!CanUseView || view.Plate == null)
|
||||
return;
|
||||
@@ -75,6 +85,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
CancelWorker();
|
||||
var plate = view.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();
|
||||
cancellation = source;
|
||||
var token = source.Token;
|
||||
@@ -162,6 +173,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
|
||||
private void NotifyChanged()
|
||||
{
|
||||
ClearHover();
|
||||
if (disposed || view.IsDisposed || view.Disposing)
|
||||
return;
|
||||
view.Status = state.Message;
|
||||
@@ -187,26 +199,194 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
{
|
||||
if (disposed)
|
||||
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;
|
||||
state.EnsureFresh(view.Plate);
|
||||
var fresh = state.EnsureFresh(view.Plate);
|
||||
if (generation != state.Generation)
|
||||
{
|
||||
CancelWorker();
|
||||
ReleasePath();
|
||||
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 (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 (!EnsureFresh())
|
||||
ClearHover();
|
||||
ValidateHoverTransform();
|
||||
return hoverText;
|
||||
}
|
||||
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()
|
||||
@@ -283,5 +463,6 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
CancelWorker();
|
||||
state.Reset();
|
||||
ReleasePath();
|
||||
ClearHover();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ namespace OpenNest.Controls
|
||||
private LayoutPart hoveredPart;
|
||||
private Point hoverPoint;
|
||||
private bool showTooltip;
|
||||
private bool hoverPending;
|
||||
private Timer hoverTimer;
|
||||
|
||||
public Box ActiveWorkArea
|
||||
@@ -229,7 +230,7 @@ namespace OpenNest.Controls
|
||||
add => 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 InvalidateOverlapCheck() => overlapOverlay.Invalidate();
|
||||
|
||||
@@ -250,6 +251,40 @@ namespace OpenNest.Controls
|
||||
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)
|
||||
{
|
||||
if (drgevent.Data.GetData(typeof(Drawing)) != null)
|
||||
@@ -271,6 +306,7 @@ namespace OpenNest.Controls
|
||||
|
||||
protected override void OnMouseDown(MouseEventArgs e)
|
||||
{
|
||||
ClearHover();
|
||||
if (!Focused)
|
||||
Focus();
|
||||
|
||||
@@ -359,6 +395,8 @@ namespace OpenNest.Controls
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
{
|
||||
if (e.Button != MouseButtons.None)
|
||||
ClearHover();
|
||||
if (e.Button == MouseButtons.Middle)
|
||||
{
|
||||
if (AllowPan)
|
||||
@@ -389,19 +427,18 @@ namespace OpenNest.Controls
|
||||
if (e.Button == MouseButtons.None && actionManager.CurrentAction is ActionSelect)
|
||||
{
|
||||
hoverPoint = e.Location;
|
||||
hoverPending = true;
|
||||
showTooltip = false;
|
||||
hoverTimer.Stop();
|
||||
hoverTimer.Start();
|
||||
|
||||
if (hoveredPart != null)
|
||||
Invalidate();
|
||||
overlapOverlay.UpdateHover(e.Location);
|
||||
}
|
||||
else if (hoveredPart != null || showTooltip)
|
||||
else
|
||||
{
|
||||
hoveredPart = null;
|
||||
hoverTimer.Stop();
|
||||
showTooltip = false;
|
||||
Invalidate();
|
||||
ClearHover();
|
||||
}
|
||||
|
||||
base.OnMouseMove(e);
|
||||
@@ -415,6 +452,18 @@ namespace OpenNest.Controls
|
||||
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)
|
||||
{
|
||||
switch (e.KeyCode)
|
||||
@@ -517,7 +566,7 @@ namespace OpenNest.Controls
|
||||
|
||||
base.OnPaint(e);
|
||||
|
||||
if (hoveredPart != null && showTooltip)
|
||||
if (!overlapOverlay.DrawHover(e.Graphics) && hoveredPart != null && showTooltip)
|
||||
{
|
||||
e.Graphics.ResetTransform();
|
||||
var text = hoveredPart.BasePart.BaseDrawing.Name;
|
||||
@@ -542,6 +591,7 @@ namespace OpenNest.Controls
|
||||
|
||||
protected override void OnHandleDestroyed(EventArgs e)
|
||||
{
|
||||
ClearHover();
|
||||
overlapOverlay.ReleaseHandle();
|
||||
base.OnHandleDestroyed(e);
|
||||
actionManager.Cleanup();
|
||||
@@ -550,7 +600,12 @@ namespace OpenNest.Controls
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
overlapOverlay?.Dispose();
|
||||
hoverTimer?.Dispose();
|
||||
hoverTimer = null;
|
||||
redrawTimer?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
@@ -706,6 +761,9 @@ namespace OpenNest.Controls
|
||||
|
||||
private void HoverCheck()
|
||||
{
|
||||
if (!hoverPending || IsDisposed || Disposing)
|
||||
return;
|
||||
hoverPending = false;
|
||||
var graphPt = PointControlToGraph(hoverPoint);
|
||||
LayoutPart hitPart = null;
|
||||
|
||||
@@ -793,6 +851,7 @@ namespace OpenNest.Controls
|
||||
|
||||
protected override void UpdateMatrix()
|
||||
{
|
||||
ClearHover();
|
||||
base.UpdateMatrix();
|
||||
parts.ForEach(p => p.Update(this));
|
||||
previewManager.Update();
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace OpenNest.Forms
|
||||
public readonly Document Document;
|
||||
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 OverlapDisplayMode OverlapDisplay
|
||||
{
|
||||
|
||||
Generated
+17
-1
@@ -54,6 +54,8 @@
|
||||
mnuOverlapDisplay = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuOverlapOff = 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();
|
||||
mnuViewDrawPiercePoints = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem();
|
||||
@@ -353,7 +355,7 @@
|
||||
//
|
||||
mnuOverlapDisplay.Name = "mnuOverlapDisplay";
|
||||
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;
|
||||
//
|
||||
// mnuOverlapOff
|
||||
@@ -367,6 +369,18 @@
|
||||
mnuOverlapAreas.Name = "mnuOverlapAreas";
|
||||
mnuOverlapAreas.Text = "Areas";
|
||||
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
|
||||
//
|
||||
@@ -1292,6 +1306,8 @@
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuOverlapDisplay;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuOverlapOff;
|
||||
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 mnuViewDrawPiercePoints;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawBounds;
|
||||
|
||||
@@ -675,8 +675,12 @@ namespace OpenNest.Forms
|
||||
mnuOverlapDisplay.Enabled = hasPlate;
|
||||
mnuOverlapOff.Enabled = hasPlate;
|
||||
mnuOverlapAreas.Enabled = hasPlate;
|
||||
mnuOverlapCentroids.Enabled = hasPlate;
|
||||
mnuOverlapBoth.Enabled = hasPlate;
|
||||
mnuOverlapOff.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Off;
|
||||
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)
|
||||
@@ -700,6 +704,18 @@ namespace OpenNest.Forms
|
||||
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)
|
||||
{
|
||||
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.
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
## 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,
|
||||
selection, cutting paths, or export behavior. Cutoffs and temporary preview parts
|
||||
are excluded. **Cancel Check** discards the running request. **Display > Off /
|
||||
Areas** changes visibility without rerunning analysis; checking from Off shows
|
||||
Areas. Display preferences belong to the current document and are not saved.
|
||||
Areas / Centroids / Both** changes visibility without rerunning analysis. Areas
|
||||
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,
|
||||
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)
|
||||
{
|
||||
// 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)
|
||||
{
|
||||
// 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,
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
already applies origin translation. Paths are rebuilt for report/scale changes,
|
||||
not ordinary repaints or panning. The state label saves/restores graphics state.
|
||||
Centroid hit tests use only cached report coordinates and DPI-scaled screen
|
||||
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
|
||||
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:
|
||||
|
||||
```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
|
||||
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,
|
||||
menu/MDI, path-cache, uniform-fill pixel, and control-lifetime checks. Run those
|
||||
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,
|
||||
check partial overlap, containment, inside-hole placement, pan/zoom and quadrant
|
||||
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