perf(geometry): bucket slide entities by axis band

The band test still visited every entity pair. For boundaries of 24 or
more entities, EntitySlideEvents now builds bucket indexes over each
entity's perpendicular extent per enumeration (both sides for vertex
rays, the line-only side for arc-to-line, the stationary curves for
tangency) and visits only the bucket members, ascending, before applying
the same exact band tests. Range queries merge the ascending bucket
lists without repeats. Each extent is the band test's own acceptance
bound widened by a reach of 2*sqrt(QuadraticSlack)*(1 + largest
along-axis separation) for the quadratic tests plus a rounding term;
bucket numbers are monotone in the coordinate, so a query returns a
superset of the accepted entities in their original order and the
emitted events are unchanged. Geometry with any coordinate that is
nonfinite or beyond the band's coordinate limit is not indexed.

SlideBandTests adds large random boundaries (24-160 entities) to the
differential comparison against the frozen pre-filter enumeration,
reruns the band-edge sweeps with decoy entities so they go through the
index (including a far circle reached through the moving offset), and
checks index queries directly. Mutations killed: zero reach, line extent
without span slack, repeats kept by the merge or the sorted path, last
bucket skipped, points just above the minimum treated as empty, curves
in the line index, arc and tangency queries ignoring the radius, moving
index or measurement without the moving offset. Not pinned by a test:
the reach's rounding term (headroom).

Measured on converted Nestbolt jobs (Default engine, hermes, quiet
machine, one run each), before -> after: m-box-tool 47.1 -> 19.2 s,
m-mechanical-mounting-kit 45.9 -> 27.3 s, m-mechanical-cog-washers
74.7 -> 44.2 s, m-mechanical-small-brackets 23.0 -> 16.6 s. The nine
completed jobs keep identical placements.
This commit is contained in:
aj committed 2026-10-31 08:16:03 -04:00
1 parent a28b543634
commit 55910d1f9d
4 files changed
+761 -43

No files matched your search

+4 -4
View File
@@ -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
/// <summary>False for NaN, infinities and magnitudes beyond the coordinate limit.</summary>
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;
}
}
+512
View File
@@ -0,0 +1,512 @@
#nullable enable
using System.Collections.Generic;
namespace OpenNest.Geometry
{
/// <summary>
/// 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
/// <see cref="SlideBand"/> tests accept. Callers still apply those exact tests; the index
/// only avoids visiting entities that cannot pass them.
/// </summary>
/// <remarks>
/// Each extent is the exact acceptance bound of the matching <see cref="SlideBand"/> 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.
/// </remarks>
internal sealed class SlideBandIndex
{
/// <summary>Entities of one kind an index holds.</summary>
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;
}
/// <summary>
/// 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 <see cref="SlideBand.CoordinateLimit"/>,
/// where the band tests keep every pair and an index could not stand in for them.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public static double CurveReach(double r) => System.Math.Abs(r);
/// <summary>
/// Indexes the entities of <paramref name="kind"/> in <paramref name="entities"/>
/// translated by (dx, dy), or returns null when the list is small or the geometry or
/// reach is not usable.
/// </summary>
public static SlideBandIndex? Build(
SlideBand.Axis axis,
List<Entity> 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<int>());
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<int>(),
System.Array.Empty<int>()
);
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);
}
/// <summary>
/// Entities whose extent may contain <paramref name="value"/>, as the item positions
/// [start, end), ascending. Empty when the value lies below every extent; false for a
/// nonfinite value (scan everything).
/// </summary>
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;
}
/// <summary>
/// Entities whose extent may overlap [<paramref name="from"/>, <paramref name="to"/>],
/// ascending and without repeats, written to <paramref name="result"/>. False for a
/// nonfinite interval (scan everything).
/// </summary>
public bool TryRange(double from, double to, List<int> 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<int> 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<int> 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);
}
/// <summary>Monotone in <paramref name="value"/>, clamped to the bucket range.</summary>
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;
}
/// <summary>
/// The exact acceptance bound of the entity's <see cref="SlideBand"/> 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
/// <paramref name="kind"/>.
/// </summary>
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;
}
/// <summary>
/// Raises the largest |along| and |perpendicular| coordinate (perpendicular plus radius
/// for curves) of translated vertices, line ends and curve centres; used to size
/// <see cref="Reach"/>. A NaN propagates. Returns the number of curves seen.
/// </summary>
public static int Measure(
SlideBand.Axis axis,
List<Entity> 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;
}
}
/// <summary>
/// The band indexes one <see cref="EntitySlideEvents"/> 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.
/// </summary>
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; }
/// <summary>Scratch list for range queries.</summary>
public List<int> Candidates { get; } = new();
/// <summary>
/// Null when neither side is large enough to index or a coordinate is unusable.
/// </summary>
public static SlideBandIndexes? Build(
SlideBand.Axis axis,
List<Entity> moving,
Vector[] movingVertices,
double movingDx,
double movingDy,
List<Entity> 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,
};
}
}
}
+102 -5
View File
@@ -229,7 +229,9 @@ namespace OpenNest.Geometry
double rayX,
double rayY,
bool arcMoves,
SlideBand.Axis band
SlideBand.Axis band,
SlideBandIndex lineIndex,
List<int> 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;
+143 -34
View File
@@ -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<Entity> { 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<Entity> { 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<Entity> { 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<Entity> { 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<Entity> { 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<Entity> { 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<Entity> { arc }, indexed);
var emitted = 0;
for (var k = -200; k <= 200; k++)
{
var dy = 3 + 2 + k * 1e-10;
var moving = new List<Entity> { new Circle(new Vector(-50, 0), 2) };
var moving = Pad(new List<Entity> { 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<Entity> { new Line(new Vector(0, 0), new Vector(1000, 2e-5)) };
var stationary = Pad(new List<Entity> { 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<Entity> { new Circle(new Vector(-50, cy), radius) };
var moving = Pad(new List<Entity> { 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<int>();
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<int>()));
}
/// <summary>
/// With <paramref name="indexed"/>, appends far-off decoy lines parallel to the push so the
/// list reaches the size that builds a band index; they never emit.
/// </summary>
private static List<Entity> Pad(List<Entity> entities, bool indexed)
{
if (!indexed)
return entities;
var padded = new List<Entity>(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;
}
/// <summary>Asserts identical event sequences and slide results; returns the event count.</summary>
private static int CompareEvents(
List<Entity> moving,
@@ -269,10 +378,10 @@ public class SlideBandTests
private static long Bits(double value) => double.IsNaN(value) ? 0x7FF8000000000000 : BitConverter.DoubleToInt64Bits(value);
/// <summary>A closed boundary of lines and arcs around a random star, sometimes with a hole circle.</summary>
private static List<Entity> RandomBoundary(Random random, double scale)
private static List<Entity> RandomBoundary(Random random, double scale, int minSides, int maxSides)
{
var entities = new List<Entity>();
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<Vector>();