mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 23:11:07 -04:00
feat(diagnostics): place overlap markers on the shared material
The overlap crosshair sat on the pair's area centroid. For a split,
holed or concave overlap, that point can be off the red material:
between two patches, in a hole, or inside a curve.
PlateOverlapPair.Marker is a point inside the pair's largest island.
It is the island's pole of inaccessibility (PolyLabel), searched to a
tenth of the island's area/perimeter within a 4096-cell budget. If
that bounded search does not land inside (a long thin sliver), the
marker falls back to the midpoint of the widest interior run of a
horizontal scan line placed between vertex heights.
The overlay crosshair, marker hit testing, hover coordinates
("Marker:") and the cutting-inspection ring now use the marker.
Centroid stays the true area centroid. View > Overlap Check > Display
> Centroids is now labelled Markers.
Before the marker existed, its call sites used the centroid. Two
patches, a covered frame, a half annulus and a thin crescent then put
it off the material; now all four land on it. Disabling the scan-line
fallback fails its test. On the 389-plate corpus, markers add about
0.4 ms per overlapping pair; pair and area results are unchanged.
This commit is contained in:
1 parent
dc0aab06fc
commit
80d01839bd
18 files changed
+315
-50
No files matched your search
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// A marker point on one island of shared material. The area centroid of a concave,
|
||||
/// holed or split overlap can lie off the material, so markers use the island's pole of
|
||||
/// inaccessibility instead, with a guaranteed-interior fallback.
|
||||
/// </summary>
|
||||
internal static class OverlapMarker
|
||||
{
|
||||
// Distance evaluations per island; a long sliver otherwise refines along its whole ridge.
|
||||
internal const int MaxCells = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// The island's pole of inaccessibility (searched to a tenth of its area/perimeter, a
|
||||
/// lower bound on a thin island's half-width) when that bounded search lands inside;
|
||||
/// otherwise the midpoint of the widest interior run of a horizontal scan line; otherwise
|
||||
/// <paramref name="fallback"/>. Rings are closed, as the clipping returns them.
|
||||
/// </summary>
|
||||
internal static Vector Find(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, double area,
|
||||
Vector fallback, int maxCells = MaxCells)
|
||||
{
|
||||
var outer = ToPolygon(outline);
|
||||
var hollow = holes.Select(ToPolygon).ToList();
|
||||
var perimeter = outer.Perimeter() + hollow.Sum(hole => hole.Perimeter());
|
||||
var precision = 0.1 * area / perimeter;
|
||||
if (double.IsFinite(precision) && precision > 0)
|
||||
{
|
||||
var pole = PolyLabel.Find(outer, hollow, precision, maxCells);
|
||||
if (Inside(outline, holes, pole))
|
||||
return pole;
|
||||
}
|
||||
return TryScanLine(outline, holes, out var point) ? point : fallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Midpoint of the widest interior run along a horizontal line through the middle of the
|
||||
/// island, placed between vertex heights so crossings are unambiguous (as JTS's
|
||||
/// interior-point-of-area). Even-odd pairing of outline and hole crossings keeps it
|
||||
/// inside the material.
|
||||
/// </summary>
|
||||
private static bool TryScanLine(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, out Vector point)
|
||||
{
|
||||
point = default;
|
||||
var minY = outline.Min(vertex => vertex.Y);
|
||||
var maxY = outline.Max(vertex => vertex.Y);
|
||||
var centre = (minY + maxY) / 2;
|
||||
double below = minY, above = maxY;
|
||||
foreach (var vertex in outline.Concat(holes.SelectMany(hole => hole)))
|
||||
{
|
||||
if (vertex.Y <= centre && vertex.Y > below)
|
||||
below = vertex.Y;
|
||||
else if (vertex.Y > centre && vertex.Y < above)
|
||||
above = vertex.Y;
|
||||
}
|
||||
var y = (below + above) / 2;
|
||||
if (!(y > minY && y < maxY))
|
||||
return false;
|
||||
|
||||
var crossings = new List<double>();
|
||||
AddCrossings(outline, y, crossings);
|
||||
foreach (var hole in holes)
|
||||
AddCrossings(hole, y, crossings);
|
||||
crossings.Sort();
|
||||
var widest = 0.0;
|
||||
for (var i = 0; i + 1 < crossings.Count; i += 2)
|
||||
{
|
||||
var width = crossings[i + 1] - crossings[i];
|
||||
if (width > widest)
|
||||
{
|
||||
widest = width;
|
||||
point = new Vector((crossings[i] + crossings[i + 1]) / 2, y);
|
||||
}
|
||||
}
|
||||
return widest > 0;
|
||||
}
|
||||
|
||||
private static void AddCrossings(IReadOnlyList<Vector> ring, double y, List<double> crossings)
|
||||
{
|
||||
for (var i = 0; i + 1 < ring.Count; i++)
|
||||
{
|
||||
var a = ring[i];
|
||||
var b = ring[i + 1];
|
||||
if ((a.Y > y) != (b.Y > y))
|
||||
crossings.Add(a.X + (y - a.Y) * (b.X - a.X) / (b.Y - a.Y));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Inside(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, Vector point) =>
|
||||
double.IsFinite(point.X) && double.IsFinite(point.Y)
|
||||
&& InsideRing(outline, point) && !holes.Any(hole => InsideRing(hole, point));
|
||||
|
||||
// Crossing-number test over a closed ring (half-open edges, so a vertex counts once).
|
||||
private static bool InsideRing(IReadOnlyList<Vector> ring, Vector point)
|
||||
{
|
||||
var inside = false;
|
||||
for (var i = 0; i + 1 < ring.Count; i++)
|
||||
{
|
||||
var a = ring[i];
|
||||
var b = ring[i + 1];
|
||||
if ((a.Y > point.Y) != (b.Y > point.Y)
|
||||
&& point.X < a.X + (point.Y - a.Y) * (b.X - a.X) / (b.Y - a.Y))
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static Polygon ToPolygon(IReadOnlyList<Vector> ring)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.AddRange(ring);
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public static class OverlapPairPresentation
|
||||
: $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}";
|
||||
return head + "\n"
|
||||
+ $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
|
||||
+ $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
|
||||
+ $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}";
|
||||
}
|
||||
|
||||
// Significant figures adapt to scale: a positive sliver must never read as zero.
|
||||
@@ -34,7 +34,7 @@ public static class OverlapPairPresentation
|
||||
public static double HitRadius(int deviceDpi) => 10.0 * deviceDpi / 96;
|
||||
|
||||
/// <summary>
|
||||
/// Projects cached centroids into a screen-pixel coordinate space. The pointer must
|
||||
/// Projects cached markers 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>
|
||||
@@ -44,7 +44,7 @@ public static class OverlapPairPresentation
|
||||
var radius = HitRadius(deviceDpi);
|
||||
return pairs.Where(pair =>
|
||||
{
|
||||
var center = worldToScreen(pair.Centroid);
|
||||
var center = worldToScreen(pair.Marker);
|
||||
var dx = pointer.X - center.X;
|
||||
var dy = pointer.Y - center.Y;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
|
||||
@@ -210,10 +210,10 @@ public static class PlateOverlapAnalyzer
|
||||
var localA = a.Material.Transform(0, origin * -1);
|
||||
var localB = b.Material.Transform(0, origin * -1);
|
||||
if (!TryShare(localA, localB.Outer, localB.Holes, origin, cancellationToken,
|
||||
out var regions, out var centroid))
|
||||
out var regions, out var centroid, out var marker))
|
||||
continue;
|
||||
pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id,
|
||||
a.Input.Name, b.Input.Name, regions, centroid));
|
||||
a.Input.Name, b.Input.Name, regions, centroid, marker));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
@@ -280,11 +280,12 @@ public static class PlateOverlapAnalyzer
|
||||
var origin = part.Material.Outer.Vertices[0];
|
||||
var local = part.Material.Transform(0, origin * -1);
|
||||
var rect = RectanglePolygon(zone, origin * -1);
|
||||
if (!TryShare(local, rect, null, origin, cancellationToken, out var regions, out var centroid))
|
||||
if (!TryShare(local, rect, null, origin, cancellationToken,
|
||||
out var regions, out var centroid, out var marker))
|
||||
continue;
|
||||
findings.Add(new PlateOverlapPair(part.Input.Id,
|
||||
ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
|
||||
$"Clamp '{clamp.Id}'", regions, centroid));
|
||||
$"Clamp '{clamp.Id}'", regions, centroid, marker));
|
||||
}
|
||||
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||
{
|
||||
@@ -298,16 +299,19 @@ public static class PlateOverlapAnalyzer
|
||||
|
||||
/// <summary>
|
||||
/// Shared material of a rebased operand with another rebased outline/holes, as world
|
||||
/// regions (connected islands) plus the area centroid of them all. Returns false when the
|
||||
/// regions (connected islands) plus the area centroid of them all and a marker point
|
||||
/// inside the largest island. Returns false when the
|
||||
/// total shared area does not exceed <see cref="OpenNest.Math.Tolerance.Epsilon"/>: contact and rounding
|
||||
/// dust are not overlap. Invalid moments throw, so the pair becomes incomplete rather
|
||||
/// than clear or marked at the origin.
|
||||
/// </summary>
|
||||
private static bool TryShare(OverlapMaterial localA, Polygon outerB, List<Polygon> holesB, Vector origin,
|
||||
CancellationToken cancellationToken, out List<PlateOverlapRegion> regions, out Vector centroid)
|
||||
CancellationToken cancellationToken, out List<PlateOverlapRegion> regions, out Vector centroid,
|
||||
out Vector marker)
|
||||
{
|
||||
regions = null;
|
||||
centroid = default;
|
||||
marker = default;
|
||||
var islands = OverlapClipping.Intersect(localA.Outer, localA.Holes, outerB, holesB);
|
||||
var area = islands.Sum(island => island.Moments.Area);
|
||||
if (!double.IsFinite(area))
|
||||
@@ -326,6 +330,12 @@ public static class PlateOverlapAnalyzer
|
||||
centroid = combined.Centroid + origin;
|
||||
if (!OverlapMaterial.IsFinite(centroid))
|
||||
throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
|
||||
// The area centroid can lie between islands or in a hole: mark the largest island.
|
||||
var largest = islands.Aggregate((best, island) => island.Moments.Area > best.Moments.Area ? island : best);
|
||||
marker = OverlapMarker.Find(largest.Outline, largest.Holes, largest.Moments.Area, largest.Moments.Centroid)
|
||||
+ origin;
|
||||
if (!OverlapMaterial.IsFinite(marker))
|
||||
throw new ArithmeticException("Overlap marker is not finite in world coordinates.");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -340,7 +350,8 @@ public static class PlateOverlapAnalyzer
|
||||
};
|
||||
var centre = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
|
||||
return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
|
||||
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, centre);
|
||||
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, centre,
|
||||
centre);
|
||||
}
|
||||
|
||||
private static Polygon RectanglePolygon(Box box, Vector offset)
|
||||
|
||||
@@ -52,6 +52,7 @@ public sealed class PlateOverlapPair
|
||||
Regions = source.Regions;
|
||||
Area = source.Area;
|
||||
Centroid = source.Centroid;
|
||||
Marker = source.Marker;
|
||||
bounds = source.bounds;
|
||||
}
|
||||
|
||||
@@ -60,7 +61,7 @@ public sealed class PlateOverlapPair
|
||||
new(this, partAId, partBId, partAName, partBName);
|
||||
|
||||
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
|
||||
List<PlateOverlapRegion> regions, Vector centroid)
|
||||
List<PlateOverlapRegion> regions, Vector centroid, Vector marker)
|
||||
{
|
||||
PartAId = partAId;
|
||||
PartBId = partBId;
|
||||
@@ -69,6 +70,7 @@ public sealed class PlateOverlapPair
|
||||
Regions = regions.AsReadOnly();
|
||||
Area = regions.Sum(region => region.Area);
|
||||
Centroid = centroid;
|
||||
Marker = marker;
|
||||
var points = regions.SelectMany(region => region.Vertices).ToArray();
|
||||
var left = points.Min(point => point.X);
|
||||
var bottom = points.Min(point => point.Y);
|
||||
@@ -88,6 +90,13 @@ public sealed class PlateOverlapPair
|
||||
/// This can lie outside disconnected or concave shared material. Returned by value.
|
||||
/// </summary>
|
||||
public Vector Centroid { get; }
|
||||
/// <summary>
|
||||
/// Finite world-coordinate point on the shared material, inside the pair's largest island:
|
||||
/// its pole of inaccessibility, or an interior scan-line point when a thin island defeats
|
||||
/// the bounded pole search. Markers, hit testing and inspection rings use this point.
|
||||
/// Returned by value.
|
||||
/// </summary>
|
||||
public Vector Marker { get; }
|
||||
/// <summary>A fresh world-coordinate bounds copy.</summary>
|
||||
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
||||
}
|
||||
|
||||
@@ -201,11 +201,11 @@ public static class PostVerificationAnalyzer
|
||||
foreach (var pair in overlap.Pairs)
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
||||
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).")
|
||||
{ Location = pair.Centroid, Overlap = pair });
|
||||
{ Location = pair.Marker, Overlap = pair });
|
||||
foreach (var pair in overlap.ClampFindings)
|
||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
||||
null, $"Clean drawing material overlaps {pair.PartBName} (holes subtracted).")
|
||||
{ Location = pair.Centroid, Overlap = pair });
|
||||
{ Location = pair.Marker, Overlap = pair });
|
||||
foreach (var issue in overlap.Issues)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
|
||||
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
|
||||
|
||||
@@ -142,8 +142,9 @@ public class IncrementalOverlapAnalysisTests
|
||||
private static string Describe(PlateOverlapReport report) =>
|
||||
string.Join(";", report.Pairs.Select(pair =>
|
||||
$"{pair.PartAId}/{pair.PartBId}[{pair.PartAName}|{pair.PartBName}]:{pair.Area:R}:"
|
||||
+ $"{pair.Centroid.X:R},{pair.Centroid.Y:R}:"
|
||||
+ string.Join(",", pair.Regions.SelectMany(region => region.Vertices).Select(v => $"{v.X:R} {v.Y:R}"))))
|
||||
+ $"{pair.Centroid.X:R},{pair.Centroid.Y:R}:{pair.Marker.X:R},{pair.Marker.Y:R}:"
|
||||
+ string.Join(",", pair.Regions.SelectMany(region => region.Vertices
|
||||
.Concat(region.Holes.SelectMany(hole => hole))).Select(v => $"{v.X:R} {v.Y:R}"))))
|
||||
+ "|" + string.Join(";", report.Issues.Select(issue => $"{issue.PartAId}/{issue.PartBId}:{issue.Message}"));
|
||||
|
||||
private static Drawing Square(string name, double size) =>
|
||||
|
||||
@@ -22,7 +22,7 @@ public class OverlapHoverPagesTests
|
||||
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.Contains("Marker: (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));
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Diagnostics;
|
||||
|
||||
public class OverlapMarkerTests
|
||||
{
|
||||
// 4x4 frame with a 2x2 hole: the area centroid (2, 2) is in the hole.
|
||||
private static readonly List<Vector> Frame = Closed(new Vector(0, 0), new Vector(4, 0), new Vector(4, 4), new Vector(0, 4));
|
||||
private static readonly List<List<Vector>> FrameHole =
|
||||
new() { Closed(new Vector(1, 1), new Vector(1, 3), new Vector(3, 3), new Vector(3, 1)) };
|
||||
|
||||
[Fact]
|
||||
public void PoleIsDeepInsideTheMaterial()
|
||||
{
|
||||
var marker = OverlapMarker.Find(Frame, FrameHole, 12, new Vector(2, 2));
|
||||
|
||||
// The frame's bars are 1 wide: the pole sits on a bar's centre line.
|
||||
Assert.True(Inside(marker), $"{marker.X:R}, {marker.Y:R}");
|
||||
var depth = new[] { marker.X, 4 - marker.X, marker.Y, 4 - marker.Y }.Min();
|
||||
Assert.InRange(depth, 0.4, 0.6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExhaustedSearchFallsBackToAnInteriorScanLinePoint()
|
||||
{
|
||||
// One cell of budget: the search cannot leave the hole-centred bounding-box cell.
|
||||
var marker = OverlapMarker.Find(Frame, FrameHole, 12, new Vector(2, 2), maxCells: 1);
|
||||
|
||||
Assert.True(Inside(marker), $"{marker.X:R}, {marker.Y:R}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoInteriorRunReturnsTheFallback()
|
||||
{
|
||||
var fallback = new Vector(7, 7);
|
||||
var marker = OverlapMarker.Find(Closed(new Vector(0, 0), new Vector(1, 0), new Vector(2, 0)),
|
||||
new List<List<Vector>>(), 0, fallback, maxCells: 1);
|
||||
|
||||
Assert.Equal(fallback, marker);
|
||||
}
|
||||
|
||||
private static bool Inside(Vector point) =>
|
||||
point.X > 0 && point.X < 4 && point.Y > 0 && point.Y < 4
|
||||
&& !(point.X >= 1 && point.X <= 3 && point.Y >= 1 && point.Y <= 3);
|
||||
|
||||
private static List<Vector> Closed(params Vector[] points) => points.Append(points[0]).ToList();
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public class OverlapPairPresentationTests
|
||||
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);
|
||||
Assert.Equal($"Pair 2/3: left / right\nShared area ≈ 16 {symbol}²\nMarker: (2, 2) {symbol}", text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -45,7 +45,7 @@ public class OverlapPairPresentationTests
|
||||
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 center = ToScreen(pairs[0].Marker);
|
||||
var radius = OverlapPairPresentation.HitRadius(dpi);
|
||||
Assert.Single(OverlapPairPresentation.HitTest(pairs, ToScreen,
|
||||
center + new Vector(radius, 0), dpi));
|
||||
|
||||
@@ -553,6 +553,66 @@ public class PlateOverlapAnalyzerTests
|
||||
return twice / 2;
|
||||
}
|
||||
|
||||
// Each row's area centroid lies off the red material (between patches, in a hole, or
|
||||
// inside a curve); the marker must sit on the shared material instead.
|
||||
[Theory]
|
||||
[InlineData("two patches")]
|
||||
[InlineData("frame covered")]
|
||||
[InlineData("half annulus")]
|
||||
[InlineData("thin crescent")]
|
||||
public void Analyze_MarkerLiesOnSharedMaterialWhereTheCentroidDoesNot(string scenario)
|
||||
{
|
||||
var parts = scenario switch
|
||||
{
|
||||
"two patches" => new[]
|
||||
{
|
||||
WithContours(new[]
|
||||
{
|
||||
new Vector(0, 0), new Vector(3, 0), new Vector(3, 3), new Vector(2, 3),
|
||||
new Vector(2, 1), new Vector(1, 1), new Vector(1, 3), new Vector(0, 3)
|
||||
}),
|
||||
Rectangle(0, 2, 3, 1)
|
||||
},
|
||||
"frame covered" => new[] { Rectangle(-1, -1, 6, 6), WithContours(Square(0, 0, 4), Square(1, 1, 2)) },
|
||||
"half annulus" => new[] { WithContours(Circle(0, 0, 5), Circle(0, 0, 4.5)), Rectangle(0, -10, 10, 20) },
|
||||
_ => new[] { WithContours(Circle(0, 0, 10)), WithContours(Square(-15, -15, 30), Circle(0.01, 0, 10)) },
|
||||
};
|
||||
|
||||
var report = PlateOverlapAnalyzer.Analyze(parts);
|
||||
|
||||
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
|
||||
var pair = Assert.Single(report.Pairs);
|
||||
Assert.False(OnMaterial(pair, pair.Centroid), "precondition: the centroid is off the material");
|
||||
Assert.True(OnMaterial(pair, pair.Marker), $"marker {pair.Marker.X:R}, {pair.Marker.Y:R}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_LongStraightSliverMarkerIsOnTheSliver()
|
||||
{
|
||||
var report = PlateOverlapAnalyzer.Analyze(new[] { Rectangle(0, 0, 48, 24), Rectangle(0, 23.998, 48, 24) });
|
||||
|
||||
var pair = Assert.Single(report.Pairs);
|
||||
Assert.True(OnMaterial(pair, pair.Marker), $"marker {pair.Marker.X:R}, {pair.Marker.Y:R}");
|
||||
}
|
||||
|
||||
private static bool OnMaterial(PlateOverlapPair pair, Vector point) =>
|
||||
pair.Regions.Any(region => InsideRing(region.Vertices, point)
|
||||
&& !region.Holes.Any(hole => InsideRing(hole, point)));
|
||||
|
||||
private static bool InsideRing(IReadOnlyList<Vector> ring, Vector point)
|
||||
{
|
||||
var inside = false;
|
||||
for (int i = 0, j = ring.Count - 1; i < ring.Count; j = i++)
|
||||
if ((ring[i].Y > point.Y) != (ring[j].Y > point.Y)
|
||||
&& point.X < (ring[j].X - ring[i].X) * (point.Y - ring[i].Y) / (ring[j].Y - ring[i].Y) + ring[i].X)
|
||||
inside = !inside;
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static Vector[] Circle(double x, double y, double radius) =>
|
||||
Enumerable.Range(0, 256).Select(i => new Vector(x + radius * System.Math.Cos(i * System.Math.PI / 128),
|
||||
y + radius * System.Math.Sin(i * System.Math.PI / 128))).ToArray();
|
||||
|
||||
// A's square hole closes the mouth of B's C-shaped hole, so the material enclosed by
|
||||
// both holes is a separate island inside the shared hole and must not be dropped.
|
||||
[Fact]
|
||||
|
||||
@@ -251,7 +251,7 @@ public class CuttingValidationPanelTests
|
||||
var view = editor.PlateView;
|
||||
Assert.Same(plate, view.Plate);
|
||||
Assert.NotNull(view.ValidationOverlay.Finding.Overlap);
|
||||
Assert.Contains("centroid", Field<TextBox>(panel, "resultLabel").Text);
|
||||
Assert.Contains("largest patch", Field<TextBox>(panel, "resultLabel").Text);
|
||||
var scale = view.ViewScale;
|
||||
InvokeView(view, "OnMouseWheel", new MouseEventArgs(MouseButtons.None, 0, 100, 100, 120));
|
||||
Assert.True(view.ViewScale > scale);
|
||||
|
||||
@@ -456,7 +456,7 @@ public class PlateOverlapOverlayTests
|
||||
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);
|
||||
var center = form.PlateView.PointWorldToControl(form.PlateView.OverlapReport.Pairs[0].Marker);
|
||||
form.PlateView.OverlapOverlay.UpdateHover(center);
|
||||
Assert.Contains(" mm²", form.PlateView.OverlapOverlay.HoverText);
|
||||
Assert.Contains("same name / same name", form.PlateView.OverlapOverlay.HoverText);
|
||||
@@ -536,7 +536,7 @@ public class PlateOverlapOverlayTests
|
||||
graphics.TranslateTransform(60, 320);
|
||||
run.View.OverlapOverlay.Draw(graphics);
|
||||
Assert.Null(run.View.OverlapOverlay.CachedPath);
|
||||
var center = run.View.PointWorldToControl(pair.Centroid);
|
||||
var center = run.View.PointWorldToControl(pair.Marker);
|
||||
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());
|
||||
@@ -550,7 +550,7 @@ public class PlateOverlapOverlayTests
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void MarkerAndHoverUsePairMaterialCentroidRatherThanBoundsCenter() => RunSta(() =>
|
||||
public void MarkerAndHoverUseThePoleOfTheSharedMaterialRatherThanBoundsCenter() => RunSta(() =>
|
||||
{
|
||||
using var run = new OverlayRun();
|
||||
run.View.Plate.Parts.Clear();
|
||||
@@ -571,7 +571,12 @@ public class PlateOverlapOverlayTests
|
||||
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));
|
||||
// The marker is the shared triangle's pole: its incenter (r, r), r = 2A / P.
|
||||
var marker = Assert.Single(run.View.OverlapReport.Pairs).Marker;
|
||||
var inradius = 2 * 9 / (6 + 3 + System.Math.Sqrt(45));
|
||||
Assert.InRange(marker.X, inradius - 0.06, inradius + 0.06);
|
||||
Assert.InRange(marker.Y, inradius - 0.06, inradius + 0.06);
|
||||
var materialCenter = run.View.PointWorldToControl(marker);
|
||||
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());
|
||||
@@ -620,7 +625,7 @@ public class PlateOverlapOverlayTests
|
||||
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));
|
||||
run.View.MoveTo(run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Marker));
|
||||
var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic;
|
||||
var hoverCheck = typeof(PlateView).GetMethod("HoverCheck", flags)!;
|
||||
var normalVisible = typeof(PlateView).GetField("showTooltip", flags)!;
|
||||
@@ -657,7 +662,7 @@ public class PlateOverlapOverlayTests
|
||||
run.Finish();
|
||||
run.View.ZoomToPoint(new Vector(), 20);
|
||||
run.View.SetOrigin(60, 220);
|
||||
var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid);
|
||||
var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Marker);
|
||||
run.View.MoveTo(center);
|
||||
Assert.NotNull(run.View.OverlapOverlay.HoverText);
|
||||
switch (change)
|
||||
@@ -726,7 +731,7 @@ public class PlateOverlapOverlayTests
|
||||
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));
|
||||
run.View.MoveTo(run.View.PointWorldToControl(report.Pairs[0].Marker));
|
||||
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));
|
||||
@@ -734,7 +739,7 @@ public class PlateOverlapOverlayTests
|
||||
Assert.Equal(1, run.Calls);
|
||||
|
||||
var scale = run.View.ViewScale;
|
||||
run.View.WheelAt(run.View.PointWorldToControl(report.Pairs[0].Centroid));
|
||||
run.View.WheelAt(run.View.PointWorldToControl(report.Pairs[0].Marker));
|
||||
Assert.True(run.View.ViewScale > scale); // Wheel remains zoom, never paging.
|
||||
Assert.Null(overlay.HoverText);
|
||||
Assert.Null(overlay.HoverPages);
|
||||
@@ -792,7 +797,7 @@ public class PlateOverlapOverlayTests
|
||||
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);
|
||||
var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Marker);
|
||||
using var largerFont = new Font(run.View.Font.FontFamily, run.View.Font.Size + 1);
|
||||
switch (change)
|
||||
{
|
||||
@@ -834,7 +839,7 @@ public class PlateOverlapOverlayTests
|
||||
run.Finish();
|
||||
run.View.ZoomToPoint(new Vector(), 10);
|
||||
run.View.SetOrigin(30, 90);
|
||||
var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Centroid);
|
||||
var center = run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Marker);
|
||||
run.View.MoveTo(center);
|
||||
using var image = new Bitmap(150, 120);
|
||||
using var graphics = Graphics.FromImage(image);
|
||||
@@ -852,7 +857,7 @@ public class PlateOverlapOverlayTests
|
||||
}
|
||||
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));
|
||||
overlay.UpdateHover(run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Marker));
|
||||
Assert.True(overlay.DrawHover(graphics));
|
||||
Assert.True(overlay.HoverPages.NeedsLargerViewport);
|
||||
Assert.False(run.View.Command(Keys.PageDown));
|
||||
@@ -1004,7 +1009,7 @@ public class PlateOverlapOverlayTests
|
||||
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));
|
||||
run.View.MoveTo(run.View.PointWorldToControl(run.View.OverlapReport.Pairs[0].Marker));
|
||||
}
|
||||
|
||||
// These waits are deliberate: only Task.Run workers use the result gate; the test's
|
||||
|
||||
@@ -488,7 +488,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
var labelRows = new Dictionary<PointF, int>();
|
||||
foreach (var pair in state.DisplayPairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId))
|
||||
{
|
||||
var center = view.PointWorldToGraph(pair.Centroid);
|
||||
var center = view.PointWorldToGraph(pair.Marker);
|
||||
if (!float.IsFinite(center.X) || !float.IsFinite(center.Y))
|
||||
continue;
|
||||
foreach (var pen in new[] { halo, crosshair })
|
||||
|
||||
@@ -448,7 +448,7 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
{
|
||||
PostVerificationKind.Overlap when finding.ClampId != null =>
|
||||
"The ring marks a nominal cut-off contacting a clamp; no material-overlap area is implied.",
|
||||
PostVerificationKind.Overlap => "Shaded material is the overlap. The ring marks its area centroid, which can lie between separate patches.",
|
||||
PostVerificationKind.Overlap => "Shaded material is the overlap. The ring marks a point inside its largest patch.",
|
||||
PostVerificationKind.RapidCrossing when finding.ClampId != null =>
|
||||
"The crosshair marks the rapid's contact with the clamp. Head height is not modeled.",
|
||||
PostVerificationKind.RapidCrossing => finding.ContactPoints.Count > 0
|
||||
|
||||
Generated
+1
-1
@@ -390,7 +390,7 @@ namespace OpenNest.Forms
|
||||
// mnuOverlapCentroids
|
||||
//
|
||||
mnuOverlapCentroids.Name = "mnuOverlapCentroids";
|
||||
mnuOverlapCentroids.Text = "Centroids";
|
||||
mnuOverlapCentroids.Text = "Markers";
|
||||
mnuOverlapCentroids.Click += OverlapCentroids_Click;
|
||||
//
|
||||
// mnuOverlapBoth
|
||||
|
||||
@@ -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** — automatic multi-plate/multi-material jobs, interlocking pair evaluation, and built-in or plug-in engines. [Choose an engine](docs/nesting-engines.md).
|
||||
- **Plate operations** — manual sheet cut-offs, [plate- or nest-wide automatic scrap cutoffs with a minimum tail-to-keep setting](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** — highlight shared material on the active plate, rechecked automatically after edits, 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).
|
||||
- **Visual overlap check** — highlight shared material on the active plate, rechecked automatically after edits, including containment and cutouts, with area shading, pair markers, 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). [Pre-post verification](docs/post-verification.md) checks overlaps, missing lead-ins, and rapid crossings, with explicit risk acknowledgment required to bypass warnings.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -6,17 +6,17 @@ 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 / Centroids / Both** changes visibility without rerunning analysis. Areas
|
||||
Areas / Markers / Both** changes visibility without rerunning analysis. Areas
|
||||
is the default; rechecking preserves a visible mode, while checking from Off
|
||||
shows Areas. Clamp overlaps are shown as part/clamp findings with the clamp ID
|
||||
in hover details; clamps are not selectable parts and are not added to quantity
|
||||
or machining. A clamp edit invalidates the diagnostic and schedules a fresh
|
||||
check. Display preferences belong to the current document and are not saved.
|
||||
|
||||
Centroids and Both show fixed-screen-size, DPI-scaled pair crosshairs with a
|
||||
Markers 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
|
||||
captured names, approximate shared area in in² or mm², and marker 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
|
||||
@@ -73,7 +73,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}, centroid {pair.Centroid}");
|
||||
Console.WriteLine($"{pair.PartAId} / {pair.PartBId}: {pair.Area}, marker {pair.Marker}");
|
||||
foreach (var region in pair.Regions)
|
||||
{
|
||||
// region.Vertices: closed, read-only, counterclockwise world-coordinate outline
|
||||
@@ -183,11 +183,17 @@ so summing all pair areas double-counts shared plate locations.
|
||||
|
||||
`pair.Centroid` is the true area-weighted center of every shared-material island
|
||||
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.
|
||||
vertices. For a disconnected, holed or concave overlap it can lie outside the red
|
||||
material (between two patches, in a hole, or inside a curve).
|
||||
|
||||
`pair.Marker` is where markers, hover hit testing and cutting-inspection rings go:
|
||||
a point on the shared material, inside the pair's largest island. It is that
|
||||
island's pole of inaccessibility (`PolyLabel`), searched to a tenth of the island's
|
||||
area/perimeter with a budget of 4096 distance evaluations. If that bounded search
|
||||
does not land inside (a long thin sliver), the marker is the midpoint of the widest
|
||||
interior run of a horizontal scan line through the island, placed between vertex
|
||||
heights. There is one marker per pair, not per island: a second patch has shading
|
||||
but no crosshair.
|
||||
|
||||
`PolygonAreaMoments` evaluates signed moments about local origins. The analyzer
|
||||
subtracts each island's hole moments from its outline's (`TrySubtract`), then
|
||||
@@ -256,7 +262,8 @@ failure clear all retained highlights.
|
||||
Measured on 389 real PEP-converted plates (252 nests), a from-scratch check takes
|
||||
median 0.8 ms, p99 97 ms and max 3.2 s. Nearly all of the slow cases are first-time
|
||||
material preparation for a hole-heavy drawing. With material prepared, clipping every
|
||||
candidate pair takes median 0.05 ms, p99 27 ms and max 52 ms. The triangle-pair kernel
|
||||
candidate pair and placing markers takes median 0.05 ms, p99 34 ms and max 76 ms
|
||||
(markers added about 0.4 ms per overlapping pair). The triangle-pair kernel
|
||||
took up to 2.6 s on the same plates. On those plates Clipper2 and the previous kernel
|
||||
found identical pair, clamp and issue sets. Areas differed by at most 0.0002 square
|
||||
units, always smaller from the kernel, consistent with its dropping fragments below
|
||||
@@ -275,7 +282,7 @@ and triple coverage does not darken. World-to-graph conversion excludes pan,
|
||||
because PlateView already applies origin translation. Paths are rebuilt for
|
||||
displayed-pair/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
|
||||
Marker 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.
|
||||
@@ -295,7 +302,7 @@ 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|FullyQualifiedName~PolygonAreaMomentsTests|FullyQualifiedName~OverlapPairPresentationTests|FullyQualifiedName~OverlapHoverPagesTests|FullyQualifiedName~IncrementalOverlapAnalysisTests|FullyQualifiedName~OverlapAutoCheckSchedulerTests'
|
||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~PlateOverlapAnalyzerTests|FullyQualifiedName~OverlapReportStateTests|FullyQualifiedName~PolygonAreaMomentsTests|FullyQualifiedName~OverlapPairPresentationTests|FullyQualifiedName~OverlapHoverPagesTests|FullyQualifiedName~IncrementalOverlapAnalysisTests|FullyQualifiedName~OverlapAutoCheckSchedulerTests|FullyQualifiedName~OverlapMarkerTests|FullyQualifiedName~PolyLabel'
|
||||
```
|
||||
|
||||
`IncrementalOverlapAnalysisTests` compares incremental rechecks with uncached full
|
||||
@@ -312,6 +319,10 @@ translations, and invalid/overflow cases. `OverlapPairPresentationTests` checks
|
||||
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.
|
||||
`OverlapMarkerTests` covers the pole, the scan-line fallback after an exhausted search
|
||||
budget, and the last-resort fallback; `PolyLabelDifferentialTests` proves the heap
|
||||
queue returns bit-identical poles to the former sorted list, so label positions
|
||||
elsewhere do not move.
|
||||
`OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs` adds STA worker/publication,
|
||||
menu/MDI, path-cache, uniform-fill pixel, control-lifetime, and automatic-recheck
|
||||
(debounce, drag detection, busy wait, supersession, display/status) checks. Run those
|
||||
@@ -323,7 +334,8 @@ dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
|
||||
|
||||
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
|
||||
check partial overlap, containment, inside-hole placement, markers on concave,
|
||||
holed and two-patch overlaps, pan/zoom and quadrant
|
||||
alignment, changed-pair clearing and unchanged-pair retention during edits and
|
||||
pending rechecks, full clearing on plate switches and converter cancellation,
|
||||
the pending label and automatic recheck after dragging a part onto another,
|
||||
|
||||
@@ -18,8 +18,8 @@ restores normal rendering without changing drawing colors or selection.
|
||||
|
||||
Overlaps use the same magenta material shading as **View > Overlap Check**. A
|
||||
DPI-scaled ring expands and fades continuously while the finding is selected;
|
||||
both its color and contrasting halo fade as it grows. For overlaps the ring marks the area centroid (possibly between
|
||||
separate patches); for missing lead-ins it marks the contour start. Rapid warnings
|
||||
both its color and contrasting halo fade as it grows. For overlaps the ring marks a point inside the largest
|
||||
shared patch; for missing lead-ins it marks the contour start. Rapid warnings
|
||||
pulse the offending orange dashed segment continuously and mark its contacts with
|
||||
the completed contour using fixed-screen-size magenta crosshairs with white halos.
|
||||
Contacts use native lines/arcs and the rapid check's existing numerical tolerances;
|
||||
|
||||
Reference in new issue
Block a user