diff --git a/OpenNest.Core/Geometry/SlideBand.cs b/OpenNest.Core/Geometry/SlideBand.cs
index 779ff17e..5f373417 100644
--- a/OpenNest.Core/Geometry/SlideBand.cs
+++ b/OpenNest.Core/Geometry/SlideBand.cs
@@ -34,13 +34,13 @@ namespace OpenNest.Geometry
// No absolute slack is needed: the kernels translate entities with the same
// expressions as these tests, and a circle point c + r·sinθ rounds monotonically, so
// it never lies outside fl(c ± r).
- private const double SpanSlack = 2 * Math.Tolerance.Epsilon;
+ internal const double SpanSlack = 2 * Math.Tolerance.Epsilon;
// SolveRayCircle accepts a nonnegative discriminant b² − 4ac. For an axis ray that is
// 4(r² − oy²) up to rounding of terms of size ox² + oy² + r², so a hit is possible when
// oy² ≤ r² + ε(1 + ox² + oy² + r²). This ε is hundreds of ulps; the absolute 1 is a
// floor for squares that underflow.
- private const double QuadraticSlack = 1e-13;
+ internal const double QuadraticSlack = 1e-13;
public static Axis For(double dirX, double dirY)
{
@@ -208,10 +208,10 @@ namespace OpenNest.Geometry
/// False for NaN, infinities and magnitudes beyond the coordinate limit.
private static bool Bounded(double value) => System.Math.Abs(value) <= CoordinateLimit;
- private static double Perp(Axis axis, double x, double y) =>
+ internal static double Perp(Axis axis, double x, double y) =>
axis == Axis.PerpendicularY ? y : x;
- private static double Para(Axis axis, double x, double y) =>
+ internal static double Para(Axis axis, double x, double y) =>
axis == Axis.PerpendicularY ? x : y;
}
}
diff --git a/OpenNest.Core/Geometry/SlideBandIndex.cs b/OpenNest.Core/Geometry/SlideBandIndex.cs
new file mode 100644
index 00000000..37297603
--- /dev/null
+++ b/OpenNest.Core/Geometry/SlideBandIndex.cs
@@ -0,0 +1,512 @@
+#nullable enable
+using System.Collections.Generic;
+
+namespace OpenNest.Geometry
+{
+ ///
+ /// Buckets entity indices by their perpendicular extent for one axis-aligned slide, so a
+ /// band query returns, in ascending entity order, a superset of the entities the
+ /// tests accept. Callers still apply those exact tests; the index
+ /// only avoids visiting entities that cannot pass them.
+ ///
+ ///
+ /// Each extent is the exact acceptance bound of the matching test,
+ /// widened by a reach that covers the quadratic slack of ray-circle and curve-curve tests
+ /// (which grows with the along-axis distance) plus rounding. Bucket numbers are a
+ /// monotone function of the coordinate, so a value inside an extent always lands in one of
+ /// the extent's buckets.
+ ///
+ internal sealed class SlideBandIndex
+ {
+ /// Entities of one kind an index holds.
+ internal enum Kind
+ {
+ All,
+ Lines,
+ Curves,
+ }
+
+ // Below this many entities a plain scan is as cheap as building buckets.
+ internal const int MinimumEntities = 24;
+
+ private const int MaximumBuckets = 512;
+
+ // Merging more sorted bucket lists than this by repeated minimum is slower than a sort.
+ private const int MaximumMergedBuckets = 16;
+
+ private readonly double min;
+ private readonly double scale;
+ private readonly int bucketCount;
+ private readonly int[] starts;
+ private readonly int[] items;
+
+ private SlideBandIndex(double min, double scale, int bucketCount, int[] starts, int[] items)
+ {
+ this.min = min;
+ this.scale = scale;
+ this.bucketCount = bucketCount;
+ this.starts = starts;
+ this.items = items;
+ }
+
+ ///
+ /// The widening every extent needs on top of its own exact bound: twice √ε times the
+ /// largest along-axis separation (the quadratic slack), plus a relative rounding term.
+ /// NaN when a coordinate is nonfinite or beyond ,
+ /// where the band tests keep every pair and an index could not stand in for them.
+ ///
+ public static double Reach(double maxAbsAlong, double maxAbsPerpendicular)
+ {
+ if (
+ !(maxAbsAlong <= SlideBand.CoordinateLimit)
+ || !(maxAbsPerpendicular <= SlideBand.CoordinateLimit)
+ )
+ return double.NaN;
+ var separation = 2 * maxAbsAlong;
+ // The second term is headroom for rounding of the extents and the curve tests'
+ // 2ε·|r|; no test case needs it today, but it costs nothing measurable.
+ return 2 * System.Math.Sqrt(SlideBand.QuadraticSlack) * (1 + separation)
+ + 1e-12 * (1 + separation + maxAbsPerpendicular);
+ }
+
+ ///
+ /// A curve's half-width before widening. The quadratic tests accept
+ /// |oy| ≤ |r|·(1 + 2ε) + 2√ε·(1 + |ox|) with ε = QuadraticSlack; the reach holds both the
+ /// second term and the first one's 2ε·|r|, since its rounding term is 1e-12 times a bound
+ /// that includes every radius.
+ ///
+ public static double CurveReach(double r) => System.Math.Abs(r);
+
+ ///
+ /// Indexes the entities of in
+ /// translated by (dx, dy), or returns null when the list is small or the geometry or
+ /// reach is not usable.
+ ///
+ public static SlideBandIndex? Build(
+ SlideBand.Axis axis,
+ List entities,
+ double dx,
+ double dy,
+ double reach,
+ Kind kind
+ )
+ {
+ var count = entities.Count;
+ if (count < MinimumEntities || !double.IsFinite(reach) || reach < 0)
+ return null;
+
+ var lo = new double[count];
+ var hi = new double[count];
+ var indexed = new bool[count];
+ var members = 0;
+ var min = double.PositiveInfinity;
+ var max = double.NegativeInfinity;
+ for (var i = 0; i < count; i++)
+ {
+ if (!TryExtent(axis, entities[i], dx, dy, kind, out var a, out var b))
+ continue;
+ a -= reach;
+ b += reach;
+ if (!double.IsFinite(a) || !double.IsFinite(b))
+ return null;
+ lo[i] = a;
+ hi[i] = b;
+ indexed[i] = true;
+ members++;
+ if (a < min)
+ min = a;
+ if (b > max)
+ max = b;
+ }
+
+ if (members == 0)
+ return new SlideBandIndex(0, 0, 1, new int[2], System.Array.Empty());
+
+ var buckets = System.Math.Min(MaximumBuckets, members);
+ var span = max - min;
+ var scale = span > 0 ? buckets / span : 0;
+ if (!double.IsFinite(scale) || scale <= 0)
+ {
+ scale = 0;
+ buckets = 1;
+ }
+
+ var shape = new SlideBandIndex(
+ min,
+ scale,
+ buckets,
+ System.Array.Empty(),
+ System.Array.Empty()
+ );
+ var next = new int[buckets + 1];
+ for (var i = 0; i < count; i++)
+ {
+ if (!indexed[i])
+ continue;
+ var last = shape.Bucket(hi[i]);
+ for (var b = shape.Bucket(lo[i]); b <= last; b++)
+ next[b + 1]++;
+ }
+
+ for (var b = 0; b < buckets; b++)
+ next[b + 1] += next[b];
+ var starts = (int[])next.Clone();
+ var items = new int[next[buckets]];
+ for (var i = 0; i < count; i++)
+ {
+ if (!indexed[i])
+ continue;
+ var last = shape.Bucket(hi[i]);
+ for (var b = shape.Bucket(lo[i]); b <= last; b++)
+ items[next[b]++] = i;
+ }
+
+ return new SlideBandIndex(min, scale, buckets, starts, items);
+ }
+
+ ///
+ /// Entities whose extent may contain , as the item positions
+ /// [start, end), ascending. Empty when the value lies below every extent; false for a
+ /// nonfinite value (scan everything).
+ ///
+ public bool TryPoint(double value, out int start, out int end)
+ {
+ start = end = 0;
+ if (!double.IsFinite(value))
+ return false;
+ if (value < min)
+ return true;
+ var b = Bucket(value);
+ start = starts[b];
+ end = starts[b + 1];
+ return true;
+ }
+
+ ///
+ /// Entities whose extent may overlap [, ],
+ /// ascending and without repeats, written to . False for a
+ /// nonfinite interval (scan everything).
+ ///
+ public bool TryRange(double from, double to, List result)
+ {
+ result.Clear();
+ if (!double.IsFinite(from) || !double.IsFinite(to))
+ return false;
+ if (to < min)
+ return true;
+
+ var first = Bucket(from);
+ var last = Bucket(to);
+ if (first == last)
+ {
+ for (var k = starts[first]; k < starts[first + 1]; k++)
+ result.Add(items[k]);
+ return true;
+ }
+
+ var lists = last - first + 1;
+ if (lists > MaximumMergedBuckets)
+ {
+ for (var k = starts[first]; k < starts[last + 1]; k++)
+ result.Add(items[k]);
+ result.Sort();
+ Deduplicate(result);
+ return true;
+ }
+
+ // Each bucket list is ascending: merge them by repeated minimum, dropping repeats.
+ System.Span heads = stackalloc int[MaximumMergedBuckets];
+ for (var b = 0; b < lists; b++)
+ heads[b] = starts[first + b];
+ var previous = -1;
+ while (true)
+ {
+ var best = int.MaxValue;
+ var source = -1;
+ for (var b = 0; b < lists; b++)
+ {
+ var head = heads[b];
+ if (head < starts[first + b + 1] && items[head] < best)
+ {
+ best = items[head];
+ source = b;
+ }
+ }
+ if (source < 0)
+ return true;
+ heads[source]++;
+ if (best != previous)
+ result.Add(best);
+ previous = best;
+ }
+ }
+
+ public int Item(int position) => items[position];
+
+ private static void Deduplicate(List sorted)
+ {
+ var write = 0;
+ for (var k = 0; k < sorted.Count; k++)
+ {
+ if (write > 0 && sorted[write - 1] == sorted[k])
+ continue;
+ sorted[write++] = sorted[k];
+ }
+ sorted.RemoveRange(write, sorted.Count - write);
+ }
+
+ /// Monotone in , clamped to the bucket range.
+ private int Bucket(double value)
+ {
+ if (scale == 0)
+ return 0;
+ var b = (value - min) * scale;
+ if (!(b >= 0))
+ return 0;
+ if (b >= bucketCount)
+ return bucketCount - 1;
+ return (int)b;
+ }
+
+ ///
+ /// The exact acceptance bound of the entity's tests before
+ /// widening: a line's span with its RayLine slack, or a curve's circle with its radius
+ /// slack. False for entity types no slide kernel emits for, or not of
+ /// .
+ ///
+ private static bool TryExtent(
+ SlideBand.Axis axis,
+ Entity entity,
+ double dx,
+ double dy,
+ Kind kind,
+ out double lo,
+ out double hi
+ )
+ {
+ switch (entity)
+ {
+ case Line line when kind != Kind.Curves:
+ {
+ var a = SlideBand.Perp(axis, line.pt1.X + dx, line.pt1.Y + dy);
+ var b = SlideBand.Perp(axis, line.pt2.X + dx, line.pt2.Y + dy);
+ var low = System.Math.Min(a, b);
+ var high = System.Math.Max(a, b);
+ var margin = SlideBand.SpanSlack * (high - low);
+ lo = low - margin;
+ hi = high + margin;
+ return true;
+ }
+ case Arc arc when kind != Kind.Lines:
+ return CurveExtent(
+ axis,
+ arc.Center.X + dx,
+ arc.Center.Y + dy,
+ arc.Radius,
+ out lo,
+ out hi
+ );
+ case Circle circle when kind != Kind.Lines:
+ return CurveExtent(
+ axis,
+ circle.Center.X + dx,
+ circle.Center.Y + dy,
+ circle.Radius,
+ out lo,
+ out hi
+ );
+ default:
+ lo = hi = 0;
+ return false;
+ }
+ }
+
+ private static bool CurveExtent(
+ SlideBand.Axis axis,
+ double cx,
+ double cy,
+ double r,
+ out double lo,
+ out double hi
+ )
+ {
+ var c = SlideBand.Perp(axis, cx, cy);
+ var radius = CurveReach(r);
+ lo = c - radius;
+ hi = c + radius;
+ return true;
+ }
+
+ ///
+ /// Raises the largest |along| and |perpendicular| coordinate (perpendicular plus radius
+ /// for curves) of translated vertices, line ends and curve centres; used to size
+ /// . A NaN propagates. Returns the number of curves seen.
+ ///
+ public static int Measure(
+ SlideBand.Axis axis,
+ List entities,
+ Vector[] vertices,
+ double dx,
+ double dy,
+ ref double maxAlong,
+ ref double maxPerpendicular
+ )
+ {
+ var curves = 0;
+ for (var i = 0; i < vertices.Length; i++)
+ {
+ var v = vertices[i];
+ Accumulate(axis, v.X + dx, v.Y + dy, 0, ref maxAlong, ref maxPerpendicular);
+ }
+ for (var i = 0; i < entities.Count; i++)
+ {
+ switch (entities[i])
+ {
+ case Line line:
+ Accumulate(
+ axis,
+ line.pt1.X + dx,
+ line.pt1.Y + dy,
+ 0,
+ ref maxAlong,
+ ref maxPerpendicular
+ );
+ Accumulate(
+ axis,
+ line.pt2.X + dx,
+ line.pt2.Y + dy,
+ 0,
+ ref maxAlong,
+ ref maxPerpendicular
+ );
+ break;
+ case Arc arc:
+ curves++;
+ Accumulate(
+ axis,
+ arc.Center.X + dx,
+ arc.Center.Y + dy,
+ arc.Radius,
+ ref maxAlong,
+ ref maxPerpendicular
+ );
+ break;
+ case Circle circle:
+ curves++;
+ Accumulate(
+ axis,
+ circle.Center.X + dx,
+ circle.Center.Y + dy,
+ circle.Radius,
+ ref maxAlong,
+ ref maxPerpendicular
+ );
+ break;
+ }
+ }
+ return curves;
+ }
+
+ private static void Accumulate(
+ SlideBand.Axis axis,
+ double x,
+ double y,
+ double r,
+ ref double maxAlong,
+ ref double maxPerpendicular
+ )
+ {
+ var along = System.Math.Abs(SlideBand.Para(axis, x, y));
+ var perpendicular = System.Math.Abs(SlideBand.Perp(axis, x, y)) + System.Math.Abs(r);
+ // Written so a NaN propagates instead of being ignored by Math.Max.
+ maxAlong = along > maxAlong || double.IsNaN(along) ? along : maxAlong;
+ maxPerpendicular = perpendicular > maxPerpendicular || double.IsNaN(perpendicular)
+ ? perpendicular
+ : maxPerpendicular;
+ }
+ }
+
+ ///
+ /// The band indexes one enumeration uses, built only for
+ /// the phases that have work: both sides for vertex rays, the line-only side an arc of the
+ /// other side queries, and the stationary curves the moving curves query.
+ ///
+ internal sealed class SlideBandIndexes
+ {
+ public SlideBandIndex? Stationary { get; private init; }
+
+ public SlideBandIndex? Moving { get; private init; }
+
+ public SlideBandIndex? StationaryLines { get; private init; }
+
+ public SlideBandIndex? MovingLines { get; private init; }
+
+ public SlideBandIndex? StationaryCurves { get; private init; }
+
+ /// Scratch list for range queries.
+ public List Candidates { get; } = new();
+
+ ///
+ /// Null when neither side is large enough to index or a coordinate is unusable.
+ ///
+ public static SlideBandIndexes? Build(
+ SlideBand.Axis axis,
+ List moving,
+ Vector[] movingVertices,
+ double movingDx,
+ double movingDy,
+ List stationary,
+ Vector[] stationaryVertices,
+ bool arcToLine
+ )
+ {
+ if (
+ stationary.Count < SlideBandIndex.MinimumEntities
+ && moving.Count < SlideBandIndex.MinimumEntities
+ )
+ return null;
+
+ double along = 0,
+ perpendicular = 0;
+ var stationaryCurves = SlideBandIndex.Measure(
+ axis,
+ stationary,
+ stationaryVertices,
+ 0,
+ 0,
+ ref along,
+ ref perpendicular
+ );
+ var movingCurves = SlideBandIndex.Measure(
+ axis,
+ moving,
+ movingVertices,
+ movingDx,
+ movingDy,
+ ref along,
+ ref perpendicular
+ );
+ var reach = SlideBandIndex.Reach(along, perpendicular);
+ if (!double.IsFinite(reach))
+ return null;
+
+ var all = SlideBandIndex.Kind.All;
+ var lines = SlideBandIndex.Kind.Lines;
+ var curves = SlideBandIndex.Kind.Curves;
+ return new SlideBandIndexes
+ {
+ Stationary = SlideBandIndex.Build(axis, stationary, 0, 0, reach, all),
+ Moving = SlideBandIndex.Build(axis, moving, movingDx, movingDy, reach, all),
+ StationaryLines = arcToLine && movingCurves > 0
+ ? SlideBandIndex.Build(axis, stationary, 0, 0, reach, lines)
+ : null,
+ MovingLines = arcToLine && stationaryCurves > 0
+ ? SlideBandIndex.Build(axis, moving, movingDx, movingDy, reach, lines)
+ : null,
+ StationaryCurves = movingCurves > 0 && stationaryCurves > 0
+ ? SlideBandIndex.Build(axis, stationary, 0, 0, reach, curves)
+ : null,
+ };
+ }
+ }
+}
diff --git a/OpenNest.Core/Geometry/SlideEvents.cs b/OpenNest.Core/Geometry/SlideEvents.cs
index c0b55a6b..4b3890ea 100644
--- a/OpenNest.Core/Geometry/SlideEvents.cs
+++ b/OpenNest.Core/Geometry/SlideEvents.cs
@@ -229,7 +229,9 @@ namespace OpenNest.Geometry
double rayX,
double rayY,
bool arcMoves,
- SlideBand.Axis band
+ SlideBand.Axis band,
+ SlideBandIndex lineIndex,
+ List candidates
)
where TSink : struct, ISlideEventSink
{
@@ -242,8 +244,19 @@ namespace OpenNest.Geometry
var cx = localCx + arcDx;
var cy = localCy + arcDy;
- for (var j = 0; j < lineEntities.Count; j++)
+ // With an index, visit only the lines CircleMayReachLine can accept, ascending.
+ var indexed = false;
+ if (lineIndex != null)
{
+ var c = SlideBand.Perp(band, cx, cy);
+ var radius = System.Math.Abs(r);
+ indexed = lineIndex.TryRange(c - radius, c + radius, candidates);
+ }
+
+ var count = indexed ? candidates.Count : lineEntities.Count;
+ for (var n = 0; n < count; n++)
+ {
+ var j = indexed ? candidates[n] : n;
if (lineEntities[j] is not Line line)
continue;
@@ -487,7 +500,21 @@ namespace OpenNest.Geometry
// An axis-aligned push only meets entities whose perpendicular extent can hold the
// ray. The band tests skip only pairs whose kernels cannot emit and keep the
// surviving pairs in their original order, so the emitted events are unchanged.
+ // For larger boundaries, bucket indexes over those extents limit each query to a
+ // superset of the accepted entities, still visited in ascending order.
var band = SlideBand.For(dirX, dirY);
+ var indexes = band == SlideBand.Axis.None
+ ? null
+ : SlideBandIndexes.Build(
+ band,
+ moving,
+ movingVertices,
+ movingDx,
+ movingDy,
+ stationary,
+ stationaryVertices,
+ arcToLine
+ );
// Phase 1: moving vertices along the push against stationary entities.
for (var v = 0; v < movingVertices.Length; v++)
@@ -495,6 +522,27 @@ namespace OpenNest.Geometry
var vx = movingVertices[v].X + movingDx;
var vy = movingVertices[v].Y + movingDy;
+ if (
+ indexes?.Stationary != null
+ && indexes.Stationary.TryPoint(
+ SlideBand.Perp(band, vx, vy),
+ out var first,
+ out var last
+ )
+ )
+ {
+ for (var k = first; k < last; k++)
+ {
+ var j = indexes.Stationary.Item(k);
+ if (!SlideBand.RayMayHit(band, vx, vy, stationary[j], 0, 0))
+ continue;
+ SlideEvents.Ray(ref sink, vx, vy, stationary[j], 0, 0, dirX, dirY, true);
+ if (sink.IsDone)
+ return;
+ }
+ continue;
+ }
+
for (var j = 0; j < stationary.Count; j++)
{
if (
@@ -514,6 +562,37 @@ namespace OpenNest.Geometry
var vx = stationaryVertices[v].X;
var vy = stationaryVertices[v].Y;
+ if (
+ indexes?.Moving != null
+ && indexes.Moving.TryPoint(
+ SlideBand.Perp(band, vx, vy),
+ out var first,
+ out var last
+ )
+ )
+ {
+ for (var k = first; k < last; k++)
+ {
+ var j = indexes.Moving.Item(k);
+ if (!SlideBand.RayMayHit(band, vx, vy, moving[j], movingDx, movingDy))
+ continue;
+ SlideEvents.Ray(
+ ref sink,
+ vx,
+ vy,
+ moving[j],
+ movingDx,
+ movingDy,
+ -dirX,
+ -dirY,
+ false
+ );
+ if (sink.IsDone)
+ return;
+ }
+ continue;
+ }
+
for (var j = 0; j < moving.Count; j++)
{
if (
@@ -551,7 +630,9 @@ namespace OpenNest.Geometry
dirX,
dirY,
true,
- band
+ band,
+ indexes?.StationaryLines,
+ indexes?.Candidates
);
if (sink.IsDone)
return;
@@ -566,7 +647,9 @@ namespace OpenNest.Geometry
-dirX,
-dirY,
false,
- band
+ band,
+ indexes?.MovingLines,
+ indexes?.Candidates
);
if (sink.IsDone)
return;
@@ -578,8 +661,22 @@ namespace OpenNest.Geometry
if (!SlideEvents.TryGetCurve(moving[i], out var mcx, out var mcy, out var mr))
continue;
- for (var j = 0; j < stationary.Count; j++)
+ var indexed = false;
+ if (indexes?.StationaryCurves != null)
{
+ var c = SlideBand.Perp(band, mcx + movingDx, mcy + movingDy);
+ var reach = SlideBandIndex.CurveReach(mr);
+ indexed = indexes.StationaryCurves.TryRange(
+ c - reach,
+ c + reach,
+ indexes.Candidates
+ );
+ }
+
+ var count = indexed ? indexes.Candidates.Count : stationary.Count;
+ for (var n = 0; n < count; n++)
+ {
+ var j = indexed ? indexes.Candidates[n] : n;
if (!SlideEvents.TryGetCurve(stationary[j], out var scx, out var scy, out var sr))
continue;
diff --git a/OpenNest.Tests/Geometry/SlideBandTests.cs b/OpenNest.Tests/Geometry/SlideBandTests.cs
index 465ee466..0dd751c0 100644
--- a/OpenNest.Tests/Geometry/SlideBandTests.cs
+++ b/OpenNest.Tests/Geometry/SlideBandTests.cs
@@ -13,19 +13,25 @@ public class SlideBandTests
private static readonly (double X, double Y)[] AxisDirections = { (1, 0), (-1, 0), (0, 1), (0, -1) };
[Theory]
- [InlineData(1.0)]
- [InlineData(250.0)]
- [InlineData(100000.0)]
- [InlineData(30000000.0)]
- public void RandomShapes_EmitTheSameEventsAsTheUnfilteredEnumeration(double scale)
+ [InlineData(1.0, 3, 12)]
+ [InlineData(250.0, 3, 12)]
+ [InlineData(100000.0, 3, 12)]
+ [InlineData(30000000.0, 3, 12)]
+ [InlineData(1.0, 24, 160)]
+ [InlineData(250.0, 24, 160)]
+ [InlineData(100000.0, 24, 160)]
+ [InlineData(30000000.0, 24, 160)]
+ public void RandomShapes_EmitTheSameEventsAsTheUnfilteredEnumeration(double scale, int minSides, int maxSides)
{
- var random = new Random(20261031 + (int)scale);
+ // Boundaries of 24 or more entities go through the bucket index; smaller ones scan.
+ var random = new Random(20261031 + (int)scale + maxSides);
var compared = 0;
var nonEmpty = 0;
- for (var trial = 0; trial < 150; trial++)
+ var trials = maxSides > SlideBandIndex.MinimumEntities ? 25 : 150;
+ for (var trial = 0; trial < trials; trial++)
{
- var stationary = RandomBoundary(random, scale);
- var moving = RandomBoundary(random, scale);
+ var stationary = RandomBoundary(random, scale, minSides, maxSides);
+ var moving = RandomBoundary(random, scale, minSides, maxSides);
foreach (var (dirX, dirY) in AxisDirections)
{
foreach (var (dx, dy) in Offsets(random, moving, stationary, dirX, dirY, scale))
@@ -41,10 +47,15 @@ public class SlideBandTests
}
[Theory]
- [InlineData(0.5)]
- [InlineData(-0.5)]
- [InlineData(0.99)]
- public void VertexJustPastASegmentEnd_WithinTheRayTolerance_StillEmits(double fractionOfTolerance)
+ [InlineData(0.5, false)]
+ [InlineData(-0.5, false)]
+ [InlineData(0.99, false)]
+ [InlineData(0.5, true)]
+ [InlineData(-0.99, true)]
+ public void VertexJustPastASegmentEnd_WithinTheRayTolerance_StillEmits(
+ double fractionOfTolerance,
+ bool indexed
+ )
{
// RayLine accepts a segment parameter up to Eps past either end, so a ray that misses
// the segment's extent by less than Eps of its span still reports a hit.
@@ -55,54 +66,68 @@ public class SlideBandTests
: fractionOfTolerance * Tolerance.Epsilon * span;
var moving = new List { new Line(new Vector(0, y), new Vector(-5, y)) };
- var events = CompareEvents(moving, stationary, 0, 0, 1, 0);
+ var events = CompareEvents(Pad(moving, indexed), Pad(stationary, indexed), 0, 0, 1, 0);
Assert.True(events > 0, "the legacy kernel must report this tolerance hit");
}
[Theory]
- [InlineData(1e-9)]
- [InlineData(1e-3)]
- [InlineData(5.0)]
- public void RayGrazingAFarCircle_KeepsEveryRoundingHit(double radius)
+ [InlineData(1e-9, false, false)]
+ [InlineData(1e-3, false, false)]
+ [InlineData(5.0, false, false)]
+ [InlineData(1e-9, true, false)]
+ [InlineData(5.0, true, false)]
+ [InlineData(5.0, true, true)]
+ [InlineData(1e-9, true, true)]
+ public void RayGrazingAFarCircle_KeepsEveryRoundingHit(double radius, bool indexed, bool offset)
{
// Far along the ray the discriminant cancels catastrophically; sweep the band around
- // the circle's edge and require identical events, including any rounding hits.
- var stationary = new List { new Circle(new Vector(90000, 37), radius) };
+ // the circle's edge and require identical events, including any rounding hits. With
+ // `offset` the distance comes from the moving translation rather than the geometry.
+ var center = offset ? 0.0 : 90000.0;
+ var dx = offset ? -90000.0 : 0.0;
+ var stationary = Pad(new List { new Circle(new Vector(center, 37), radius) }, indexed);
var hits = 0;
for (var k = -400; k <= 400; k++)
{
- var y = 37 + radius + k * 1e-9 * System.Math.Max(1, radius);
- var moving = new List { new Line(new Vector(0, y), new Vector(-1, y)) };
- hits += CompareEvents(moving, stationary, 0, 0, 1, 0) > 0 ? 1 : 0;
+ // Tiny radii widen the rounding band to about √(ulp(ox²)); sweep it coarsely.
+ var step = radius < 1e-6 && offset ? 1e-6 : 1e-9 * System.Math.Max(1, radius);
+ var y = 37 + radius + k * step;
+ var moving = Pad(new List { new Line(new Vector(0, y), new Vector(-1, y)) }, indexed);
+ hits += CompareEvents(moving, stationary, dx, 0, 1, 0) > 0 ? 1 : 0;
}
Assert.True(hits > 0, "the sweep must include hits");
}
- [Fact]
- public void TangentCurvesAcrossTheBandEdge_EmitTheSameEvents()
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void TangentCurvesAcrossTheBandEdge_EmitTheSameEvents(bool indexed)
{
- var stationary = new List { new Arc(new Vector(0, 0), 3, 0, Angle.TwoPI * 0.75, false) };
+ var arc = new Arc(new Vector(0, 0), 3, 0, Angle.TwoPI * 0.75, false);
+ var stationary = Pad(new List { arc }, indexed);
var emitted = 0;
for (var k = -200; k <= 200; k++)
{
var dy = 3 + 2 + k * 1e-10;
- var moving = new List { new Circle(new Vector(-50, 0), 2) };
+ var moving = Pad(new List { new Circle(new Vector(-50, 0), 2) }, indexed);
emitted += CompareEvents(moving, stationary, 0, dy, 1, 0) > 0 ? 1 : 0;
}
Assert.True(emitted > 0, "the sweep must include tangent contacts");
}
[Theory]
- [InlineData(0.0)]
- [InlineData(2e-5)]
- public void ArcAgainstANearlyParallelLine_AtTheBandEdge_EmitsTheSameEvents(double end)
+ [InlineData(0.0, false)]
+ [InlineData(2e-5, false)]
+ [InlineData(0.0, true)]
+ [InlineData(2e-5, true)]
+ public void ArcAgainstANearlyParallelLine_AtTheBandEdge_EmitsTheSameEvents(double end, bool indexed)
{
// ArcToLine samples the circle where its tangent is parallel to the line. For a line
// nearly parallel to the push that point is the circle's top or bottom, so sweeping
// the circle across either end of the line's tiny perpendicular span walks the
// arc-to-line band edge, including RayLine's Eps·span tolerance past the end.
- var stationary = new List { new Line(new Vector(0, 0), new Vector(1000, 2e-5)) };
+ var stationary = Pad(new List { new Line(new Vector(0, 0), new Vector(1000, 2e-5)) }, indexed);
var radius = 5.0;
var hits = 0;
for (var k = -60; k <= 60; k++)
@@ -111,7 +136,7 @@ public class SlideBandTests
{
// side -1 puts the circle above the line (bottom point), +1 below (top point).
var cy = end - side * radius + k * 1e-11;
- var moving = new List { new Circle(new Vector(-50, cy), radius) };
+ var moving = Pad(new List { new Circle(new Vector(-50, cy), radius) }, indexed);
hits += CompareEvents(moving, stationary, 0, 0, 1, 0, vertices: false) > 0 ? 1 : 0;
}
}
@@ -154,6 +179,20 @@ public class SlideBandTests
Assert.True(CompareVertexEvents(new[] { new Vector(-7e153, 1e150) }, circle, 0, 0) > 0);
}
+ [Theory]
+ [InlineData(double.NegativeInfinity)]
+ [InlineData(-3e150)]
+ public void LargeBoundary_WithAVertexBeyondTheLimitAlongThePush_KeepsTheKernelsEvents(double x)
+ {
+ // Enough entities to build an index; the far vertex must switch it off, because the
+ // kernels can emit against lines whose band the vertex misses.
+ var stationary = Enumerable.Range(0, 40)
+ .Select(i => (Entity)new Line(new Vector(i, 0), new Vector(i + 0.5, 1)))
+ .ToList();
+ var events = CompareVertexEvents(new[] { new Vector(x, 0.5) }, stationary, 0, 0);
+ Assert.True(events > 0);
+ }
+
[Fact]
public void InfiniteLineEnd_KeepsArcToLineEvents()
{
@@ -194,6 +233,76 @@ public class SlideBandTests
Assert.False(SlideBand.CircleMayReachLine(SlideBand.Axis.PerpendicularY, 0, 10, 1, 5, 0, 6, 2));
}
+ [Fact]
+ public void Index_ReturnsEveryAcceptedEntity_InAscendingOrder()
+ {
+ var random = new Random(7);
+ foreach (var axis in new[] { SlideBand.Axis.PerpendicularY, SlideBand.Axis.PerpendicularX })
+ {
+ for (var trial = 0; trial < 40; trial++)
+ {
+ var scale = new[] { 1.0, 300.0, 1e6 }[trial % 3];
+ var entities = RandomBoundary(random, scale, 24, 200);
+ var vertices = SpatialQuery.ExtractEntityVertices(entities);
+ double along = 0, perpendicular = 0;
+ SlideBandIndex.Measure(axis, entities, vertices, 0, 0, ref along, ref perpendicular);
+ var reach = SlideBandIndex.Reach(along, perpendicular);
+ var index = SlideBandIndex.Build(axis, entities, 0, 0, reach, SlideBandIndex.Kind.All);
+ Assert.NotNull(index);
+ var range = new List();
+ for (var q = 0; q < 300; q++)
+ {
+ var v = vertices[random.Next(vertices.Length)];
+ var nudge = (random.NextDouble() - 0.5) * 1e-4 * scale * (q % 2);
+ var vx = v.X + (axis == SlideBand.Axis.PerpendicularX ? nudge : 0);
+ var vy = v.Y + (axis == SlideBand.Axis.PerpendicularY ? nudge : 0);
+ var expected = Enumerable.Range(0, entities.Count)
+ .Where(j => entities[j] is Line or Arc or Circle && SlideBand.RayMayHit(axis, vx, vy, entities[j], 0, 0))
+ .ToList();
+ Assert.True(index!.TryPoint(SlideBand.Perp(axis, vx, vy), out var first, out var last));
+ var found = Enumerable.Range(first, last - first).Select(index.Item).ToList();
+ Assert.Equal(found.OrderBy(j => j), found);
+ Assert.Empty(expected.Except(found));
+
+ var p = SlideBand.Perp(axis, vx, vy);
+ Assert.True(index.TryRange(p - scale * 0.05, p + scale * 0.05, range));
+ Assert.Equal(range.Distinct().OrderBy(j => j), range);
+ }
+ }
+ }
+ }
+
+ [Fact]
+ public void Index_RefusesNonfiniteGeometry()
+ {
+ var entities = Enumerable.Range(0, 30)
+ .Select(i => (Entity)new Line(new Vector(i, 0), new Vector(i, 1)))
+ .Append(new Line(new Vector(0, double.NaN), new Vector(1, 1)))
+ .ToList();
+ Assert.Null(SlideBandIndex.Build(SlideBand.Axis.PerpendicularY, entities, 0, 0, 0, SlideBandIndex.Kind.All));
+ Assert.Null(SlideBandIndex.Build(SlideBand.Axis.PerpendicularY, entities.Take(30).ToList(), 0, 0, double.NaN, SlideBandIndex.Kind.All));
+ Assert.True(double.IsNaN(SlideBandIndex.Reach(2e150, 0)));
+ Assert.True(double.IsNaN(SlideBandIndex.Reach(0, double.PositiveInfinity)));
+ var index = SlideBandIndex.Build(SlideBand.Axis.PerpendicularY, entities.Take(30).ToList(), 0, 0, 0, SlideBandIndex.Kind.All);
+ Assert.NotNull(index);
+ Assert.False(index!.TryPoint(double.NaN, out _, out _));
+ Assert.False(index.TryRange(0, double.PositiveInfinity, new List()));
+ }
+
+ ///
+ /// With , appends far-off decoy lines parallel to the push so the
+ /// list reaches the size that builds a band index; they never emit.
+ ///
+ private static List Pad(List entities, bool indexed)
+ {
+ if (!indexed)
+ return entities;
+ var padded = new List(entities);
+ for (var i = 0; padded.Count < SlideBandIndex.MinimumEntities + 6; i++)
+ padded.Add(new Line(new Vector(0, 1e4 + i), new Vector(1, 1e4 + i)));
+ return padded;
+ }
+
/// Asserts identical event sequences and slide results; returns the event count.
private static int CompareEvents(
List moving,
@@ -269,10 +378,10 @@ public class SlideBandTests
private static long Bits(double value) => double.IsNaN(value) ? 0x7FF8000000000000 : BitConverter.DoubleToInt64Bits(value);
/// A closed boundary of lines and arcs around a random star, sometimes with a hole circle.
- private static List RandomBoundary(Random random, double scale)
+ private static List RandomBoundary(Random random, double scale, int minSides, int maxSides)
{
var entities = new List();
- var count = random.Next(3, 12);
+ var count = random.Next(minSides, maxSides);
var cx = (random.NextDouble() - 0.5) * scale;
var cy = (random.NextDouble() - 0.5) * scale;
var points = new List();