Collision.HasOverlap only needs the verdict, but it went through Check, which also collected crossing points. Triangulation, clipping and hole subtraction now live in one private OverlapRegions method shared by Check and HasOverlap, so verdict arithmetic stays single-sourced; Check output is unchanged. Tests: a frozen copy of the previous Collision is the oracle. 50,000 seeded HasOverlap verdicts and 2,400 bitwise Check results match it, plus containment, contact, hole and input-immutability cases. A Debug-only PerfCounters.CrossingPointScans counter proves HasOverlap no longer scans. Malformed polygons with null outer vertices still throw when the bounding boxes overlap (now ArgumentNullException from triangulation rather than NullReferenceException from ToLines). Measured (Release, same harness in both trees): about 44% less time per overlap-only polygon check, allocations 10.0 -> 7.9 MB per 155-pair sweep. The 169-part serialized corpus layout is byte-identical.
254 lines
11 KiB
C#
254 lines
11 KiB
C#
using OpenNest.Geometry;
|
|
using Xunit.Abstractions;
|
|
using static OpenNest.Tests.Geometry.NoFitPolygonTests;
|
|
|
|
namespace OpenNest.Tests.Geometry;
|
|
|
|
/// <summary>
|
|
/// The overlap-only <see cref="Collision.HasOverlap"/> path skips crossing points. These tests
|
|
/// compare it and <see cref="Collision.Check"/> against the frozen pre-change
|
|
/// <see cref="LegacyCollision"/> (commit 82feb78).
|
|
/// </summary>
|
|
[Collection(nameof(OpenNest.Tests.BestFit.FillCacheCollection))]
|
|
public class CollisionOverlapOnlyTests
|
|
{
|
|
private readonly ITestOutputHelper output;
|
|
|
|
public CollisionOverlapOnlyTests(ITestOutputHelper output) => this.output = output;
|
|
|
|
[Fact]
|
|
public void SeededHasOverlap_MatchesLegacyVerdicts()
|
|
{
|
|
var random = new Random(27092026);
|
|
var decisions = 0;
|
|
var overlaps = 0;
|
|
var boxOverlapClear = 0;
|
|
var centerAligned = 0;
|
|
for (var pair = 0; pair < 200; pair++)
|
|
{
|
|
var a = Make(random, pair % 6);
|
|
var b = Make(random, (pair / 6) % 6);
|
|
var holesA = pair % 4 == 0 ? new List<Polygon> { Move(Square(0.7), 0.2, 0.2) } : null;
|
|
var holesB = pair % 7 == 0 ? new List<Polygon> { Move(Square(0.6), 0.3, 0.3) } : null;
|
|
for (var sample = 0; sample < 250; sample++)
|
|
{
|
|
var (ax, ay, bx, by) = Offsets(random, a, b, sample);
|
|
var ma = Move(a, ax, ay);
|
|
var mb = Move(b, bx, by);
|
|
var ha = holesA?.Select(h => Move(h, ax, ay)).ToList();
|
|
var hb = holesB?.Select(h => Move(h, bx, by)).ToList();
|
|
var legacyCheck = LegacyCollision.Check(ma, mb, ha, hb).Overlaps;
|
|
var legacyHas = LegacyCollision.HasOverlap(ma, mb, ha, hb);
|
|
var actual = Collision.HasOverlap(ma, mb, ha, hb);
|
|
Assert.True(legacyCheck == legacyHas, $"legacy self-disagreement pair={pair} sample={sample}");
|
|
Assert.True(actual == legacyHas,
|
|
$"pair={pair} sample={sample} a=({ax:R},{ay:R}) b=({bx:R},{by:R}) expected={legacyHas}");
|
|
Assert.Equal(legacyHas, Collision.Check(ma, mb, ha, hb).Overlaps);
|
|
if (legacyHas)
|
|
overlaps++;
|
|
else if (BoxesOverlap(ma.BoundingBox, mb.BoundingBox))
|
|
boxOverlapClear++;
|
|
if (sample % 10 == 9)
|
|
centerAligned++;
|
|
decisions++;
|
|
}
|
|
}
|
|
output.WriteLine($"decisions={decisions}; overlaps={overlaps}; bbox-overlap but clear={boxOverlapClear}; center-aligned samples={centerAligned}");
|
|
Assert.Equal(50000, decisions);
|
|
Assert.True(overlaps > 5000);
|
|
Assert.True(boxOverlapClear > 5000);
|
|
}
|
|
|
|
[Fact]
|
|
public void SeededCheck_MatchesLegacyBitwise()
|
|
{
|
|
var random = new Random(9272026);
|
|
var compared = 0;
|
|
for (var pair = 0; pair < 60; pair++)
|
|
{
|
|
var a = Make(random, pair % 6);
|
|
var b = Make(random, (pair / 6) % 6);
|
|
var holesA = pair % 3 == 0 ? new List<Polygon> { Move(Square(0.7), 0.2, 0.2) } : null;
|
|
for (var sample = 0; sample < 40; sample++)
|
|
{
|
|
var (ax, ay, bx, by) = Offsets(random, a, b, sample);
|
|
var ma = Move(a, ax, ay);
|
|
var mb = Move(b, bx, by);
|
|
var ha = holesA?.Select(h => Move(h, ax, ay)).ToList();
|
|
var expected = LegacyCollision.Check(ma, mb, ha);
|
|
var actual = Collision.Check(ma, mb, ha);
|
|
Assert.Equal(expected.Overlaps, actual.Overlaps);
|
|
Assert.Equal(Bits(expected.OverlapArea), Bits(actual.OverlapArea));
|
|
Assert.Equal(expected.IntersectionPoints.SelectMany(VectorBits), actual.IntersectionPoints.SelectMany(VectorBits));
|
|
Assert.Equal(expected.OverlapRegions.Count, actual.OverlapRegions.Count);
|
|
for (var r = 0; r < expected.OverlapRegions.Count; r++)
|
|
{
|
|
Assert.Equal(expected.OverlapRegions[r].Vertices.SelectMany(VectorBits),
|
|
actual.OverlapRegions[r].Vertices.SelectMany(VectorBits));
|
|
Assert.Equal(BoxBits(expected.OverlapRegions[r].BoundingBox), BoxBits(actual.OverlapRegions[r].BoundingBox));
|
|
}
|
|
compared++;
|
|
}
|
|
}
|
|
Assert.Equal(2400, compared);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0.0, 0.0, true)] // identical squares
|
|
[InlineData(4.0, 4.0, true)] // b entirely inside a: no edge crossings
|
|
[InlineData(10.0, 0.0, false)] // exact edge contact
|
|
[InlineData(10.0000001, 0.0, false)] // separated by less than Epsilon
|
|
[InlineData(9.99999, 0.0, false)] // overlap below the area floor
|
|
[InlineData(9.9, 0.0, true)] // thin positive overlap
|
|
public void Containment_Contact_AndThinOverlap_MatchLegacy(double bx, double by, bool expected)
|
|
{
|
|
var a = Square(10);
|
|
var b = Move(bx == 0 && by == 0 ? Square(10) : Square(bx == 4 ? 1 : 10), bx, by);
|
|
Assert.Equal(expected, LegacyCollision.HasOverlap(a, b));
|
|
Assert.Equal(expected, Collision.HasOverlap(a, b));
|
|
Assert.Equal(expected, Collision.Check(a, b).Overlaps);
|
|
}
|
|
|
|
[Fact]
|
|
public void HoleContainment_MatchesLegacy()
|
|
{
|
|
var outer = Square(10);
|
|
var holes = new List<Polygon> { Move(Square(6), 2, 2) };
|
|
foreach (var (x, y, expected) in new[] { (4.0, 4.0, false), (1.5, 4.0, true), (2.0, 2.0, false), (1.9, 2.0, true) })
|
|
{
|
|
var inner = Move(Square(1), x, y);
|
|
Assert.Equal(expected, LegacyCollision.HasOverlap(outer, inner, holes));
|
|
Assert.Equal(expected, Collision.HasOverlap(outer, inner, holes));
|
|
Assert.Equal(expected, Collision.HasOverlap(inner, outer, null, holes));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void HasOverlap_DoesNotMutateInputs()
|
|
{
|
|
var a = Make(new Random(5), 1);
|
|
var b = Move(Make(new Random(6), 2), 0.5, 0.5);
|
|
var holes = new List<Polygon> { Move(Square(0.7), 0.2, 0.2) };
|
|
var before = Snapshot(a, b, holes[0]);
|
|
Collision.HasOverlap(a, b, holes, null);
|
|
Collision.HasOverlap(b, a, null, holes);
|
|
Assert.Equal(before, Snapshot(a, b, holes[0]));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Malformed input (a null outer vertex list after bounds were cached) must still fail loudly
|
|
/// once the bounding boxes overlap, rather than reporting "no overlap". The exception type
|
|
/// is intentionally not pinned: skipping crossing points moves the first dereference from
|
|
/// Polygon.ToLines (NullReferenceException) to triangulation (ArgumentNullException).
|
|
/// Bounding-box rejection still returns false without touching the vertices.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void NullOuterVertices_StillFailLoudly_UnlessBoxesAreSeparate(bool nullFirst)
|
|
{
|
|
var random = new Random(5);
|
|
var a = Make(random, 0);
|
|
var b = Move(Make(random, 0), 0.5, 0.5);
|
|
(nullFirst ? a : b).Vertices = null;
|
|
|
|
Assert.ThrowsAny<Exception>(() => LegacyCollision.Check(a, b));
|
|
Assert.ThrowsAny<Exception>(() => Collision.HasOverlap(a, b));
|
|
Assert.ThrowsAny<Exception>(() => Collision.Check(a, b));
|
|
|
|
var far = Move(Make(new Random(5), 0), 100, 100);
|
|
var broken = nullFirst ? a : b;
|
|
Assert.False(LegacyCollision.Check(broken, far).Overlaps);
|
|
Assert.False(Collision.HasOverlap(broken, far));
|
|
}
|
|
|
|
#if DEBUG
|
|
[Fact]
|
|
public void Work_HasOverlapSkipsCrossingPointScans()
|
|
{
|
|
var random = new Random(11);
|
|
var a = Make(random, 1);
|
|
var b = Move(Make(random, 2), 0.25, 0.25);
|
|
PerfCounters.Reset();
|
|
try
|
|
{
|
|
for (var i = 0; i < 5; i++)
|
|
Collision.HasOverlap(a, b);
|
|
var overlapOnly = PerfCounters.CrossingPointScans;
|
|
PerfCounters.Reset();
|
|
for (var i = 0; i < 5; i++)
|
|
Collision.Check(a, b);
|
|
var full = PerfCounters.CrossingPointScans;
|
|
output.WriteLine($"crossing-point scans: HasOverlap={overlapOnly}; Check={full}");
|
|
Assert.Equal(0, overlapOnly);
|
|
Assert.Equal(5, full);
|
|
}
|
|
finally
|
|
{
|
|
PerfCounters.Reset();
|
|
}
|
|
}
|
|
#endif
|
|
|
|
internal static Polygon Make(Random random, int kind)
|
|
{
|
|
var size = 2 + random.NextDouble() * 2;
|
|
switch (kind)
|
|
{
|
|
case 0:
|
|
return Square(size);
|
|
case 1:
|
|
return Star(random);
|
|
case 2:
|
|
return Ring((0, 0), (size, 0), (size, 1), (1, 1), (1, size), (0, size));
|
|
case 3:
|
|
return Ring((0, 0), (size, 0), (0, size));
|
|
case 4:
|
|
// Concave comb: several notches that interlock with a translated copy.
|
|
return Ring((0, 0), (size, 0), (size, 1), (size * 0.75, 1), (size * 0.75, 0.4),
|
|
(size * 0.5, 0.4), (size * 0.5, 1), (size * 0.25, 1), (size * 0.25, 0.4), (0, 0.4));
|
|
default:
|
|
var shape = new Shape();
|
|
shape.Entities.Add(new Arc(0, 0, size / 2, 0, System.Math.PI));
|
|
shape.Entities.Add(new Arc(0, 0, size / 2, System.Math.PI, 2 * System.Math.PI));
|
|
return ClipperBridge.Flatten(shape, 0.08, circumscribe: false);
|
|
}
|
|
}
|
|
|
|
private static (double, double, double, double) Offsets(Random random, Polygon a, Polygon b, int sample)
|
|
{
|
|
var ax = random.NextDouble() * 200 - 100;
|
|
var ay = random.NextDouble() * 200 - 100;
|
|
var bx = ax + random.NextDouble() * 8 - 4;
|
|
var by = ay + random.NextDouble() * 8 - 4;
|
|
if (sample % 5 == 0)
|
|
{
|
|
// Exact, near-touching and thin positive-area contacts at a box edge.
|
|
var gap = new[] { 0, -1e-7, 1e-7, -1e-5, 1e-5, -1e-4, 1e-4 }[(sample / 5) % 7];
|
|
bx = ax + a.BoundingBox.Right - b.BoundingBox.Left + gap;
|
|
by = ay + a.BoundingBox.Bottom - b.BoundingBox.Bottom;
|
|
}
|
|
else if (sample % 10 == 9)
|
|
{
|
|
// Box centers coincide: probes containment and deep overlap (not verified per sample).
|
|
bx = ax + a.BoundingBox.Center.X - b.BoundingBox.Center.X;
|
|
by = ay + a.BoundingBox.Center.Y - b.BoundingBox.Center.Y;
|
|
}
|
|
return (ax, ay, bx, by);
|
|
}
|
|
|
|
private static bool BoxesOverlap(Box a, Box b) =>
|
|
System.Math.Min(a.Right, b.Right) - System.Math.Max(a.Left, b.Left) > OpenNest.Math.Tolerance.Epsilon
|
|
&& System.Math.Min(a.Top, b.Top) - System.Math.Max(a.Bottom, b.Bottom) > OpenNest.Math.Tolerance.Epsilon;
|
|
|
|
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
|
|
|
private static long[] VectorBits(Vector v) => new[] { Bits(v.X), Bits(v.Y) };
|
|
|
|
private static long[] BoxBits(Box box) => new[] { Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width) };
|
|
|
|
private static long[] Snapshot(params Polygon[] polygons) =>
|
|
polygons.SelectMany(p => p.Vertices.SelectMany(VectorBits).Concat(BoxBits(p.BoundingBox))
|
|
.Append(p.Vertices.Count)).ToArray();
|
|
}
|