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();