Files
OpenNest/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerTests.cs
T
aj 80d01839bd 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.
2026-10-15 23:31:14 -04:00

753 lines
32 KiB
C#

using OpenNest.CNC;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using OpenNest.Shapes;
namespace OpenNest.Tests.Diagnostics;
public class PlateOverlapAnalyzerTests
{
[Fact]
public void Analyze_ReturnsOwnedWorldPolygonsForEachOverlappingPair()
{
var parts = new[] { Rectangle(0, 0, 1, 1), Rectangle(0.5, 0, 1, 1) };
var report = PlateOverlapAnalyzer.Analyze(parts);
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
Assert.Empty(report.Issues);
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 =>
{
Assert.True(region.Area > 0);
Assert.Equal(region.Vertices[0], region.Vertices[^1]);
Assert.All(region.Vertices, point =>
{
Assert.InRange(point.X, 0.5, 1);
Assert.InRange(point.Y, 0, 1);
});
});
Assert.Equal(pair.Area, pair.Regions.Sum(r => r.Area), 9);
Assert.Equal((0.5, 0.0, 1.0, 1.0),
(pair.Bounds.Left, pair.Bounds.Bottom, pair.Bounds.Right, pair.Bounds.Top));
}
[Fact]
public void Analyze_FullContainmentDoesNotRequireCrossingPoints()
{
var pair = Assert.Single(PlateOverlapAnalyzer.Analyze(new[]
{
Rectangle(0, 0, 4, 4), Rectangle(1, 1, 1, 1)
}).Pairs);
Assert.Equal(1, pair.Area, 9);
AssertCentroid(pair, 1.5, 1.5);
}
[Theory]
[InlineData(2, 0)]
[InlineData(1, 0)]
[InlineData(1, 1)]
public void Analyze_DisjointAndBoundaryOnlyContactAreClear(double x, double y)
{
var report = PlateOverlapAnalyzer.Analyze(new[]
{
Rectangle(0, 0, 1, 1), Rectangle(x, y, 1, 1)
});
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
Assert.Empty(report.Pairs);
}
[Fact]
public void Analyze_EmptyAndSinglePartAreComplete()
{
foreach (var parts in new[] { Array.Empty<Part>(), new[] { Rectangle(0, 0, 1, 1) } })
{
var report = PlateOverlapAnalyzer.Analyze(parts);
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
Assert.Empty(report.Pairs);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Analyze_SubtractsHolesOfEitherOperand(bool swap)
{
var frame = WithContours(Square(0, 0, 4), Square(1, 1, 2));
var insert = Rectangle(1.5, 1.5, 1, 1);
var report = PlateOverlapAnalyzer.Analyze(swap ? new[] { insert, frame } : new[] { frame, insert });
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
Assert.Empty(report.Pairs);
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));
var pair = Assert.Single(report.Pairs);
Assert.Equal(0.5, pair.Area, 9);
AssertCentroid(pair, 0.75, 2);
}
[Fact]
public void Analyze_SubtractsBothPartsHolesWithoutDuplicatingArea()
{
var a = WithContours(Square(0, 0, 4), Square(0.5, 0.5, 1));
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));
var pair = Assert.Single(report.Pairs);
Assert.Equal(14, pair.Area, 8);
AssertCentroid(pair, 28.5 / 14, 28.5 / 14);
}
[Fact]
public void Analyze_ConcaveIntersectionKeepsDisconnectedFragments()
{
var u = 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)
});
var report = PlateOverlapAnalyzer.Analyze(new[] { u, Rectangle(0, 2, 3, 1) });
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)));
}
[Theory]
[InlineData(0, 0)]
[InlineData(1000000000, 1000000000)]
[InlineData(-1000000000, -1000000000)]
public void Analyze_ReversedWindingAndLargeTranslationPreserveArea(double x, double y)
{
var a = WithContours(Square(0, 0, 1).Reverse().ToArray());
var b = Rectangle(0, 0, 1, 1);
a.Location = new Vector(x, y);
b.Location = new Vector(x + 0.5, y);
var report = PlateOverlapAnalyzer.Analyze(new[] { a, b });
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);
}
[Fact]
public void Analyze_UsesBaselineAdjustedCleanDrawingPose()
{
var drawing = Rectangle(0, 0, 2, 1).BaseDrawing;
drawing.Program.Rotate(System.Math.PI / 2);
var part = new Part(drawing);
part.Rotate(System.Math.PI / 2);
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));
var pair = Assert.Single(report.Pairs);
Assert.Equal(2, pair.Area, 8);
AssertCentroid(pair, 9, 9.5);
}
[Fact]
public void Analyze_CircularHoleIsNotSolidMaterial()
{
var ring = new Part(new RingShape { OuterDiameter = 10, InnerDiameter = 7 }.GetDrawing());
var disk = new Part(new CircleShape { Diameter = 6 }.GetDrawing());
ring.Location = new Vector(-36.8, 5.4);
disk.Location = new Vector(-36.7, 5.4);
var report = PlateOverlapAnalyzer.Analyze(new[] { ring, disk });
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
Assert.Empty(report.Pairs);
}
[Fact]
public void Analyze_KeepsInputIndicesAndDeterministicOrderWhileSkippingCutoffs()
{
var cutoff = Rectangle(0, 0, 1, 1);
cutoff.BaseDrawing.IsCutOff = true;
var drawing = Rectangle(0, 0, 1, 1).BaseDrawing;
var parts = new[] { new Part(drawing), cutoff, new Part(drawing), new Part(drawing) };
var report = PlateOverlapAnalyzer.Analyze(parts);
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
Assert.Equal(new[] { (0, 2), (0, 3), (2, 3) },
report.Pairs.Select(p => (p.PartAId, p.PartBId)));
Assert.All(report.Pairs, p => Assert.Equal(1, p.Area, 9));
}
[Fact]
public void Analyze_SweepMatchesAnalyticalExhaustiveRectanglePairs()
{
var random = new Random(928);
var parts = Enumerable.Range(0, 40)
.Select(_ => Rectangle(random.Next(-10, 10), random.Next(-10, 10), 3, 2)).ToArray();
var expected = new List<(int, int, double)>();
for (var a = 0; a < parts.Length; a++)
for (var b = a + 1; b < parts.Length; b++)
{
var dx = System.Math.Min(parts[a].Right, parts[b].Right)
- System.Math.Max(parts[a].Left, parts[b].Left);
var dy = System.Math.Min(parts[a].Top, parts[b].Top)
- System.Math.Max(parts[a].Bottom, parts[b].Bottom);
if (dx > 0 && dy > 0)
expected.Add((a, b, dx * dy));
}
var report = PlateOverlapAnalyzer.Analyze(parts);
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
Assert.Equal(expected.Select(pair => (pair.Item1, pair.Item2)),
report.Pairs.Select(pair => (pair.PartAId, pair.PartBId)));
foreach (var (expectedPair, actualPair) in expected.Zip(report.Pairs))
Assert.Equal(expectedPair.Item3, actualPair.Area, 9);
}
[Theory]
[InlineData("open")]
[InlineData("empty")]
[InlineData("nan")]
[InlineData("infinite-pose")]
[InlineData("disjoint-outers")]
[InlineData("nested-island")]
[InlineData("crossing-holes")]
[InlineData("self-crossing")]
public void Analyze_InvalidPartMakesReportIncompleteButRetainsKnownOverlaps(string invalid)
{
var bad = Rectangle(0, 0, 1, 1);
switch (invalid)
{
case "open": bad.BaseDrawing.Program.Codes.RemoveAt(bad.BaseDrawing.Program.Codes.Count - 1); break;
case "empty": bad.BaseDrawing.Program.Codes.Clear(); break;
case "nan": ((LinearMove)bad.BaseDrawing.Program.Codes[1]).EndPoint = new Vector(double.NaN, 0); break;
case "infinite-pose": bad.Location = new Vector(double.PositiveInfinity, 0); break;
case "disjoint-outers": bad = WithContours(Square(0, 0, 1), Square(3, 3, 1)); break;
case "nested-island": bad = WithContours(Square(0, 0, 6), Square(1, 1, 4), Square(2, 2, 1)); break;
case "crossing-holes": bad = WithContours(Square(0, 0, 6), Square(1, 1, 3), Square(2, 2, 3)); break;
case "self-crossing": bad = WithContours(new[] { new Vector(0, 0), new Vector(3, 3), new Vector(0, 3), new Vector(2, 0) }); break;
}
var report = PlateOverlapAnalyzer.Analyze(new[] { Rectangle(0, 0, 1, 1), bad, Rectangle(0, 0, 1, 1) });
Assert.False(report.IsComplete);
var issue = Assert.Single(report.Issues);
Assert.Equal(1, issue.PartAId);
Assert.Null(issue.PartBId);
Assert.False(string.IsNullOrWhiteSpace(issue.Message));
var pair = Assert.Single(report.Pairs);
Assert.Equal((0, 2), (pair.PartAId, pair.PartBId));
}
[Fact]
public void Capture_IsIndependentOfLaterDrawingPoseAndCollectionChanges()
{
var a = Rectangle(0, 0, 1, 1);
var b = new Part(a.BaseDrawing, new Vector(0.5, 0));
var parts = new List<Part> { a, b };
var snapshot = PlateOverlapAnalyzer.Capture(parts);
a.BaseDrawing.Program.Codes.Clear();
b.Location = new Vector(100, 100);
parts.Clear();
var first = PlateOverlapAnalyzer.Analyze(snapshot);
var second = PlateOverlapAnalyzer.Analyze(snapshot);
Assert.True(first.IsComplete);
Assert.Equal(0.5, Assert.Single(first.Pairs).Area, 9);
Assert.Equal(0.5, Assert.Single(second.Pairs).Area, 9);
}
[Fact]
public void Analyze_IgnoresPlacedCuttingProgramAndDoesNotMutateParts()
{
var a = Rectangle(0, 0, 1, 1);
var b = Rectangle(0.5, 0, 1, 1);
Assert.True(a.RestoreLeadInProgram(Rectangle(100, 100, 4, 4).Program, locked: true));
var program = a.Program;
var source = a.BaseDrawing.Program;
var sourceText = source.ToString();
var placedText = program.ToString();
var bounds = a.BoundingBox;
var location = a.Location;
var rotation = a.Rotation;
var report = PlateOverlapAnalyzer.Analyze(new[] { a, b });
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
Assert.Equal(0.5, Assert.Single(report.Pairs).Area, 9);
Assert.Same(program, a.Program);
Assert.Same(source, a.BaseDrawing.Program);
Assert.Equal(sourceText, source.ToString());
Assert.Equal(placedText, program.ToString());
Assert.Same(bounds, a.BoundingBox);
Assert.Equal(location, a.Location);
Assert.Equal(rotation, a.Rotation);
Assert.True(a.HasManualLeadIns);
Assert.True(a.LeadInsLocked);
}
[Fact]
public void Analyze_IgnoresScribeRapidAndLeadPathsInCleanSource()
{
var a = Rectangle(0, 0, 1, 1);
foreach (var layer in new[] { LayerType.Scribe, LayerType.Leadin, LayerType.Leadout })
{
a.BaseDrawing.Program.Codes.Add(new RapidMove(new Vector(0, 0)));
a.BaseDrawing.Program.Codes.Add(new LinearMove(new Vector(10, 10)) { Layer = layer });
}
var report = PlateOverlapAnalyzer.Analyze(new[] { a, Rectangle(5, 5, 1, 1) });
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
Assert.Empty(report.Pairs);
}
[Fact]
public void Analyze_ReportCollectionsAndBoundsCannotBeMutated()
{
var report = PlateOverlapAnalyzer.Analyze(new[] { Rectangle(0, 0, 1, 1), Rectangle(0, 0, 1, 1) });
var pair = Assert.Single(report.Pairs);
Assert.Throws<NotSupportedException>(() => ((IList<PlateOverlapPair>)report.Pairs).Clear());
Assert.Throws<NotSupportedException>(() => ((IList<PlateOverlapRegion>)pair.Regions).Clear());
var region = pair.Regions[0];
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]
public async Task Capture_SharedHoleSubprogramsAreOwnedAndReusableAcrossWorkers()
{
var source = Rectangle(0, 0, 10, 10).BaseDrawing;
var hole = Rectangle(0, 0, 1, 1).BaseDrawing.Program;
hole.Mode = Mode.Incremental;
source.Program.SubPrograms[-1] = hole;
source.Program.Codes.Add(new SubProgramCall { Id = -1, Program = hole, Offset = new Vector(2, 2) });
source.Program.Codes.Add(new SubProgramCall { Id = -1, Program = hole, Offset = new Vector(6, 2) });
source.Program.Rotate(System.Math.PI / 2);
var placed = new Part(source);
placed.Rotate(System.Math.PI / 2);
placed.Location = new Vector(20, 20);
var parts = new[] { placed, Rectangle(10, 10, 10, 10) };
var before = source.Program.Codes.Select(code => code.ToString()).ToArray();
var holeBefore = hole.Codes.Select(code => code.ToString()).ToArray();
var snapshot = PlateOverlapAnalyzer.Capture(parts);
Assert.Equal(before, source.Program.Codes.Select(code => code.ToString()));
Assert.Equal(holeBefore, hole.Codes.Select(code => code.ToString()));
foreach (var call in source.Program.Codes.OfType<SubProgramCall>())
call.Offset = new Vector(100, 100);
hole.Codes.Clear();
source.Program.Codes.Clear();
var reports = await Task.WhenAll(Enumerable.Range(0, 4).Select(_ =>
Task.Run(() => PlateOverlapAnalyzer.Analyze(snapshot))));
foreach (var report in reports)
{
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
Assert.Equal(98, Assert.Single(report.Pairs).Area, 8);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Capture_InvalidSubprogramGraphIsIncompleteRatherThanRecursive(bool missing)
{
var part = Rectangle(0, 0, 1, 1);
var call = new SubProgramCall();
if (!missing)
call.Program = part.BaseDrawing.Program;
part.BaseDrawing.Program.Codes.Add(call);
var report = PlateOverlapAnalyzer.Analyze(new[] { part });
Assert.False(report.IsComplete);
Assert.Single(report.Issues);
Assert.Empty(report.Pairs);
}
[Fact]
public void Analyze_NullArgumentsThrowAndNullEntriesAreIssues()
{
Assert.Throws<ArgumentNullException>(() => PlateOverlapAnalyzer.Capture(null!));
Assert.Throws<ArgumentNullException>(() => PlateOverlapAnalyzer.Analyze((PlateOverlapSnapshot)null!));
var report = PlateOverlapAnalyzer.Analyze(new Part[] { null! });
Assert.False(report.IsComplete);
Assert.Single(report.Issues);
Assert.Empty(report.Pairs);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Analyze_HugeOuterWithSmallClockwisePartIsOrderIndependent(bool swap)
{
var outer = Rectangle(0, 0, 1e9, 1e9);
var small = WithContours(Square(0, 0, 1).Reverse().ToArray());
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));
var pair = Assert.Single(report.Pairs);
Assert.Equal(1, pair.Area, 8);
AssertCentroid(pair, 999999998.5, 999999998.5);
}
[Fact]
public void Analyze_TranslationThatCollapsesMaterialIsIncompleteNotClear()
{
var report = PlateOverlapAnalyzer.Analyze(new[]
{
Rectangle(1e16, 1e16, 1, 1), Rectangle(1e16, 1e16, 1, 1)
});
Assert.False(report.IsComplete);
Assert.Equal(new[] { 0, 1 }, report.Issues.Select(issue => issue.PartAId));
Assert.Empty(report.Pairs);
}
[Theory]
[InlineData(9.0)]
[InlineData(9.0002)]
public void Analyze_NativeCircularHoleContactCannotBeHiddenByTessellation(double y)
{
var source = Rectangle(0, 0, 10, 10).BaseDrawing;
source.Program.Codes.Add(new RapidMove(new Vector(6, y)));
source.Program.Codes.Add(new ArcMove(new Vector(6, y), new Vector(5, y), RotationType.CCW));
var report = PlateOverlapAnalyzer.Analyze(new[] { new Part(source) });
Assert.False(report.IsComplete);
Assert.Single(report.Issues);
Assert.Empty(report.Pairs);
}
[Fact]
public void Analyze_SubChordThinRingIsExplicitlyUncheckableNotClear()
{
const double radius = 4.995098381203606;
var ring = new Part(new RingShape
{
OuterDiameter = 2 * (radius + 0.0001),
InnerDiameter = 2 * (radius - 0.0001)
}.GetDrawing());
var report = PlateOverlapAnalyzer.Analyze(new[] { ring });
Assert.False(report.IsComplete);
Assert.Single(report.Issues);
Assert.Empty(report.Pairs);
}
[Fact]
public void Analyze_CancellationThrowsInsteadOfPublishingPartialClear()
{
var parts = new[] { Rectangle(0, 0, 1, 1), Rectangle(0, 0, 1, 1) };
var snapshot = PlateOverlapAnalyzer.Capture(parts);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.Throws<OperationCanceledException>(() => PlateOverlapAnalyzer.Capture(parts, cancellation.Token));
Assert.Throws<OperationCanceledException>(() => PlateOverlapAnalyzer.Analyze(snapshot, cancellation.Token));
}
[Theory]
[InlineData(0)]
[InlineData(1000000000)]
[InlineData(-1000000000)]
public void Analyze_UnequalDisconnectedOverlapWeightsAllFragments(double offset)
{
var u = WithContours(new[]
{
new Vector(0, 0), new Vector(6, 0), new Vector(6, 3), new Vector(3, 3),
new Vector(3, 1), new Vector(1, 1), new Vector(1, 3), new Vector(0, 3)
});
u.Location = new Vector(offset, offset);
var report = PlateOverlapAnalyzer.Analyze(new[] { u, Rectangle(offset, offset + 2, 6, 1) });
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
var pair = Assert.Single(report.Pairs);
Assert.Equal(4, pair.Area, 8);
AssertCentroid(pair, offset + 3.5, offset + 2.5);
}
[Fact]
public void Analyze_RotatedTriangleHasTrueMaterialCentroid()
{
var triangle = WithContours(new[] { new Vector(0, 0), new Vector(6, 0), new Vector(0, 3) });
triangle.Rotate(System.Math.PI / 2);
triangle.Location = new Vector(10, 20);
var report = PlateOverlapAnalyzer.Analyze(new[] { Rectangle(0, 0, 30, 30), triangle });
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
var pair = Assert.Single(report.Pairs);
Assert.Equal(9, pair.Area, 8);
AssertCentroid(pair, 9, 22);
}
[Fact]
public void Analyze_OverflowingPairMomentsAreIncompleteAndRetainOtherPairs()
{
var huge = Rectangle(0, 0, 1e103, 1e103);
var report = PlateOverlapAnalyzer.Analyze(new[]
{
huge, new Part(huge.BaseDrawing), Rectangle(-2, -2, 1, 1), Rectangle(-2, -2, 1, 1)
});
Assert.False(report.IsComplete);
var issue = Assert.Single(report.Issues);
Assert.Equal((0, (int?)1), (issue.PartAId, issue.PartBId));
// The exact clipping range refuses the pair before any moment could overflow.
Assert.Contains("range", 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);
}
// Regions are connected islands of shared material, not triangulation fragments: a
// C-shaped overlap (frame half beside its hole) is one region with the C's area.
[Fact]
public void Analyze_ConcaveOverlapIsOneConnectedRegion()
{
var frame = WithContours(Square(0, 0, 4), Square(1, 1, 2));
var report = PlateOverlapAnalyzer.Analyze(new[] { frame, Rectangle(2, -1, 4, 6) });
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
var pair = Assert.Single(report.Pairs);
var region = Assert.Single(pair.Regions);
Assert.Equal(6, pair.Area, 8);
Assert.Equal(6, region.Area, 8);
Assert.Equal(region.Vertices[0], region.Vertices[^1]);
}
// A part covering a whole frame shares one island whose hole is the frame's hole.
[Fact]
public void Analyze_RegionKeepsTheHoleOfSharedMaterial()
{
var frame = WithContours(Square(0, 0, 4), Square(1, 1, 2));
var report = PlateOverlapAnalyzer.Analyze(new[] { Rectangle(-1, -1, 6, 6), frame });
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
var pair = Assert.Single(report.Pairs);
var region = Assert.Single(pair.Regions);
Assert.Equal(12, pair.Area, 8);
AssertCentroid(pair, 2, 2);
var hole = Assert.Single(region.Holes);
Assert.Equal(hole[0], hole[^1]);
Assert.Equal(16, SignedArea(region.Vertices), 8); // Outline counterclockwise.
Assert.Equal(-4, SignedArea(hole), 8); // Hole clockwise.
Assert.All(hole, point =>
{
Assert.InRange(point.X, 1, 3);
Assert.InRange(point.Y, 1, 3);
});
Assert.Throws<NotSupportedException>(() => ((IList<IReadOnlyList<Vector>>)region.Holes).Clear());
Assert.Throws<NotSupportedException>(() => ((IList<Vector>)hole)[0] = new Vector(9, 9));
}
private static double SignedArea(IReadOnlyList<Vector> ring)
{
var twice = 0.0;
for (var i = 0; i + 1 < ring.Count; i++)
twice += ring[i].X * ring[i + 1].Y - ring[i + 1].X * ring[i].Y;
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]
public void Analyze_IslandEnclosedByBothPartsHolesIsKept()
{
var a = WithContours(Square(0, 0, 10), new[]
{
new Vector(6.5, 3.5), new Vector(7.5, 3.5), new Vector(7.5, 6.5), new Vector(6.5, 6.5)
});
var b = WithContours(Square(0, 0, 10), new[]
{
new Vector(3, 3), new Vector(7, 3), new Vector(7, 4), new Vector(4, 4),
new Vector(4, 6), new Vector(7, 6), new Vector(7, 7), new Vector(3, 7)
});
var report = PlateOverlapAnalyzer.Analyze(new[] { a, b });
Assert.True(report.IsComplete, string.Join("; ", report.Issues));
var pair = Assert.Single(report.Pairs);
Assert.Equal(87.5, pair.Area, 8); // 100 - |H1 u H2| = 100 - (3 + 10 - 0.5).
Assert.Equal(new[] { 5.0, 82.5 }, pair.Regions.Select(r => System.Math.Round(r.Area, 8)).Order());
var island = pair.Regions.Single(r => r.Area < 10);
Assert.Empty(island.Holes);
Assert.All(island.Vertices, point =>
{
Assert.InRange(point.X, 4, 6.5);
Assert.InRange(point.Y, 4, 6);
});
Assert.Single(pair.Regions.Single(r => r.Area > 10).Holes);
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void Analyze_IncrementalHolesKeepTheirOffsetsAndAbsoluteOnesAreRefused(Mode mode)
{
var hole = new Program();
hole.MoveTo(1, 0);
hole.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
hole.Mode = mode;
var clean = new Program();
clean.MoveTo(0, 0);
clean.LineTo(0, 10);
clean.LineTo(10, 10);
clean.LineTo(10, 0);
clean.LineTo(0, 0);
clean.SubPrograms[-1] = hole;
clean.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(3, 3), Id = -1 });
clean.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(7, 3), Id = -1 });
clean.Mode = mode;
var host = new Part(new Drawing("host", clean));
var insert = Rectangle(2.75, 2.75, 0.5, 0.5); // Inside the hole called at (3, 3).
var report = PlateOverlapAnalyzer.Analyze(new[] { host, insert });
// Never a false overlap: incremental holes are read at their call offsets, and an absolute
// subprogram, which the converter would read at its frame origin, is an incomplete check.
Assert.Empty(report.Pairs);
if (mode == Mode.Incremental)
Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message)));
else
{
var issue = Assert.Single(report.Issues);
Assert.Equal((0, (int?)null), (issue.PartAId, issue.PartBId));
Assert.Contains("Absolute-mode subprograms", issue.Message);
}
}
[Fact]
public void Analyze_AbsoluteCoordinatesFarFromTheMaterialAreReadExactly()
{
// Rebuilding absolute endpoints from incremental deltas after a rapid at 1e12 moves the
// contour by about 2.4e-5, enough to hide this 2e-5 wide overlap.
var far = new Program(Mode.Absolute);
far.Codes.Add(new RapidMove(new Vector(1e12, 0)));
far.Codes.Add(new RapidMove(new Vector(0.1, 0)));
foreach (var point in new[] { new Vector(1.1, 0), new Vector(1.1, 10), new Vector(0.1, 10), new Vector(0.1, 0) })
far.Codes.Add(new LinearMove(point));
var first = new Part(new Drawing("far rapid", far));
var second = Rectangle(1.09998, 0, 1, 10);
var report = PlateOverlapAnalyzer.Analyze(new[] { first, second });
Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message)));
var pair = Assert.Single(report.Pairs);
Assert.Equal(2e-4, pair.Area, 6);
}
[Fact]
public void Analyze_NullOrUnsupportedInstructionsAreIncompleteWithoutRunningThem()
{
var missing = Rectangle(0, 0, 1, 1);
missing.BaseDrawing.Program.Codes = null;
var unsupported = Rectangle(5, 0, 1, 1);
var code = new CountingCode();
unsupported.BaseDrawing.Program.Codes.Add(code);
var clear = Rectangle(10, 0, 1, 1);
var report = PlateOverlapAnalyzer.Analyze(new[] { missing, unsupported, clear });
Assert.Equal(new[] { 0, 1 }, report.Issues.Select(issue => issue.PartAId).OrderBy(id => id));
Assert.Empty(report.Pairs);
Assert.Equal(0, code.Clones);
}
// Reports a built-in type but is not one: conversion must refuse it rather than cast or clone it.
private sealed class CountingCode : ICode
{
public int Clones { get; private set; }
public CodeType Type => CodeType.LinearMove;
public ICode Clone()
{
Clones++;
return this;
}
}
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));
private static Vector[] Square(double x, double y, double size) =>
new[] { new Vector(x, y), new Vector(x + size, y), new Vector(x + size, y + size), new Vector(x, y + size) };
private static Part WithContours(params Vector[][] contours)
{
var program = new Program(Mode.Absolute);
foreach (var contour in contours)
{
program.Codes.Add(new RapidMove(contour[0]));
foreach (var point in contour.Skip(1).Append(contour[0]))
program.Codes.Add(new LinearMove(point));
}
return new Part(new Drawing("same name", program));
}
}