diff --git a/OpenNest.Core/Geometry/SlideContact.cs b/OpenNest.Core/Geometry/SlideContact.cs new file mode 100644 index 0000000..e24410f --- /dev/null +++ b/OpenNest.Core/Geometry/SlideContact.cs @@ -0,0 +1,831 @@ +using System.Collections.Generic; +using OpenNest.Math; + +namespace OpenNest.Geometry +{ + internal enum ContactSide + { + /// The boundary could not be decomposed into closed loops. + Unresolved, + + /// The point is not on the boundary: a tolerance near-miss, not a contact. + Off, + + /// Several boundary runs meet here, or the corner is a cusp or spike. + Ambiguous, + + /// The material sector is known. + Sector, + } + + /// + /// Closed boundary loops of one entity list, prepared so a directional slide can tell + /// which side of each boundary point is material. Immutable after + /// , so one instance may be shared by concurrent queries. + /// + /// + /// Loops are recovered from contiguous runs whose end points chain back to their start + /// (the order produced by and the offset helpers). Nesting + /// depth decides holes: material is inside even-depth loops and outside odd-depth ones. + /// When the list cannot be decomposed that way, every contact query is unresolved. + /// + public sealed class SlideContactGeometry + { + // Contact points are computed from unsnapped ray parameters, so a genuine contact is + // on both boundaries to floating-point accuracy. This also bounds the overlap sliver a + // tangential classification can admit, so keep it far below spacing tolerances. + internal const double IncidenceTolerance = 1e-7; + + private readonly List entities; + private readonly int[] loopOf; + private readonly int[] previous; + private readonly int[] following; + private readonly bool[] materialLeft; + + private SlideContactGeometry( + List entities, + int[] loopOf, + int[] previous, + int[] following, + bool[] materialLeft + ) + { + this.entities = entities; + this.loopOf = loopOf; + this.previous = previous; + this.following = following; + this.materialLeft = materialLeft; + } + + /// True when every entity belongs to a closed loop with a known material side. + public bool IsResolved => materialLeft != null; + + public static SlideContactGeometry Prepare(List entities) + { + var count = entities.Count; + var loopOf = new int[count]; + var previous = new int[count]; + var following = new int[count]; + var loops = new List<(int First, int Last)>(); + + var i = 0; + while (i < count) + { + var first = i; + if (entities[i] is Circle) + { + i++; + } + else + { + if (!TryEndpoints(entities[i], out var start, out _)) + return Unresolved(entities); + + var closed = false; + while (i < count && TryEndpoints(entities[i], out _, out var end)) + { + // A lone closed arc is a loop; a lone line cannot be, even when it + // has zero length and so ends where it starts. + if (Near(end, start) && (i > first || entities[i] is Arc)) + { + closed = true; + i++; + break; + } + + if ( + i + 1 >= count + || !TryEndpoints(entities[i + 1], out var nextStart, out _) + || !Near(nextStart, end) + ) + break; + + i++; + } + + if (!closed) + return Unresolved(entities); + } + + var loop = loops.Count; + loops.Add((first, i - 1)); + for (var k = first; k < i; k++) + { + loopOf[k] = loop; + previous[k] = k == first ? i - 1 : k - 1; + following[k] = k == i - 1 ? first : k + 1; + } + } + + var materialLeft = new bool[loops.Count]; + + for (var loop = 0; loop < loops.Count; loop++) + { + var area = SignedArea(entities, loops[loop].First, loops[loop].Last); + if (System.Math.Abs(area) <= Tolerance.Epsilon) + return Unresolved(entities); + + var depth = 0; + if (loops.Count > 1) + { + var sample = SamplePoint(entities[loops[loop].First]); + for (var other = 0; other < loops.Count; other++) + { + if (other == loop) + continue; + if (Contains(entities, loops[other].First, loops[other].Last, sample)) + depth++; + } + } + + materialLeft[loop] = (area > 0) == (depth % 2 == 0); + } + + return new SlideContactGeometry(entities, loopOf, previous, following, materialLeft); + } + + private static SlideContactGeometry Unresolved(List entities) => + new SlideContactGeometry(entities, null, null, null, null); + + /// + /// Material directions at a boundary point: an angular sector starting at + /// and sweeping CCW by . + /// Concavity is recorded separately at each sector ray: only the supporting + /// curve, not an unrelated curve at that corner, can block a tangential slide. + /// Entities wholly inside the incidence tolerance are treated as part of the corner. + /// + internal ContactSide GetMaterialSector( + Vector point, + out double start, + out double width, + out bool startConcave, + out bool endConcave + ) + { + start = width = 0; + startConcave = endConcave = false; + if (materialLeft == null) + return ContactSide.Unresolved; + + var best = -1; + var bestDistance = double.MaxValue; + for (var i = 0; i < entities.Count; i++) + { + var distance = DistanceTo(entities[i], point); + if (distance < bestDistance) + { + bestDistance = distance; + best = i; + } + } + + if (best < 0 || bestDistance > IncidenceTolerance) + return ContactSide.Off; + + // Walk to the entities that enter and leave the tolerance disc. + var loopLength = LoopLength(best); + var incoming = best; + var steps = 0; + var smoothLoop = loopLength == 1 && (entities[best] is Circle + || entities[best] is Arc fullArc && fullArc.IsFullCircle()); + while (!smoothLoop && StartsNear(incoming, point)) + { + incoming = previous[incoming]; + if (++steps >= loopLength) + return ContactSide.Ambiguous; + } + + var outgoing = best; + steps = 0; + while (!smoothLoop && EndsNear(outgoing, point)) + { + outgoing = following[outgoing]; + if (++steps >= loopLength) + return ContactSide.Ambiguous; + } + + // Anything else touching this point (another loop, a spike, a self-crossing) + // makes the local material side ambiguous. + for (var i = 0; i < entities.Count; i++) + { + if (InRun(i, incoming, outgoing)) + continue; + if (DistanceTo(entities[i], point) <= IncidenceTolerance) + return ContactSide.Ambiguous; + } + + var interior = incoming == best && outgoing == best && !EndsNear(best, point); + var inTangent = interior ? TangentAt(entities[best], point) : EndTangent(entities[incoming]); + var outTangent = interior + ? inTangent + : StartTangent(entities[outgoing]); + + // A circle has no endpoints, so its point is always interior. + if (smoothLoop) + inTangent = outTangent = TangentAt(entities[best], point); + + if (IsZero(inTangent) || IsZero(outTangent)) + return ContactSide.Ambiguous; + + var outAngle = System.Math.Atan2(outTangent.Y, outTangent.X); + var inAngle = System.Math.Atan2(-inTangent.Y, -inTangent.X); + var left = materialLeft[loopOf[best]]; + + start = left ? outAngle : inAngle; + width = Angle.NormalizeRad((left ? inAngle : outAngle) - start); + + startConcave = IsConcave(entities[left ? outgoing : incoming], left); + endConcave = IsConcave(entities[left ? incoming : outgoing], left); + + return + width > SlideContact.AngleTolerance + && width < Angle.TwoPI - 2 * SlideContact.SplitOverlap + ? ContactSide.Sector + : ContactSide.Ambiguous; + } + + private int LoopLength(int index) + { + var length = 1; + for (var i = following[index]; i != index; i = following[i]) + length++; + return length; + } + + private bool StartsNear(int index, Vector point) => + TryEndpoints(entities[index], out var start, out _) + && start.DistanceTo(point) <= IncidenceTolerance; + + private bool EndsNear(int index, Vector point) => + TryEndpoints(entities[index], out _, out var end) + && end.DistanceTo(point) <= IncidenceTolerance; + + private bool InRun(int index, int first, int last) + { + for (var i = first; ; i = following[i]) + { + if (i == index) + return true; + if (i == last) + return false; + } + } + + private static bool IsZero(Vector v) => v.X == 0 && v.Y == 0; + + private static bool IsConcave(Entity entity, bool materialLeft) + { + // A CCW curve has its center on its left; that center is on the free side + // (a concave boundary) exactly when material is on the right. + return entity switch + { + Arc arc => materialLeft == arc.IsReversed, + Circle circle => materialLeft == (circle.Rotation == RotationType.CW), + _ => false, + }; + } + + private static Vector StartTangent(Entity entity) => + entity switch + { + Line line => Direction(line.pt1, line.pt2), + Arc arc => ArcTangent(arc.StartAngle, arc.IsReversed), + _ => new Vector(), + }; + + private static Vector EndTangent(Entity entity) => + entity switch + { + Line line => Direction(line.pt1, line.pt2), + Arc arc => ArcTangent(arc.EndAngle, arc.IsReversed), + _ => new Vector(), + }; + + private static Vector TangentAt(Entity entity, Vector point) => + entity switch + { + Line line => Direction(line.pt1, line.pt2), + Arc arc => ArcTangent(arc.Center.AngleTo(point), arc.IsReversed), + Circle circle => ArcTangent( + circle.Center.AngleTo(point), + circle.Rotation == RotationType.CW + ), + _ => new Vector(), + }; + + private static Vector ArcTangent(double angle, bool clockwise) + { + var sign = clockwise ? -1.0 : 1.0; + return new Vector(-System.Math.Sin(angle) * sign, System.Math.Cos(angle) * sign); + } + + private static Vector Direction(Vector from, Vector to) + { + var dx = to.X - from.X; + var dy = to.Y - from.Y; + var length = System.Math.Sqrt(dx * dx + dy * dy); + return length > 0 ? new Vector(dx / length, dy / length) : new Vector(); + } + + private static double DistanceTo(Entity entity, Vector point) + { + switch (entity) + { + case Line line: + return point.DistanceTo(line.ClosestPointTo(point)); + case Arc arc: + { + var angle = arc.Center.AngleTo(point); + if (Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed)) + return System.Math.Abs(arc.Center.DistanceTo(point) - arc.Radius); + return System.Math.Min( + point.DistanceTo(arc.StartPoint()), + point.DistanceTo(arc.EndPoint()) + ); + } + case Circle circle: + return System.Math.Abs(circle.Center.DistanceTo(point) - circle.Radius); + default: + return double.MaxValue; + } + } + + private static bool TryEndpoints(Entity entity, out Vector start, out Vector end) + { + switch (entity) + { + case Line line: + start = line.pt1; + end = line.pt2; + return true; + case Arc arc: + start = arc.StartPoint(); + end = arc.EndPoint(); + return true; + default: + start = end = new Vector(); + return false; + } + } + + private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= IncidenceTolerance; + + private static double SignedArea(List entities, int first, int last) + { + var area = 0.0; + for (var i = first; i <= last; i++) + { + switch (entities[i]) + { + case Circle circle: + var sign = circle.Rotation == RotationType.CW ? -1 : 1; + area += sign * System.Math.PI * circle.Radius * circle.Radius; + break; + case Line line: + area += Cross(line.pt1, line.pt2) / 2; + break; + case Arc arc: + var sweep = arc.IsReversed ? -arc.SweepAngle() : arc.SweepAngle(); + var r = arc.Radius; + area += Cross(arc.StartPoint(), arc.EndPoint()) / 2; + area += r * r / 2 * (sweep - System.Math.Sin(sweep)); + break; + } + } + return area; + } + + private static double Cross(Vector a, Vector b) => a.X * b.Y - b.X * a.Y; + + private static Vector SamplePoint(Entity entity) => + entity switch + { + Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y), + Arc arc => arc.StartPoint(), + Line line => line.pt1, + _ => new Vector(), + }; + + // Exact horizontal-ray parity. Split arcs at Y extrema so every piece is + // monotone; the same half-open endpoint rule as lines avoids seam double counts. + // A coarse inscribed polygon can misclassify thin rings as solid material. + private static bool Contains(List entities, int first, int last, Vector point) + { + var inside = false; + for (var i = first; i <= last; i++) + { + if (entities[i] is Circle circle) + return circle.Center.DistanceTo(point) < circle.Radius; + if (entities[i] is Line line) + { + var a = line.pt1; + var b = line.pt2; + if ((a.Y > point.Y) != (b.Y > point.Y) + && point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X) + inside = !inside; + } + else if (entities[i] is Arc arc) + { + var sweep = arc.SweepAngle(); + var sign = arc.IsReversed ? -1.0 : 1.0; + var cuts = new List { 0, sweep }; + foreach (var extreme in new[] { Angle.HalfPI, 3 * Angle.HalfPI }) + { + var t = Angle.NormalizeRad(sign * (extreme - arc.StartAngle)); + if (t > 0 && t < sweep) + cuts.Add(t); + } + cuts.Sort(); + for (var k = 1; k < cuts.Count; k++) + { + var a = arc.StartAngle + sign * cuts[k - 1]; + var b = arc.StartAngle + sign * cuts[k]; + var y1 = arc.Center.Y + arc.Radius * System.Math.Sin(a); + var y2 = arc.Center.Y + arc.Radius * System.Math.Sin(b); + if ((y1 > point.Y) == (y2 > point.Y)) + continue; + var dy = point.Y - arc.Center.Y; + var dx = System.Math.Sqrt(System.Math.Max(0, arc.Radius * arc.Radius - dy * dy)); + var x = arc.Center.X + (System.Math.Cos((a + b) / 2) >= 0 ? dx : -dx); + if (point.X < x) + inside = !inside; + } + } + } + return inside; + } + } + + /// + /// Contact classifier for one moving/stationary pair of boundaries. Geometry is prepared + /// on first use, so a slide whose nearest contact is never classified pays nothing; call + /// before sharing one instance across threads. Each boundary is + /// given in its own frame; the origins place those frames in the world coordinates used + /// by slide events. + /// + public sealed class SlideContactClassifier + { + private readonly System.Func> movingSource; + private readonly System.Func> stationarySource; + private SlideContactGeometry moving; + private SlideContactGeometry stationary; + + public SlideContactClassifier(List movingEntities, List stationaryEntities) + : this(movingEntities, Vector.Zero, stationaryEntities, Vector.Zero) { } + + public SlideContactClassifier( + List movingEntities, + Vector movingOrigin, + List stationaryEntities, + Vector stationaryOrigin + ) + : this(() => movingEntities, movingOrigin, () => stationaryEntities, stationaryOrigin) + { } + + public SlideContactClassifier( + SlideContactGeometry moving, + Vector movingOrigin, + SlideContactGeometry stationary, + Vector stationaryOrigin + ) + { + this.moving = moving; + this.stationary = stationary; + MovingOrigin = movingOrigin; + StationaryOrigin = stationaryOrigin; + } + + private SlideContactClassifier( + System.Func> movingSource, + Vector movingOrigin, + System.Func> stationarySource, + Vector stationaryOrigin + ) + { + this.movingSource = movingSource; + this.stationarySource = stationarySource; + MovingOrigin = movingOrigin; + StationaryOrigin = stationaryOrigin; + } + + public Vector MovingOrigin { get; } + + public Vector StationaryOrigin { get; } + + public static SlideContactClassifier FromLines( + List movingLines, + Vector movingOrigin, + List stationaryLines, + Vector stationaryOrigin + ) => + new SlideContactClassifier( + () => new List(movingLines), + movingOrigin, + () => new List(stationaryLines), + stationaryOrigin + ); + + public static SlideContactClassifier FromEdges( + (Vector start, Vector end)[] movingEdges, + Vector movingOrigin, + (Vector start, Vector end)[] stationaryEdges, + Vector stationaryOrigin + ) + { + // The kernel sorts edge arrays in place, so snapshot the chain order now. + var moving = ((Vector start, Vector end)[])movingEdges.Clone(); + var stationary = ((Vector start, Vector end)[])stationaryEdges.Clone(); + return new SlideContactClassifier( + () => ToLines(moving), + movingOrigin, + () => ToLines(stationary), + stationaryOrigin + ); + } + + private static List ToLines((Vector start, Vector end)[] edges) + { + var lines = new List(edges.Length); + foreach (var (start, end) in edges) + lines.Add(new Line(start, end)); + // Public edge arrays are sorted in place by previous queries. Recover their + // chains on private line objects; never reverse or reorder caller geometry. + var ordered = new List(lines.Count); + foreach (var shape in ShapeBuilder.GetShapes(lines)) + ordered.AddRange(shape.Entities); + return ordered; + } + + public SlideContactClassifier Prepare() + { + moving ??= SlideContactGeometry.Prepare(movingSource?.Invoke() ?? new List()); + stationary ??= SlideContactGeometry.Prepare( + stationarySource?.Invoke() ?? new List() + ); + return this; + } + + /// The same prepared boundaries placed at other origins. + public SlideContactClassifier At(Vector movingOrigin, Vector stationaryOrigin) + { + Prepare(); + return new SlideContactClassifier(moving, movingOrigin, stationary, stationaryOrigin); + } + + /// + /// True when moving along (dirX, dirY) from this world-space contact would push + /// material into material, or the contact cannot be classified. + /// + public bool Blocks(Vector movingPoint, Vector stationaryPoint, double dirX, double dirY) + { + Prepare(); + return SlideContact.Blocks( + moving, + movingPoint - MovingOrigin, + stationary, + stationaryPoint - StationaryOrigin, + dirX, + dirY + ); + } + } + + /// Receives candidate contact events from a directional slide query. + public interface ISlideEventSink + { + /// True once further events cannot change this sink's result. + bool IsDone { get; } + + /// Travel to the contact, snapped to zero within Tolerance.Epsilon. + /// Contact on the moving boundary, at its start position. + /// Contact on the stationary boundary. + void Add(double distance, Vector movingPoint, Vector stationaryPoint); + } + + /// + /// Enumerates every candidate contact of one slide. Must yield the same events each + /// time it is enumerated. + /// + public interface ISlideEventSource + { + void Enumerate(ref TSink sink) + where TSink : struct, ISlideEventSink; + } + + /// Keeps the nearest event; stops at a contact that is already touching. + public struct NearestSlideEvent : ISlideEventSink + { + public bool Found; + public double Distance; + public Vector MovingPoint; + public Vector StationaryPoint; + + public bool IsDone => Found && Distance <= 0; + + public void Add(double distance, Vector movingPoint, Vector stationaryPoint) + { + if (Found && distance >= Distance) + return; + + Found = true; + Distance = distance; + MovingPoint = movingPoint; + StationaryPoint = stationaryPoint; + } + } + + internal struct SlideEventList : ISlideEventSink + { + public List<(double Distance, Vector MovingPoint, Vector StationaryPoint)> Events; + + public bool IsDone => false; + + public void Add(double distance, Vector movingPoint, Vector stationaryPoint) => + Events.Add((distance, movingPoint, stationaryPoint)); + } + + public static class SlideResolver + { + /// + /// Travel to the first contact that blocks the slide, or double.MaxValue. When the + /// nearest contact blocks (every contact, for unresolved boundaries), the result is + /// exactly the nearest event distance and the events are enumerated once. + /// + public static double FirstBlocking( + ref TSource source, + SlideContactClassifier contacts, + double dirX, + double dirY + ) + where TSource : struct, ISlideEventSource + { + var nearest = new NearestSlideEvent(); + source.Enumerate(ref nearest); + + if (!nearest.Found) + return double.MaxValue; + + if (contacts.Blocks(nearest.MovingPoint, nearest.StationaryPoint, dirX, dirY)) + return nearest.Distance; + + var all = new SlideEventList + { + Events = new List<(double, Vector, Vector)>(), + }; + source.Enumerate(ref all); + all.Events.Sort((a, b) => a.Distance.CompareTo(b.Distance)); + + foreach (var (distance, movingPoint, stationaryPoint) in all.Events) + { + if (contacts.Blocks(movingPoint, stationaryPoint, dirX, dirY)) + return distance; + } + + return double.MaxValue; + } + } + + /// + /// Decides whether a first-contact event found by a directional slide stops the slide. + /// + /// + /// Parts that already touch may slide along each other or apart. Only a direction that + /// would create positive-area overlap blocks: with S the stationary material sector and + /// M the moving one at the contact point, that is the open Minkowski cone S ⊕ −M. + /// A direction on that cone's boundary is a tangential slide; it blocks only when an + /// incident curve is concave, because the second-order bend then closes the gap. + /// Unresolved or ambiguous topology blocks, which is the previous behavior for every + /// contact. + /// + public static class SlideContact + { + internal const double AngleTolerance = 1e-7; + + // Reflex sectors are split into two overlapping convex halves; the overlap keeps + // the split ray in the interior of the union. + internal const double SplitOverlap = 1e-3; + + /// + /// True when moving along (dirX, dirY) from this contact would push material into + /// material, or when the contact cannot be classified. False for a near-miss whose + /// point is not on both boundaries. + /// + /// Contact point in the moving entities' own frame. + /// The same contact in the stationary frame. + public static bool Blocks( + SlideContactGeometry moving, + Vector movingPoint, + SlideContactGeometry stationary, + Vector stationaryPoint, + double dirX, + double dirY + ) + { + if (moving == null || stationary == null) + return true; + + var stationarySide = stationary.GetMaterialSector( + stationaryPoint, + out var stationaryStart, + out var stationaryWidth, + out var stationaryStartConcave, + out var stationaryEndConcave + ); + var movingSide = moving.GetMaterialSector( + movingPoint, + out var movingStart, + out var movingWidth, + out var movingStartConcave, + out var movingEndConcave + ); + + if (stationarySide == ContactSide.Unresolved || movingSide == ContactSide.Unresolved) + return true; + + // Ray tolerances report hits slightly beyond an entity's end; such a point is + // not on the other boundary, so the parts pass without touching there. + if (stationarySide == ContactSide.Off || movingSide == ContactSide.Off) + return false; + + if (stationarySide == ContactSide.Ambiguous || movingSide == ContactSide.Ambiguous) + return true; + + var direction = System.Math.Atan2(dirY, dirX); + var stationaryPieces = Split(stationaryStart, stationaryWidth); + var movingPieces = Split(movingStart + System.Math.PI, movingWidth); + var onBoundary = false; + + foreach (var s in stationaryPieces) + { + foreach (var m in movingPieces) + { + if (!TryHull(s, m, out var hullStart, out var hullWidth)) + return true; + + var offset = Angle.NormalizeRad(direction - hullStart); + if (offset > AngleTolerance && offset < hullWidth - AngleTolerance) + return true; + + if ( + offset <= AngleTolerance + || offset >= Angle.TwoPI - AngleTolerance + || System.Math.Abs(offset - hullWidth) <= AngleTolerance + ) + onBoundary = true; + } + } + + return onBoundary && ( + stationaryStartConcave && SameRay(direction, stationaryStart) + || stationaryEndConcave && SameRay(direction, stationaryStart + stationaryWidth) + || movingStartConcave && SameRay(direction, movingStart + System.Math.PI) + || movingEndConcave && SameRay(direction, movingStart + movingWidth + System.Math.PI)); + } + + private static bool SameRay(double a, double b) + { + var offset = Angle.NormalizeRad(a - b); + return offset <= AngleTolerance || offset >= Angle.TwoPI - AngleTolerance; + } + + private static (double Start, double Width)[] Split(double start, double width) + { + if (width <= System.Math.PI + AngleTolerance) + return new[] { (start, width) }; + + var half = width / 2; + return new[] + { + (start, half + SplitOverlap), + (start + half - SplitOverlap, half + SplitOverlap), + }; + } + + /// + /// Convex cone generated by two convex sectors. False when it is the whole plane. + /// + private static bool TryHull( + (double Start, double Width) a, + (double Start, double Width) b, + out double start, + out double width + ) + { + var fromA = System.Math.Max(a.Width, Angle.NormalizeRad(b.Start - a.Start) + b.Width); + var fromB = System.Math.Max(b.Width, Angle.NormalizeRad(a.Start - b.Start) + a.Width); + + if (fromA <= fromB) + { + start = a.Start; + width = fromA; + } + else + { + start = b.Start; + width = fromB; + } + + return width <= System.Math.PI + AngleTolerance; + } + } +} diff --git a/OpenNest.Core/Geometry/SlideEvents.cs b/OpenNest.Core/Geometry/SlideEvents.cs new file mode 100644 index 0000000..509245e --- /dev/null +++ b/OpenNest.Core/Geometry/SlideEvents.cs @@ -0,0 +1,806 @@ +using System.Collections.Generic; +using OpenNest.Math; + +namespace OpenNest.Geometry +{ + /// + /// Candidate contact events of directional slides. Each emitter reports every forward + /// hit its distance kernel considers, with the distance snapped exactly as that kernel + /// snaps it, so the nearest event equals the kernel's historical minimum. + /// + internal static class SlideEvents + { + private const double Eps = Tolerance.Epsilon; + + private static double Snap(double t) => t > Eps ? t : 0; + + /// + /// Ray from a vertex against one entity. When is true + /// the vertex belongs to the moving boundary and the ray follows the push direction; + /// otherwise it is a stationary vertex and the ray runs opposite to the push. + /// + public static void Ray( + ref TSink sink, + double vx, + double vy, + Entity entity, + double entityDx, + double entityDy, + double rayX, + double rayY, + bool vertexMoves + ) + where TSink : struct, ISlideEventSink + { + switch (entity) + { + case Line line: + RayLine( + ref sink, + vx, + vy, + line.pt1.X + entityDx, + line.pt1.Y + entityDy, + line.pt2.X + entityDx, + line.pt2.Y + entityDy, + rayX, + rayY, + vertexMoves + ); + break; + + case Arc arc: + { + var cx = arc.Center.X + entityDx; + var cy = arc.Center.Y + entityDy; + if (!SolveRayCircle(vx, vy, cx, cy, arc.Radius, rayX, rayY, out var t1, out var t2)) + return; + + for (var k = 0; k < 2; k++) + { + var t = k == 0 ? t1 : t2; + if (t <= -Eps) + continue; + + var hitAngle = Angle.NormalizeRad( + System.Math.Atan2(vy + t * rayY - cy, vx + t * rayX - cx) + ); + if (!Angle.IsBetweenRad(hitAngle, arc.StartAngle, arc.EndAngle, arc.IsReversed)) + continue; + + Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves); + if (sink.IsDone) + return; + } + break; + } + + case Circle circle: + { + if ( + !SolveRayCircle( + vx, + vy, + circle.Center.X + entityDx, + circle.Center.Y + entityDy, + circle.Radius, + rayX, + rayY, + out var t1, + out var t2 + ) + ) + return; + + for (var k = 0; k < 2; k++) + { + var t = k == 0 ? t1 : t2; + if (t < -Eps) + continue; + + Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves); + if (sink.IsDone) + return; + } + break; + } + } + } + + /// Same hit rule as . + public static void RayLine( + ref TSink sink, + double vx, + double vy, + double p1x, + double p1y, + double p2x, + double p2y, + double rayX, + double rayY, + bool vertexMoves + ) + where TSink : struct, ISlideEventSink + { + var ex = p2x - p1x; + var ey = p2y - p1y; + + var det = ex * rayY - ey * rayX; + if (System.Math.Abs(det) < Eps) + return; + + var dvx = p1x - vx; + var dvy = p1y - vy; + + var t = (ex * dvy - ey * dvx) / det; + if (t < -Eps) + return; + + var s = (rayX * dvy - rayY * dvx) / det; + if (s < -Eps || s > 1.0 + Eps) + return; + + Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves); + } + + /// + /// Axis-aligned ray against a segment, with the same hit rule as the + /// kernel. + /// + public static void AxisRayLine( + ref TSink sink, + double vx, + double vy, + double p1x, + double p1y, + double p2x, + double p2y, + PushDirection rayDirection, + bool vertexMoves + ) + where TSink : struct, ISlideEventSink + { + double dist, + hx, + hy; + + switch (rayDirection) + { + case PushDirection.Left: + case PushDirection.Right: + { + var dy = p2y - p1y; + if (System.Math.Abs(dy) < Eps) + return; + + var t = (vy - p1y) / dy; + if (t < -Eps || t > 1.0 + Eps) + return; + + hx = p1x + t * (p2x - p1x); + hy = vy; + dist = rayDirection == PushDirection.Left ? vx - hx : hx - vx; + break; + } + + case PushDirection.Down: + case PushDirection.Up: + { + var dx = p2x - p1x; + if (System.Math.Abs(dx) < Eps) + return; + + var t = (vx - p1x) / dx; + if (t < -Eps || t > 1.0 + Eps) + return; + + hx = vx; + hy = p1y + t * (p2y - p1y); + dist = rayDirection == PushDirection.Down ? vy - hy : hy - vy; + break; + } + + default: + return; + } + + if (dist < -Eps) + return; + + var vertex = new Vector(vx, vy); + var hit = new Vector(hx, hy); + if (vertexMoves) + sink.Add(Snap(dist), vertex, hit); + else + sink.Add(Snap(dist), hit, vertex); + } + + /// + /// Closest-approach points of arcs against lines, which vertex sampling can miss. + /// + public static void ArcToLine( + ref TSink sink, + List arcEntities, + double arcDx, + double arcDy, + List lineEntities, + double lineDx, + double lineDy, + double rayX, + double rayY, + bool arcMoves + ) + where TSink : struct, ISlideEventSink + { + for (var i = 0; i < arcEntities.Count; i++) + { + if (!TryGetCurve(arcEntities[i], out var localCx, out var localCy, out var r)) + continue; + + var arc = arcEntities[i] as Arc; + var cx = localCx + arcDx; + var cy = localCy + arcDy; + + for (var j = 0; j < lineEntities.Count; j++) + { + if (lineEntities[j] is not Line line) + continue; + + var p1x = line.pt1.X + lineDx; + var p1y = line.pt1.Y + lineDy; + var p2x = line.pt2.X + lineDx; + var p2y = line.pt2.Y + lineDy; + var ex = p2x - p1x; + var ey = p2y - p1y; + + var det = ex * rayY - ey * rayX; + if (System.Math.Abs(det) < Eps) + continue; + + // The directional distance from an arc point at angle θ to the + // line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det. + // dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π. + var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey)); + var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI); + + for (var k = 0; k < 2; k++) + { + var theta = k == 0 ? theta1 : theta2; + + if (arc != null && !Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed)) + continue; + + var qx = cx + r * System.Math.Cos(theta); + var qy = cy + r * System.Math.Sin(theta); + + RayLine(ref sink, qx, qy, p1x, p1y, p2x, p2y, rayX, rayY, arcMoves); + if (sink.IsDone) + return; + } + } + } + } + + /// + /// External and internal tangencies of two curves along a unit direction. Radii must + /// be nonnegative; a null arc is a full circle. + /// + public static void CurveTangency( + ref TSink sink, + double movingCx, + double movingCy, + double movingRadius, + Arc movingArc, + double stationaryCx, + double stationaryCy, + double stationaryRadius, + Arc stationaryArc, + double dirX, + double dirY + ) + where TSink : struct, ISlideEventSink + { + for (var kind = 0; kind < 2; kind++) + { + var internalContact = kind == 1; + var radius = internalContact + ? System.Math.Abs(movingRadius - stationaryRadius) + : movingRadius + stationaryRadius; + + // Equal-radius internal contact has coincident centers, not a unique + // tangent point. Endpoints detect any overlap of those angular spans. + if (radius == 0) + continue; + + if ( + !SolveRayCircle( + movingCx, + movingCy, + stationaryCx, + stationaryCy, + radius, + dirX, + dirY, + out var t1, + out var t2 + ) + ) + continue; + + // The nearer center-circle root can be outside an arc while the farther + // root is its first contact. Check the actual tangent point at BOTH roots. + for (var root = 0; root < 2; root++) + { + var t = root == 0 ? t1 : t2; + if (t < -Eps) + continue; + + var toX = stationaryCx - (movingCx + t * dirX); + var toY = stationaryCy - (movingCy + t * dirY); + var movingSign = internalContact && movingRadius < stationaryRadius ? -1 : 1; + var stationarySign = internalContact ? movingSign : -1; + if ( + !ContainsContactAngle( + movingArc, + movingRadius, + movingSign * toX, + movingSign * toY + ) + || !ContainsContactAngle( + stationaryArc, + stationaryRadius, + stationarySign * toX, + stationarySign * toY + ) + ) + continue; + + var length = System.Math.Sqrt(toX * toX + toY * toY); + var ux = length > 0 ? toX / length : 0; + var uy = length > 0 ? toY / length : 0; + var movingPoint = new Vector( + movingCx + movingSign * movingRadius * ux, + movingCy + movingSign * movingRadius * uy + ); + var stationaryPoint = new Vector( + stationaryCx + stationarySign * stationaryRadius * ux, + stationaryCy + stationarySign * stationaryRadius * uy + ); + + sink.Add(Snap(t), movingPoint, stationaryPoint); + if (sink.IsDone) + return; + } + } + } + + public static bool TryGetCurve(Entity entity, out double cx, out double cy, out double r) + { + switch (entity) + { + case Circle circle: + cx = circle.Center.X; + cy = circle.Center.Y; + r = circle.Radius; + return true; + case Arc arc: + cx = arc.Center.X; + cy = arc.Center.Y; + r = arc.Radius; + return true; + default: + cx = cy = r = 0; + return false; + } + } + + private static void Emit( + ref TSink sink, + double vx, + double vy, + double t, + double rayX, + double rayY, + bool vertexMoves + ) + where TSink : struct, ISlideEventSink + { + var vertex = new Vector(vx, vy); + var hit = new Vector(vx + t * rayX, vy + t * rayY); + if (vertexMoves) + sink.Add(Snap(t), vertex, hit); + else + sink.Add(Snap(t), hit, vertex); + } + + private static bool ContainsContactAngle(Arc arc, double radius, double x, double y) + { + // A zero-radius curve is a point: its angular range has no geometric meaning. + if (arc == null || radius == 0) + return true; + var angle = Angle.NormalizeRad(System.Math.Atan2(y, x)); + return Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed); + } + + internal static bool SolveRayCircle( + double vx, + double vy, + double cx, + double cy, + double r, + double dirX, + double dirY, + out double t1, + out double t2 + ) + { + var ox = vx - cx; + var oy = vy - cy; + + var a = dirX * dirX + dirY * dirY; + var b = 2.0 * (ox * dirX + oy * dirY); + var c = ox * ox + oy * oy - r * r; + + var discriminant = b * b - 4.0 * a * c; + if (discriminant < 0) + { + t1 = t2 = double.MaxValue; + return false; + } + + var sqrtD = System.Math.Sqrt(discriminant); + var inv2a = 1.0 / (2.0 * a); + t1 = (-b - sqrtD) * inv2a; + t2 = (-b + sqrtD) * inv2a; + return true; + } + } + + /// + /// Slide events between native Line/Arc/Circle boundaries. The moving entities and + /// vertices are translated by (movingDx, movingDy); vertex arrays may be subsets. + /// + public struct EntitySlideEvents : ISlideEventSource + { + private readonly List moving; + private readonly Vector[] movingVertices; + private readonly double movingDx; + private readonly double movingDy; + private readonly List stationary; + private readonly Vector[] stationaryVertices; + private readonly double dirX; + private readonly double dirY; + private readonly bool arcToLine; + + public EntitySlideEvents( + List moving, + Vector[] movingVertices, + double movingDx, + double movingDy, + List stationary, + Vector[] stationaryVertices, + double dirX, + double dirY, + bool arcToLine + ) + { + this.moving = moving; + this.movingVertices = movingVertices; + this.movingDx = movingDx; + this.movingDy = movingDy; + this.stationary = stationary; + this.stationaryVertices = stationaryVertices; + this.dirX = dirX; + this.dirY = dirY; + this.arcToLine = arcToLine; + } + + public void Enumerate(ref TSink sink) + where TSink : struct, ISlideEventSink + { + // Phase 1: moving vertices along the push against stationary entities. + for (var v = 0; v < movingVertices.Length; v++) + { + var vx = movingVertices[v].X + movingDx; + var vy = movingVertices[v].Y + movingDy; + + for (var j = 0; j < stationary.Count; j++) + { + SlideEvents.Ray(ref sink, vx, vy, stationary[j], 0, 0, dirX, dirY, true); + if (sink.IsDone) + return; + } + } + + // Phase 2: stationary vertices against the push onto moving entities. + for (var v = 0; v < stationaryVertices.Length; v++) + { + var vx = stationaryVertices[v].X; + var vy = stationaryVertices[v].Y; + + for (var j = 0; j < moving.Count; j++) + { + SlideEvents.Ray( + ref sink, + vx, + vy, + moving[j], + movingDx, + movingDy, + -dirX, + -dirY, + false + ); + if (sink.IsDone) + return; + } + } + + // Phase 3: arc-to-line closest points, which vertex sampling can miss. + if (arcToLine) + { + SlideEvents.ArcToLine( + ref sink, + moving, + movingDx, + movingDy, + stationary, + 0, + 0, + dirX, + dirY, + true + ); + if (sink.IsDone) + return; + SlideEvents.ArcToLine( + ref sink, + stationary, + 0, + 0, + moving, + movingDx, + movingDy, + -dirX, + -dirY, + false + ); + if (sink.IsDone) + return; + } + + // Phase 4: native curve tangency, including a convex corner inside a concave arc. + for (var i = 0; i < moving.Count; i++) + { + if (!SlideEvents.TryGetCurve(moving[i], out var mcx, out var mcy, out var mr)) + continue; + + for (var j = 0; j < stationary.Count; j++) + { + if (!SlideEvents.TryGetCurve(stationary[j], out var scx, out var scy, out var sr)) + continue; + + SlideEvents.CurveTangency( + ref sink, + mcx + movingDx, + mcy + movingDy, + mr, + moving[i] as Arc, + scx, + scy, + sr, + stationary[j] as Arc, + dirX, + dirY + ); + if (sink.IsDone) + return; + } + } + } + } + + /// + /// Slide events between line boundaries along an arbitrary unit direction. The moving + /// lines and vertices are translated by (movingDx, movingDy); vertex arrays may be subsets. + /// + public struct LineSlideEvents : ISlideEventSource + { + private readonly List moving; + private readonly Vector[] movingVertices; + private readonly double movingDx; + private readonly double movingDy; + private readonly List stationary; + private readonly Vector[] stationaryVertices; + private readonly double dirX; + private readonly double dirY; + + public LineSlideEvents( + List moving, + Vector[] movingVertices, + double movingDx, + double movingDy, + List stationary, + Vector[] stationaryVertices, + double dirX, + double dirY + ) + { + this.moving = moving; + this.movingVertices = movingVertices; + this.movingDx = movingDx; + this.movingDy = movingDy; + this.stationary = stationary; + this.stationaryVertices = stationaryVertices; + this.dirX = dirX; + this.dirY = dirY; + } + + public void Enumerate(ref TSink sink) + where TSink : struct, ISlideEventSink + { + for (var v = 0; v < movingVertices.Length; v++) + { + var vx = movingVertices[v].X + movingDx; + var vy = movingVertices[v].Y + movingDy; + + for (var j = 0; j < stationary.Count; j++) + { + var e = stationary[j]; + SlideEvents.RayLine( + ref sink, + vx, + vy, + e.pt1.X, + e.pt1.Y, + e.pt2.X, + e.pt2.Y, + dirX, + dirY, + true + ); + if (sink.IsDone) + return; + } + } + + for (var v = 0; v < stationaryVertices.Length; v++) + { + var vx = stationaryVertices[v].X; + var vy = stationaryVertices[v].Y; + + for (var j = 0; j < moving.Count; j++) + { + var e = moving[j]; + SlideEvents.RayLine( + ref sink, + vx, + vy, + e.pt1.X + movingDx, + e.pt1.Y + movingDy, + e.pt2.X + movingDx, + e.pt2.Y + movingDy, + -dirX, + -dirY, + false + ); + if (sink.IsDone) + return; + } + } + } + } + + /// + /// Axis-aligned slide events between edge arrays sorted for pruning, as used by the + /// kernel. Offsets translate each side into world space. + /// + public struct AxisSlideEvents : ISlideEventSource + { + private readonly (Vector start, Vector end)[] movingEdges; + private readonly Vector movingOffset; + private readonly Vector[] movingVertices; + private readonly (Vector start, Vector end)[] stationaryEdges; + private readonly Vector stationaryOffset; + private readonly Vector[] stationaryVertices; + private readonly PushDirection direction; + + /// World-space moving vertices. + /// World-space stationary vertices. + public AxisSlideEvents( + (Vector start, Vector end)[] movingEdges, + Vector movingOffset, + Vector[] movingVertices, + (Vector start, Vector end)[] stationaryEdges, + Vector stationaryOffset, + Vector[] stationaryVertices, + PushDirection direction + ) + { + this.movingEdges = movingEdges; + this.movingOffset = movingOffset; + this.movingVertices = movingVertices; + this.stationaryEdges = stationaryEdges; + this.stationaryOffset = stationaryOffset; + this.stationaryVertices = stationaryVertices; + this.direction = direction; + } + + public void Enumerate(ref TSink sink) + where TSink : struct, ISlideEventSink + { + for (var v = 0; v < movingVertices.Length; v++) + { + OneWay(ref sink, movingVertices[v], stationaryEdges, stationaryOffset, direction, true); + if (sink.IsDone) + return; + } + + var opposite = SpatialQuery.OppositeDirection(direction); + for (var v = 0; v < stationaryVertices.Length; v++) + { + OneWay(ref sink, stationaryVertices[v], movingEdges, movingOffset, opposite, false); + if (sink.IsDone) + return; + } + } + + private static void OneWay( + ref TSink sink, + Vector vertex, + (Vector start, Vector end)[] edges, + Vector edgeOffset, + PushDirection rayDirection, + bool vertexMoves + ) + where TSink : struct, ISlideEventSink + { + var vx = vertex.X; + var vy = vertex.Y; + var horizontal = SpatialQuery.IsHorizontalDirection(rayDirection); + + // Edges are sorted by their perpendicular min-coordinate. + for (var i = 0; i < edges.Length; i++) + { + var e1 = edges[i].start + edgeOffset; + var e2 = edges[i].end + edgeOffset; + + double perpValue, + edgeMin, + edgeMax; + if (horizontal) + { + perpValue = vy; + edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y; + edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y; + } + else + { + perpValue = vx; + edgeMin = e1.X < e2.X ? e1.X : e2.X; + edgeMax = e1.X > e2.X ? e1.X : e2.X; + } + + if (perpValue < edgeMin - Tolerance.Epsilon) + break; + + if (perpValue > edgeMax + Tolerance.Epsilon) + continue; + + SlideEvents.AxisRayLine( + ref sink, + vx, + vy, + e1.X, + e1.Y, + e2.X, + e2.Y, + rayDirection, + vertexMoves + ); + if (sink.IsDone) + return; + } + } + } +} diff --git a/OpenNest.Core/Geometry/SpatialQuery.cs b/OpenNest.Core/Geometry/SpatialQuery.cs index 0ad7ad7..27d22b2 100644 --- a/OpenNest.Core/Geometry/SpatialQuery.cs +++ b/OpenNest.Core/Geometry/SpatialQuery.cs @@ -320,8 +320,9 @@ namespace OpenNest.Geometry } /// - /// Computes the minimum translation distance along a push direction before - /// any edge of movingLines contacts any edge of stationaryLines. + /// Computes the translation distance along a push direction before any edge of + /// movingLines first blocks against an edge of stationaryLines. A contact that + /// the push slides along or leaves does not block (see ). /// Returns double.MaxValue if no collision path exists. /// public static double DirectionalDistance( @@ -334,7 +335,7 @@ namespace OpenNest.Geometry } /// - /// Computes the minimum directional distance with the moving lines translated + /// Computes the directional distance with the moving lines translated /// by (movingDx, movingDy) without creating new Line objects. /// public static double DirectionalDistance( @@ -345,37 +346,57 @@ namespace OpenNest.Geometry PushDirection direction ) { - var minDist = double.MaxValue; - var movingOffset = new Vector(movingDx, movingDy); + return DirectionalDistance( + movingLines, + movingDx, + movingDy, + stationaryLines, + direction, + SlideContactClassifier.FromLines( + movingLines, + new Vector(movingDx, movingDy), + stationaryLines, + Vector.Zero + ) + ); + } - // Case 1: Each moving vertex -> each stationary edge - var movingVertices = CollectVertices(movingLines, movingOffset); + /// + /// + /// with caller-supplied contact topology, for inputs that are not complete closed + /// boundaries (for example direction-filtered edges). + /// + public static double DirectionalDistance( + List movingLines, + double movingDx, + double movingDy, + List stationaryLines, + PushDirection direction, + SlideContactClassifier contacts + ) + { + var movingOffset = new Vector(movingDx, movingDy); + var movingVertices = CollectVertices(movingLines, movingOffset).ToArray(); var stationaryEdges = ToEdgeArray(stationaryLines); SortEdgesForPruning(stationaryEdges, direction); - foreach (var mv in movingVertices) - { - var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction); - if (d < minDist) - minDist = d; - } - - // Case 2: Each stationary vertex -> each moving edge (opposite direction) - var opposite = OppositeDirection(direction); - var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero); + var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero).ToArray(); var movingEdges = ToEdgeArray(movingLines); - SortEdgesForPruning(movingEdges, opposite); + SortEdgesForPruning(movingEdges, OppositeDirection(direction)); - foreach (var sv in stationaryVertices) - { - var d = OneWayDistance(sv, movingEdges, movingOffset, opposite); - if (d < minDist) - minDist = d; - } - - return minDist; + var source = new AxisSlideEvents( + movingEdges, + movingOffset, + movingVertices, + stationaryEdges, + Vector.Zero, + stationaryVertices, + direction + ); + var unit = DirectionToOffset(direction, 1.0); + return SlideResolver.FirstBlocking(ref source, contacts, unit.X, unit.Y); } /// @@ -396,8 +417,8 @@ namespace OpenNest.Geometry } /// - /// Computes the minimum directional distance using raw edge arrays and location offsets - /// to avoid all intermediate object allocations. + /// Computes the blocking directional distance using raw edge arrays and location + /// offsets. Sorts both edge arrays in place for pruning. /// public static double DirectionalDistance( (Vector start, Vector end)[] movingEdges, @@ -407,36 +428,58 @@ namespace OpenNest.Geometry PushDirection direction ) { - var minDist = double.MaxValue; - - SortEdgesForPruning(stationaryEdges, direction); - - // Case 1: Each moving vertex -> each stationary edge - var movingVertices = CollectVertices(movingEdges, movingOffset); - - foreach (var mv in movingVertices) - { - var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction); - if (d < minDist) - minDist = d; - } - - // Case 2: Each stationary vertex -> each moving edge (opposite direction) - var opposite = OppositeDirection(direction); - SortEdgesForPruning(movingEdges, opposite); - - var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset); - - foreach (var sv in stationaryVertices) - { - var d = OneWayDistance(sv, movingEdges, movingOffset, opposite); - if (d < minDist) - minDist = d; - } - - return minDist; + return DirectionalDistance( + movingEdges, + movingOffset, + stationaryEdges, + stationaryOffset, + direction, + SlideContactClassifier.FromEdges( + movingEdges, + movingOffset, + stationaryEdges, + stationaryOffset + ) + ); } + /// + /// Edge-array overload with caller-supplied contact topology. The classifier's + /// origins must match and + /// in the frame of its boundaries. + /// + public static double DirectionalDistance( + (Vector start, Vector end)[] movingEdges, + Vector movingOffset, + (Vector start, Vector end)[] stationaryEdges, + Vector stationaryOffset, + PushDirection direction, + SlideContactClassifier contacts + ) + { + SortEdgesForPruning(stationaryEdges, direction); + var movingVertices = CollectVertices(movingEdges, movingOffset).ToArray(); + + SortEdgesForPruning(movingEdges, OppositeDirection(direction)); + var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset).ToArray(); + + var source = new AxisSlideEvents( + movingEdges, + movingOffset, + movingVertices, + stationaryEdges, + stationaryOffset, + stationaryVertices, + direction + ); + var unit = DirectionToOffset(direction, 1.0); + return SlideResolver.FirstBlocking(ref source, contacts, unit.X, unit.Y); + } + + /// + /// Nearest raw hit from one vertex along a push direction against edges sorted for + /// pruning. This is a first-touch primitive; it does not classify sliding contacts. + /// public static double OneWayDistance( Vector vertex, (Vector start, Vector end)[] edges, @@ -628,8 +671,8 @@ namespace OpenNest.Geometry } /// - /// Computes the minimum translation distance along an arbitrary unit direction - /// before any edge of movingLines contacts any edge of stationaryLines. + /// Computes the translation distance along an arbitrary unit direction before any + /// edge of movingLines first blocks against an edge of stationaryLines. /// public static double DirectionalDistance( List movingLines, @@ -637,58 +680,41 @@ namespace OpenNest.Geometry Vector direction ) { - var minDist = double.MaxValue; - var dirX = direction.X; - var dirY = direction.Y; + return DirectionalDistance( + movingLines, + stationaryLines, + direction, + SlideContactClassifier.FromLines( + movingLines, + Vector.Zero, + stationaryLines, + Vector.Zero + ) + ); + } - var movingVertices = CollectVertices(movingLines, Vector.Zero); - - foreach (var mv in movingVertices) - { - for (var i = 0; i < stationaryLines.Count; i++) - { - var e = stationaryLines[i]; - var d = RayEdgeDistance( - mv.X, - mv.Y, - e.pt1.X, - e.pt1.Y, - e.pt2.X, - e.pt2.Y, - dirX, - dirY - ); - if (d < minDist) - minDist = d; - } - } - - var oppX = -dirX; - var oppY = -dirY; - - var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero); - - foreach (var sv in stationaryVertices) - { - for (var i = 0; i < movingLines.Count; i++) - { - var e = movingLines[i]; - var d = RayEdgeDistance( - sv.X, - sv.Y, - e.pt1.X, - e.pt1.Y, - e.pt2.X, - e.pt2.Y, - oppX, - oppY - ); - if (d < minDist) - minDist = d; - } - } - - return minDist; + /// + /// with + /// caller-supplied contact topology. + /// + public static double DirectionalDistance( + List movingLines, + List stationaryLines, + Vector direction, + SlideContactClassifier contacts + ) + { + var source = new LineSlideEvents( + movingLines, + CollectVertices(movingLines, Vector.Zero).ToArray(), + 0, + 0, + stationaryLines, + CollectVertices(stationaryLines, Vector.Zero).ToArray(), + direction.X, + direction.Y + ); + return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y); } /// @@ -710,10 +736,10 @@ namespace OpenNest.Geometry } /// - /// Computes the minimum translation distance along an arbitrary unit direction - /// before any vertex/edge of movingEntities contacts any vertex/edge of - /// stationaryEntities. Works with native Line, Arc, and Circle entities - /// without tessellation. + /// Computes the translation distance along an arbitrary unit direction before any + /// vertex/edge of movingEntities first blocks against stationaryEntities. Works with + /// native Line, Arc, and Circle entities without tessellation. A contact that the + /// push slides along or leaves does not block (see ). /// public static double DirectionalDistance( List movingEntities, @@ -721,228 +747,42 @@ namespace OpenNest.Geometry Vector direction ) { - var minDist = double.MaxValue; - var dirX = direction.X; - var dirY = direction.Y; - - var movingVertices = ExtractEntityVertices(movingEntities); - - for (var v = 0; v < movingVertices.Length; v++) - { - var vx = movingVertices[v].X; - var vy = movingVertices[v].Y; - - for (var j = 0; j < stationaryEntities.Count; j++) - { - var d = RayEntityDistance(vx, vy, stationaryEntities[j], dirX, dirY); - if (d < minDist) - { - minDist = d; - if (d <= 0) - return 0; - } - } - } - - var oppX = -dirX; - var oppY = -dirY; - - var stationaryVertices = ExtractEntityVertices(stationaryEntities); - - for (var v = 0; v < stationaryVertices.Length; v++) - { - var vx = stationaryVertices[v].X; - var vy = stationaryVertices[v].Y; - - for (var j = 0; j < movingEntities.Count; j++) - { - var d = RayEntityDistance(vx, vy, movingEntities[j], oppX, oppY); - if (d < minDist) - { - minDist = d; - if (d <= 0) - return 0; - } - } - } - - // Phase 3: Arc-to-line closest-point check. - // Phases 1-2 sample arc endpoints and cardinal extremes, but the actual - // closest point on a small corner arc to a straight edge may lie between - // those samples. Use ClosestPointTo to find it and fire a ray from there. - minDist = ArcToLineClosestDistance( + return DirectionalDistance( movingEntities, stationaryEntities, - dirX, - dirY, - minDist + direction, + new SlideContactClassifier(movingEntities, stationaryEntities) ); - if (minDist <= 0) - return 0; - minDist = ArcToLineClosestDistance( - stationaryEntities, + } + + /// + /// with + /// caller-supplied contact topology. + /// + public static double DirectionalDistance( + List movingEntities, + List stationaryEntities, + Vector direction, + SlideContactClassifier contacts + ) + { + // Phases: vertex rays both ways, arc-to-line closest points (vertex sampling + // misses interior arc contact), then native curve tangency. + var source = new EntitySlideEvents( movingEntities, - oppX, - oppY, - minDist + ExtractEntityVertices(movingEntities), + 0, + 0, + stationaryEntities, + ExtractEntityVertices(stationaryEntities), + direction.X, + direction.Y, + arcToLine: true ); - if (minDist <= 0) - return 0; - - // Phase 4: Native curve tangency, including a convex corner inside a concave arc. - for (var i = 0; i < movingEntities.Count; i++) - { - var me = movingEntities[i]; - if (!TryGetCurveParams(me, out var mcx, out var mcy, out var mr)) - continue; - - for (var j = 0; j < stationaryEntities.Count; j++) - { - var se = stationaryEntities[j]; - if (!TryGetCurveParams(se, out var scx, out var scy, out var sr)) - continue; - - var d = CurveTangencyDistance( - mcx, mcy, mr, me as Arc, - scx, scy, sr, se as Arc, dirX, dirY); - if (d >= minDist) - continue; - - minDist = d; - if (d <= 0) - return 0; - } - } - - return minDist; + return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y); } - private static double ArcToLineClosestDistance( - List arcEntities, - List lineEntities, - double dirX, - double dirY, - double minDist - ) - { - for (var i = 0; i < arcEntities.Count; i++) - { - if (arcEntities[i] is not Arc arc) - continue; - - var cx = arc.Center.X; - var cy = arc.Center.Y; - var r = arc.Radius; - - for (var j = 0; j < lineEntities.Count; j++) - { - if (lineEntities[j] is not Line line) - continue; - - var p1x = line.pt1.X; - var p1y = line.pt1.Y; - var ex = line.pt2.X - p1x; - var ey = line.pt2.Y - p1y; - - var det = ex * dirY - ey * dirX; - if (System.Math.Abs(det) < Tolerance.Epsilon) - continue; - - // The directional distance from an arc point at angle θ to the - // line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det. - // dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π. - var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey)); - var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI); - - for (var k = 0; k < 2; k++) - { - var theta = k == 0 ? theta1 : theta2; - - if ( - !Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed) - ) - continue; - - var qx = cx + r * System.Math.Cos(theta); - var qy = cy + r * System.Math.Sin(theta); - - var d = RayEdgeDistance( - qx, - qy, - p1x, - p1y, - line.pt2.X, - line.pt2.Y, - dirX, - dirY - ); - if (d < minDist) - { - minDist = d; - if (d <= 0) - return 0; - } - } - } - } - return minDist; - } - - private static double RayEntityDistance( - double vx, - double vy, - Entity entity, - double dirX, - double dirY - ) - { - if (entity is Line line) - { - return RayEdgeDistance( - vx, - vy, - line.pt1.X, - line.pt1.Y, - line.pt2.X, - line.pt2.Y, - dirX, - dirY - ); - } - - if (entity is Arc arc) - { - return RayArcDistance( - vx, - vy, - arc.Center.X, - arc.Center.Y, - arc.Radius, - arc.StartAngle, - arc.EndAngle, - arc.IsReversed, - dirX, - dirY - ); - } - - if (entity is Circle circle) - { - return RayCircleDistance( - vx, - vy, - circle.Center.X, - circle.Center.Y, - circle.Radius, - dirX, - dirY - ); - } - - return double.MaxValue; - } - - private static Vector[] ExtractEntityVertices(List entities) + public static Vector[] ExtractEntityVertices(List entities) { var vertices = new HashSet(); @@ -1041,31 +881,6 @@ namespace OpenNest.Geometry ); } - private static bool TryGetCurveParams( - Entity entity, - out double cx, - out double cy, - out double r - ) - { - if (entity is Circle circle) - { - cx = circle.Center.X; - cy = circle.Center.Y; - r = circle.Radius; - return true; - } - if (entity is Arc arc) - { - cx = arc.Center.X; - cy = arc.Center.Y; - r = arc.Radius; - return true; - } - cx = cy = r = 0; - return false; - } - private static double BoxProjectionMin(Box box, double dx, double dy) { var x = dx >= 0 ? box.Left : box.Right; diff --git a/OpenNest.Engine/BestFit/CpuDistanceComputer.cs b/OpenNest.Engine/BestFit/CpuDistanceComputer.cs index 21339fb..be9fa49 100644 --- a/OpenNest.Engine/BestFit/CpuDistanceComputer.cs +++ b/OpenNest.Engine/BestFit/CpuDistanceComputer.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Linq; using OpenNest.Geometry; -using OpenNest.Math; namespace OpenNest.Engine.BestFit { @@ -13,117 +12,21 @@ namespace OpenNest.Engine.BestFit SlideOffset[] offsets ) { - var count = offsets.Length; - var results = new double[count]; + var results = new double[offsets.Length]; + var movingVertices = Vertices(movingTemplateLines); + var stationaryVertices = Vertices(stationaryLines); + var contacts = SlideContactClassifier.FromLines( + movingTemplateLines, Vector.Zero, stationaryLines, Vector.Zero).Prepare(); - var allMovingVerts = ExtractUniqueVertices(movingTemplateLines); - var allStationaryVerts = ExtractUniqueVertices(stationaryLines); - - var vertexCache = - new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>(); - - foreach (var offset in offsets) + System.Threading.Tasks.Parallel.For(0, offsets.Length, i => { - var key = (offset.DirX, offset.DirY); - if (vertexCache.ContainsKey(key)) - continue; - - var leading = FilterVerticesByProjection( - allMovingVerts, - offset.DirX, - offset.DirY, - keepHigh: true - ); - var facing = FilterVerticesByProjection( - allStationaryVerts, - offset.DirX, - offset.DirY, - keepHigh: false - ); - vertexCache[key] = (leading, facing); - } - - System.Threading.Tasks.Parallel.For( - 0, - count, - i => - { - var offset = offsets[i]; - var dirX = offset.DirX; - var dirY = offset.DirY; - var oppX = -dirX; - var oppY = -dirY; - - var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)]; - - var minDist = double.MaxValue; - - for (var v = 0; v < leadingMoving.Length; v++) - { - var vx = leadingMoving[v].X + offset.Dx; - var vy = leadingMoving[v].Y + offset.Dy; - - for (var j = 0; j < stationaryLines.Count; j++) - { - var e = stationaryLines[j]; - var d = SpatialQuery.RayEdgeDistance( - vx, - vy, - e.StartPoint.X, - e.StartPoint.Y, - e.EndPoint.X, - e.EndPoint.Y, - dirX, - dirY - ); - - if (d < minDist) - { - minDist = d; - if (d <= 0) - { - results[i] = 0; - return; - } - } - } - } - - for (var v = 0; v < facingStationary.Length; v++) - { - var svx = facingStationary[v].X; - var svy = facingStationary[v].Y; - - for (var j = 0; j < movingTemplateLines.Count; j++) - { - var e = movingTemplateLines[j]; - var d = SpatialQuery.RayEdgeDistance( - svx, - svy, - e.StartPoint.X + offset.Dx, - e.StartPoint.Y + offset.Dy, - e.EndPoint.X + offset.Dx, - e.EndPoint.Y + offset.Dy, - oppX, - oppY - ); - - if (d < minDist) - { - minDist = d; - if (d <= 0) - { - results[i] = 0; - return; - } - } - } - } - - results[i] = minDist; - } - ); - + var offset = offsets[i]; + var source = new LineSlideEvents( + movingTemplateLines, movingVertices, offset.Dx, offset.Dy, + stationaryLines, stationaryVertices, offset.DirX, offset.DirY); + results[i] = SlideResolver.FirstBlocking(ref source, + contacts.At(new Vector(offset.Dx, offset.Dy), Vector.Zero), offset.DirX, offset.DirY); + }); return results; } @@ -133,347 +36,26 @@ namespace OpenNest.Engine.BestFit SlideOffset[] offsets ) { - var count = offsets.Length; - var results = new double[count]; + var results = new double[offsets.Length]; + var movingVertices = SpatialQuery.ExtractEntityVertices(movingEntities); + var stationaryVertices = SpatialQuery.ExtractEntityVertices(stationaryEntities); + var contacts = new SlideContactClassifier(movingEntities, stationaryEntities).Prepare(); - var allMovingVerts = ExtractVerticesFromEntities(movingEntities); - var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities); - - var movingCurves = ExtractCurveParams(movingEntities); - var stationaryCurves = ExtractCurveParams(stationaryEntities); - - var vertexCache = - new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>(); - - foreach (var offset in offsets) + // All vertices participate: a leading-half filter can miss the next contact + // after sliding past an initial touch on a concave boundary. + System.Threading.Tasks.Parallel.For(0, offsets.Length, i => { - var key = (offset.DirX, offset.DirY); - if (vertexCache.ContainsKey(key)) - continue; - - var leading = FilterVerticesByProjection( - allMovingVerts, - offset.DirX, - offset.DirY, - keepHigh: true - ); - var facing = FilterVerticesByProjection( - allStationaryVerts, - offset.DirX, - offset.DirY, - keepHigh: false - ); - vertexCache[key] = (leading, facing); - } - - System.Threading.Tasks.Parallel.For( - 0, - count, - i => - { - var offset = offsets[i]; - var dirX = offset.DirX; - var dirY = offset.DirY; - var oppX = -dirX; - var oppY = -dirY; - - var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)]; - - var minDist = double.MaxValue; - - // Case 1: Leading moving vertices → stationary entities - for (var v = 0; v < leadingMoving.Length; v++) - { - var vx = leadingMoving[v].X + offset.Dx; - var vy = leadingMoving[v].Y + offset.Dy; - - for (var j = 0; j < stationaryEntities.Count; j++) - { - var d = RayEntityDistance( - vx, - vy, - stationaryEntities[j], - 0, - 0, - dirX, - dirY - ); - - if (d < minDist) - { - minDist = d; - if (d <= 0) - { - results[i] = 0; - return; - } - } - } - } - - // Case 2: Facing stationary vertices → moving entities (opposite direction) - for (var v = 0; v < facingStationary.Length; v++) - { - var svx = facingStationary[v].X; - var svy = facingStationary[v].Y; - - for (var j = 0; j < movingEntities.Count; j++) - { - var d = RayEntityDistance( - svx, - svy, - movingEntities[j], - offset.Dx, - offset.Dy, - oppX, - oppY - ); - - if (d < minDist) - { - minDist = d; - if (d <= 0) - { - results[i] = 0; - return; - } - } - } - } - - // Phase 3: Curve-to-curve direct distance. - // Vertex sampling misses the true contact between two curved entities - // when the approach angle doesn't align with a sampled vertex. - for (var m = 0; m < movingCurves.Length; m++) - { - var mc = movingCurves[m]; - var mcx = mc.Cx + offset.Dx; - var mcy = mc.Cy + offset.Dy; - - for (var s = 0; s < stationaryCurves.Length; s++) - { - var sc = stationaryCurves[s]; - var d = SpatialQuery.CurveTangencyDistance( - mcx, mcy, mc.Radius, mc.Entity as Arc, - sc.Cx, sc.Cy, sc.Radius, sc.Entity as Arc, dirX, dirY); - - if (d >= minDist) - continue; - - minDist = d; - if (d <= 0) - { - results[i] = 0; - return; - } - } - } - - results[i] = minDist; - } - ); - + var offset = offsets[i]; + var source = new EntitySlideEvents( + movingEntities, movingVertices, offset.Dx, offset.Dy, + stationaryEntities, stationaryVertices, offset.DirX, offset.DirY, arcToLine: true); + results[i] = SlideResolver.FirstBlocking(ref source, + contacts.At(new Vector(offset.Dx, offset.Dy), Vector.Zero), offset.DirX, offset.DirY); + }); return results; } - private readonly struct CurveParams - { - public readonly Entity Entity; - public readonly double Cx, - Cy, - Radius; - - public CurveParams(Entity entity, double cx, double cy, double radius) - { - Entity = entity; - Cx = cx; - Cy = cy; - Radius = radius; - } - } - - private static CurveParams[] ExtractCurveParams(List entities) - { - var curves = new List(); - for (var i = 0; i < entities.Count; i++) - { - if (entities[i] is Circle circle) - curves.Add( - new CurveParams(circle, circle.Center.X, circle.Center.Y, circle.Radius) - ); - else if (entities[i] is Arc arc) - curves.Add(new CurveParams(arc, arc.Center.X, arc.Center.Y, arc.Radius)); - } - return curves.ToArray(); - } - - private static double RayEntityDistance( - double vx, - double vy, - Entity entity, - double entityOffsetX, - double entityOffsetY, - double dirX, - double dirY - ) - { - if (entity is Line line) - { - return SpatialQuery.RayEdgeDistance( - vx, - vy, - line.StartPoint.X + entityOffsetX, - line.StartPoint.Y + entityOffsetY, - line.EndPoint.X + entityOffsetX, - line.EndPoint.Y + entityOffsetY, - dirX, - dirY - ); - } - - if (entity is Arc arc) - { - return SpatialQuery.RayArcDistance( - vx, - vy, - arc.Center.X + entityOffsetX, - arc.Center.Y + entityOffsetY, - arc.Radius, - arc.StartAngle, - arc.EndAngle, - arc.IsReversed, - dirX, - dirY - ); - } - - if (entity is Circle circle) - { - return SpatialQuery.RayCircleDistance( - vx, - vy, - circle.Center.X + entityOffsetX, - circle.Center.Y + entityOffsetY, - circle.Radius, - dirX, - dirY - ); - } - - return double.MaxValue; - } - - private static Vector[] ExtractVerticesFromEntities(List entities) - { - var vertices = new HashSet(); - - for (var i = 0; i < entities.Count; i++) - { - var entity = entities[i]; - - if (entity is Line line) - { - vertices.Add(line.StartPoint); - vertices.Add(line.EndPoint); - } - else if (entity is Arc arc) - { - vertices.Add(arc.StartPoint()); - vertices.Add(arc.EndPoint()); - AddArcExtremes(vertices, arc); - } - else if (entity is Circle circle) - { - // Four cardinal points - vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y)); - vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y)); - vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius)); - vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius)); - } - } - - return vertices.ToArray(); - } - - private static void AddArcExtremes(HashSet points, Arc arc) - { - var a1 = arc.StartAngle; - var a2 = arc.EndAngle; - var reversed = arc.IsReversed; - - if (reversed) - Generic.Swap(ref a1, ref a2); - - // Right (0°) - if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2)) - points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y)); - - // Top (90°) - if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2)) - points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius)); - - // Left (180°) - if (Angle.IsBetweenRad(System.Math.PI, a1, a2)) - points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y)); - - // Bottom (270°) - if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2)) - points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius)); - } - - private static Vector[] ExtractUniqueVertices(List lines) - { - var vertices = new HashSet(); - for (var i = 0; i < lines.Count; i++) - { - vertices.Add(lines[i].StartPoint); - vertices.Add(lines[i].EndPoint); - } - return vertices.ToArray(); - } - - private static Vector[] FilterVerticesByProjection( - Vector[] vertices, - double dirX, - double dirY, - bool keepHigh - ) - { - if (vertices.Length == 0) - return vertices; - - var projections = new double[vertices.Length]; - var min = double.MaxValue; - var max = double.MinValue; - - for (var i = 0; i < vertices.Length; i++) - { - projections[i] = vertices[i].X * dirX + vertices[i].Y * dirY; - if (projections[i] < min) - min = projections[i]; - if (projections[i] > max) - max = projections[i]; - } - - var midpoint = (min + max) / 2; - var count = 0; - - for (var i = 0; i < vertices.Length; i++) - { - if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint) - count++; - } - - var result = new Vector[count]; - var idx = 0; - - for (var i = 0; i < vertices.Length; i++) - { - if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint) - result[idx++] = vertices[i]; - } - - return result; - } + private static Vector[] Vertices(List lines) => + lines.SelectMany(line => new[] { line.StartPoint, line.EndPoint }).Distinct().ToArray(); } } diff --git a/OpenNest.Engine/BestFit/GpuDistanceComputer.cs b/OpenNest.Engine/BestFit/GpuDistanceComputer.cs index c921bca..0a18575 100644 --- a/OpenNest.Engine/BestFit/GpuDistanceComputer.cs +++ b/OpenNest.Engine/BestFit/GpuDistanceComputer.cs @@ -18,6 +18,15 @@ namespace OpenNest.Engine.BestFit SlideOffset[] offsets ) { + // ISlideComputer is axis-only; do not quantize an arbitrary direction into + // an unrelated cardinal push. Native curves already use this same fallback. + foreach (var offset in offsets) + { + if (!((offset.DirX == 0 && System.Math.Abs(offset.DirY) == 1) + || (offset.DirY == 0 && System.Math.Abs(offset.DirX) == 1))) + return new CpuDistanceComputer().ComputeDistances(stationaryLines, movingTemplateLines, offsets); + } + var stationarySegments = SpatialQuery.FlattenLines(stationaryLines); var movingSegments = SpatialQuery.FlattenLines(movingTemplateLines); var count = offsets.Length; @@ -55,7 +64,7 @@ namespace OpenNest.Engine.BestFit /// /// Maps a unit direction vector to a PushDirection int for the GPU interface. - /// Left=0, Down=1, Right=2, Up=3. + /// Up=0, Down=1, Left=2, Right=3. /// private static int DirectionVectorToInt(double dirX, double dirY) { diff --git a/OpenNest.Engine/BestFit/ISlideComputer.cs b/OpenNest.Engine/BestFit/ISlideComputer.cs index e6734d0..a6aa5a0 100644 --- a/OpenNest.Engine/BestFit/ISlideComputer.cs +++ b/OpenNest.Engine/BestFit/ISlideComputer.cs @@ -9,7 +9,8 @@ namespace OpenNest.Engine.BestFit public interface ISlideComputer : IDisposable { /// - /// Computes the minimum directional distance for each offset position. + /// Computes the first blocking contact distance for each offset position. + /// Separating/tangential contacts on closed boundaries do not block. /// /// Flat array [x1,y1,x2,y2, ...] for stationary edges. /// Number of line segments in stationarySegments. @@ -30,7 +31,7 @@ namespace OpenNest.Engine.BestFit ); /// - /// Computes minimum directional distance for offsets with per-offset directions. + /// Computes first blocking contact distances with per-offset directions. /// Uploads segment data once for all offsets, reducing GPU round-trips. /// double[] ComputeBatchMultiDir( diff --git a/OpenNest.Engine/Fill/Compactor.cs b/OpenNest.Engine/Fill/Compactor.cs index 394720e..8593fb6 100644 --- a/OpenNest.Engine/Fill/Compactor.cs +++ b/OpenNest.Engine/Fill/Compactor.cs @@ -127,24 +127,20 @@ namespace OpenNest.Engine.Fill : PartGeometry.GetPerimeterEntities(moving) ); + // A moving part can be inside an obstacle's cutout. Omitting that + // loop would let it cross the inner wall before seeing the perimeter. obstacleEntities[i] ??= halfSpacing > 0 - ? PartGeometry.GetOffsetPerimeterEntities(obstacleParts[i], halfSpacing) - : PartGeometry.GetPerimeterEntities(obstacleParts[i]); + ? PartGeometry.GetOffsetPartEntities(obstacleParts[i], halfSpacing) + : PartGeometry.GetPartEntities(obstacleParts[i]); + // Contacts left by a previous push only block directions that would + // push material into material; the kernel classifies them. var d = SpatialQuery.DirectionalDistance( movingEntities, obstacleEntities[i], direction ); - if ( - d <= Tolerance.Epsilon - && partSpacing <= Tolerance.Epsilon - && CanNudgeWithoutOverlap(moving, obstacleParts[i], direction) - ) - { - continue; - } if (d < distance) distance = d; @@ -176,27 +172,25 @@ namespace OpenNest.Engine.Fill { for (var i = 0; i < parts.Count; i++) { - if (candidate.Intersects(parts[i], out _)) + if (!candidate.Intersects(parts[i], out _)) + continue; + + // Part.Intersects compares outer perimeters only. A valid insert in a + // cutout must remain an obstacle, not be discarded as already overlapping. + var a = new ShapeProfile(PartGeometry.GetPartEntities(candidate)); + var b = new ShapeProfile(PartGeometry.GetPartEntities(parts[i])); + if (a.Cutouts.Count == 0 && b.Cutouts.Count == 0) + return true; + if (Collision.HasOverlap( + a.Perimeter.ToPolygonWithTolerance(0.001), + b.Perimeter.ToPolygonWithTolerance(0.001), + a.Cutouts.Select(hole => hole.ToPolygonWithTolerance(0.001)).ToList(), + b.Cutouts.Select(hole => hole.ToPolygonWithTolerance(0.001)).ToList())) return true; } return false; } - private static bool CanNudgeWithoutOverlap(Part moving, Part obstacle, Vector direction) - { - var nudge = direction * (Tolerance.Epsilon * 10); - - moving.Offset(nudge); - try - { - return !moving.Intersects(obstacle, out _); - } - finally - { - moving.Offset(-nudge); - } - } - public static double Push( List movingParts, List obstacleParts, diff --git a/OpenNest.Engine/Fill/FillExtents.cs b/OpenNest.Engine/Fill/FillExtents.cs index 211ebdb..0fdae78 100644 --- a/OpenNest.Engine/Fill/FillExtents.cs +++ b/OpenNest.Engine/Fill/FillExtents.cs @@ -83,8 +83,9 @@ namespace OpenNest.Engine.Fill // Slide uses locations, not cached bounds; Offset already translates the box. // Slide part2 left toward part1. - var movingLines = boundary2.GetLines(part2.Location, PushDirection.Left); - var stationaryLines = boundary1.GetLines(part1.Location, PushDirection.Right); + // Keep complete loops so the shared kernel can classify tangential contacts. + var movingLines = boundary2.GetLines(part2.Location); + var stationaryLines = boundary1.GetLines(part1.Location); var dist = SpatialQuery.DirectionalDistance( movingLines, stationaryLines, @@ -234,15 +235,9 @@ namespace OpenNest.Engine.Fill PushDirection direction ) { - var opposite = SpatialQuery.OppositeDirection(direction); - var movingEdges = movingBoundary.GetEdges(direction); - var stationaryEdges = stationaryBoundary.GetEdges(opposite); - return SpatialQuery.DirectionalDistance( - movingEdges, - movingLocation, - stationaryEdges, - stationaryLocation, + movingBoundary.GetLines(movingLocation), + stationaryBoundary.GetLines(stationaryLocation), direction ); } diff --git a/OpenNest.Gpu/GpuSlideComputer.cs b/OpenNest.Gpu/GpuSlideComputer.cs index 21876ad..ec2dcca 100644 --- a/OpenNest.Gpu/GpuSlideComputer.cs +++ b/OpenNest.Gpu/GpuSlideComputer.cs @@ -1,8 +1,13 @@ +#nullable enable + using System; +using System.Collections.Generic; using ILGPU; using ILGPU.Algorithms; using ILGPU.Runtime; using OpenNest.Engine.BestFit; +using OpenNest.Geometry; +using OpenNest.Math; namespace OpenNest.Gpu { @@ -19,7 +24,7 @@ namespace OpenNest.Gpu ArrayView1D, // stationaryPrep ArrayView1D, // movingPrep ArrayView1D, // offsets - ArrayView1D, // results + ArrayView1D, // results int, int, int @@ -30,7 +35,7 @@ namespace OpenNest.Gpu ArrayView1D, // stationaryPrep ArrayView1D, // movingPrep ArrayView1D, // offsets - ArrayView1D, // results + ArrayView1D, // results ArrayView1D, // directions int, int @@ -47,22 +52,24 @@ namespace OpenNest.Gpu private MemoryBuffer1D? _gpuStationaryRaw; private MemoryBuffer1D? _gpuStationaryPrep; - private double[]? _lastStationaryData; // Keep CPU copy/ref for content check + private double[]? _lastStationaryData; // Active segment snapshot used for upload and contact topology private MemoryBuffer1D? _gpuMovingRaw; private MemoryBuffer1D? _gpuMovingPrep; - private double[]? _lastMovingData; // Keep CPU copy/ref for content check + private double[]? _lastMovingData; // Active segment snapshot used for upload and contact topology private MemoryBuffer1D? _gpuOffsets; - private MemoryBuffer1D? _gpuResults; + private MemoryBuffer1D? _gpuResults; private MemoryBuffer1D? _gpuDirs; private int _offsetCapacity; - public GpuSlideComputer() + public GpuSlideComputer() : this(preferCPU: false) { } + + public GpuSlideComputer(bool preferCPU) { _context = Context.CreateDefault(); _accelerator = _context - .GetPreferredDevice(preferCPU: false) + .GetPreferredDevice(preferCPU) .CreateAccelerator(_context); _kernel = _accelerator.LoadAutoGroupedStreamKernel< @@ -70,7 +77,7 @@ namespace OpenNest.Gpu ArrayView1D, ArrayView1D, ArrayView1D, - ArrayView1D, + ArrayView1D, int, int, int @@ -81,7 +88,7 @@ namespace OpenNest.Gpu ArrayView1D, ArrayView1D, ArrayView1D, - ArrayView1D, + ArrayView1D, ArrayView1D, int, int @@ -118,21 +125,24 @@ namespace OpenNest.Gpu EnsureMoving(movingTemplateSegments, movingCount); EnsureOffsetBuffers(offsetCount); - _gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets); + _gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU( + _accelerator.DefaultStream, (ReadOnlySpan)offsets.AsSpan(0, offsetCount * 2)); _kernel( offsetCount, _gpuStationaryPrep!.View, _gpuMovingPrep!.View, - _gpuOffsets.View, - _gpuResults!.View, + _gpuOffsets.View.SubView(0, offsetCount * 2), + _gpuResults!.View.SubView(0, offsetCount), stationaryCount, movingCount, (int)direction ); _accelerator.Synchronize(); - _gpuResults.View.SubView(0, offsetCount).CopyToCPU(results); + var witnesses = new ContactWitness[offsetCount]; + _gpuResults.View.SubView(0, offsetCount).CopyToCPU(witnesses); + ResolveContacts(witnesses, offsets, results, direction, null); } return results; @@ -161,93 +171,138 @@ namespace OpenNest.Gpu EnsureMoving(movingTemplateSegments, movingCount); EnsureOffsetBuffers(offsetCount); - _gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets); - _gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU(directions); + _gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU( + _accelerator.DefaultStream, (ReadOnlySpan)offsets.AsSpan(0, offsetCount * 2)); + _gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU( + _accelerator.DefaultStream, (ReadOnlySpan)directions.AsSpan(0, offsetCount)); _kernelMultiDir( offsetCount, _gpuStationaryPrep!.View, _gpuMovingPrep!.View, - _gpuOffsets.View, - _gpuResults!.View, - _gpuDirs.View, + _gpuOffsets.View.SubView(0, offsetCount * 2), + _gpuResults!.View.SubView(0, offsetCount), + _gpuDirs.View.SubView(0, offsetCount), stationaryCount, movingCount ); _accelerator.Synchronize(); - _gpuResults.View.SubView(0, offsetCount).CopyToCPU(results); + var witnesses = new ContactWitness[offsetCount]; + _gpuResults.View.SubView(0, offsetCount).CopyToCPU(witnesses); + ResolveContacts(witnesses, offsets, results, default, directions); } return results; } - public void InvalidateStationary() => _lastStationaryData = null; + public void InvalidateStationary() + { + lock (_lock) + _lastStationaryData = null; + } - public void InvalidateMoving() => _lastMovingData = null; + public void InvalidateMoving() + { + lock (_lock) + _lastMovingData = null; + } + + private void ResolveContacts( + ContactWitness[] witnesses, + double[] offsets, + double[] results, + PushDirection direction, + int[]? directions + ) + { + var moving = default(List); + var stationary = default(List); + var contacts = default(SlideContactClassifier); + for (var i = 0; i < witnesses.Length; i++) + { + var witness = witnesses[i]; + results[i] = witness.Distance; + if (witness.Distance == double.MaxValue) + continue; + + // GPU finds the nearest event. Prepare the complete boundaries only + // once per batch, and share their material-side topology at each offset. + if (contacts == null) + { + moving = ToLines(_lastMovingData!); + stationary = ToLines(_lastStationaryData!); + contacts = SlideContactClassifier.FromLines( + moving, Vector.Zero, stationary, Vector.Zero).Prepare(); + } + + var offset = new Vector(offsets[i * 2], offsets[i * 2 + 1]); + var push = directions == null ? direction : (PushDirection)directions[i]; + var unit = SpatialQuery.DirectionToOffset(push, 1); + var placed = contacts.At(offset, Vector.Zero); + if (placed.Blocks( + new Vector(witness.MovingX, witness.MovingY), + new Vector(witness.StationaryX, witness.StationaryY), unit.X, unit.Y)) + continue; + + // A departing or grazing event does not discard the obstacle: replay + // all events through the shared resolver to find the next blocking one, + // including another contact tied at the same distance. + results[i] = SpatialQuery.DirectionalDistance( + moving!, offset.X, offset.Y, stationary!, push, placed); + } + } + + private static List ToLines(double[] segments) + { + var lines = new List(segments.Length / 4); + for (var i = 0; i < segments.Length; i += 4) + lines.Add(new Line(segments[i], segments[i + 1], segments[i + 2], segments[i + 3])); + return lines; + } private void EnsureStationary(double[] data, int count) { - // Fast check: if same object or content is identical, skip upload - if ( - _gpuStationaryPrep != null - && _lastStationaryData != null - && _lastStationaryData.Length == data.Length - ) - { - // Reference equality or content equality - if ( - _lastStationaryData == data - || new ReadOnlySpan(_lastStationaryData).SequenceEqual( - new ReadOnlySpan(data) - ) - ) - { - return; - } - } + // Cache the active prefix by value: callers may reuse an array with a + // different segment count or mutate its coordinates between batches. + var active = data.AsSpan(0, count * 4); + if (_gpuStationaryPrep != null && _lastStationaryData != null + && active.SequenceEqual(_lastStationaryData)) + return; _gpuStationaryRaw?.Dispose(); _gpuStationaryPrep?.Dispose(); - _gpuStationaryRaw = _accelerator.Allocate1D(data); + var snapshot = active.ToArray(); + _gpuStationaryRaw = _accelerator.Allocate1D(snapshot); _gpuStationaryPrep = _accelerator.Allocate1D(count * 10); _prepareKernel(count, _gpuStationaryRaw.View, _gpuStationaryPrep.View, count); _accelerator.Synchronize(); - _lastStationaryData = data; // store reference for next comparison + _lastStationaryData = snapshot; } private void EnsureMoving(double[] data, int count) { - if ( - _gpuMovingPrep != null - && _lastMovingData != null - && _lastMovingData.Length == data.Length - ) - { - if ( - _lastMovingData == data - || new ReadOnlySpan(_lastMovingData).SequenceEqual( - new ReadOnlySpan(data) - ) - ) - { - return; - } - } + // Cache the active prefix by value: callers may reuse an array with a + // different segment count or mutate its coordinates between batches. + var active = data.AsSpan(0, count * 4); + if (_gpuMovingPrep != null && _lastMovingData != null + && active.SequenceEqual(_lastMovingData)) + return; _gpuMovingRaw?.Dispose(); _gpuMovingPrep?.Dispose(); - _gpuMovingRaw = _accelerator.Allocate1D(data); + var snapshot = active.ToArray(); + _gpuMovingRaw = _accelerator.Allocate1D(snapshot); _gpuMovingPrep = _accelerator.Allocate1D(count * 10); _prepareKernel(count, _gpuMovingRaw.View, _gpuMovingPrep.View, count); _accelerator.Synchronize(); - _lastMovingData = data; + _lastMovingData = snapshot; } private void EnsureOffsetBuffers(int offsetCount) @@ -262,7 +317,7 @@ namespace OpenNest.Gpu _gpuDirs?.Dispose(); _gpuOffsets = _accelerator.Allocate1D(newCapacity * 2); - _gpuResults = _accelerator.Allocate1D(newCapacity); + _gpuResults = _accelerator.Allocate1D(newCapacity); _gpuDirs = _accelerator.Allocate1D(newCapacity); _offsetCapacity = newCapacity; @@ -293,8 +348,8 @@ namespace OpenNest.Gpu var dy = y2 - y1; // invD is used for parameter 't'. We use a small epsilon for stability. - prepared[index * 10 + 4] = (XMath.Abs(dx) < 1e-9) ? 0 : 1.0 / dx; - prepared[index * 10 + 5] = (XMath.Abs(dy) < 1e-9) ? 0 : 1.0 / dy; + prepared[index * 10 + 4] = (XMath.Abs(dx) < Tolerance.Epsilon) ? 0 : 1.0 / dx; + prepared[index * 10 + 5] = (XMath.Abs(dy) < Tolerance.Epsilon) ? 0 : 1.0 / dy; prepared[index * 10 + 6] = XMath.Min(x1, x2); prepared[index * 10 + 7] = XMath.Max(x1, x2); @@ -304,12 +359,48 @@ namespace OpenNest.Gpu // ── Main Slide Kernels ─────────────────────────────────────── + // Public because ILGPU's CPU backend emits kernel argument types in a separate assembly. + public struct ContactWitness + { + public double Distance; + public double MovingX; + public double MovingY; + public double StationaryX; + public double StationaryY; + } + + private static void Consider( + ref ContactWitness nearest, + double distance, + double vx, + double vy, + int rayDirection, + bool vertexMoves + ) + { + var snapped = distance > Tolerance.Epsilon ? distance : 0; + if (snapped >= nearest.Distance) + return; + + // Use the unsnapped hit for incidence; snapping a tiny gap to zero must + // not move the witness off the other boundary. + var dirX = rayDirection == 2 ? -1 : rayDirection == 3 ? 1 : 0; + var dirY = rayDirection == 0 ? 1 : rayDirection == 1 ? -1 : 0; + var hx = vx + distance * dirX; + var hy = vy + distance * dirY; + nearest.Distance = snapped; + nearest.MovingX = vertexMoves ? vx : hx; + nearest.MovingY = vertexMoves ? vy : hy; + nearest.StationaryX = vertexMoves ? hx : vx; + nearest.StationaryY = vertexMoves ? hy : vy; + } + private static void SlideKernel( Index1D index, ArrayView1D stationaryPrep, ArrayView1D movingPrep, ArrayView1D offsets, - ArrayView1D results, + ArrayView1D results, int sCount, int mCount, int direction @@ -337,7 +428,7 @@ namespace OpenNest.Gpu ArrayView1D stationaryPrep, ArrayView1D movingPrep, ArrayView1D offsets, - ArrayView1D results, + ArrayView1D results, ArrayView1D directions, int sCount, int mCount @@ -361,7 +452,7 @@ namespace OpenNest.Gpu ); } - private static double ComputeSlideLean( + private static ContactWitness ComputeSlideLean( ArrayView1D sPrep, ArrayView1D mPrep, double dx, @@ -371,20 +462,20 @@ namespace OpenNest.Gpu int direction ) { - const double eps = 0.00001; - var minDist = double.MaxValue; + const double eps = Tolerance.Epsilon; + var nearest = new ContactWitness { Distance = double.MaxValue }; var horizontal = direction >= 2; var oppDir = direction ^ 1; // ── Forward Pass: moving vertices vs stationary edges ───── - for (int i = 0; i < mCount; i++) + for (var i = 0; i < mCount; i++) { var m1x = mPrep[i * 10 + 0] + dx; var m1y = mPrep[i * 10 + 1] + dy; var m2x = mPrep[i * 10 + 2] + dx; var m2y = mPrep[i * 10 + 3] + dy; - for (int j = 0; j < sCount; j++) + for (var j = 0; j < sCount; j++) { var sMin = horizontal ? sPrep[j * 10 + 8] : sPrep[j * 10 + 6]; var sMax = horizontal ? sPrep[j * 10 + 9] : sPrep[j * 10 + 7]; @@ -394,8 +485,8 @@ namespace OpenNest.Gpu if (mv1 >= sMin - eps && mv1 <= sMax + eps) { var d = RayEdgeLean(m1x, m1y, sPrep, j, direction, eps); - if (d < minDist) - minDist = d; + Consider(ref nearest, d, m1x, m1y, + direction, vertexMoves: true); } // Test moving vertex 2 against stationary edge j @@ -403,21 +494,21 @@ namespace OpenNest.Gpu if (mv2 >= sMin - eps && mv2 <= sMax + eps) { var d = RayEdgeLean(m2x, m2y, sPrep, j, direction, eps); - if (d < minDist) - minDist = d; + Consider(ref nearest, d, m2x, m2y, + direction, vertexMoves: true); } } } // ── Reverse Pass: stationary vertices vs moving edges ───── - for (int i = 0; i < sCount; i++) + for (var i = 0; i < sCount; i++) { var s1x = sPrep[i * 10 + 0]; var s1y = sPrep[i * 10 + 1]; var s2x = sPrep[i * 10 + 2]; var s2y = sPrep[i * 10 + 3]; - for (int j = 0; j < mCount; j++) + for (var j = 0; j < mCount; j++) { var mMin = horizontal ? (mPrep[j * 10 + 8] + dy) : (mPrep[j * 10 + 6] + dx); var mMax = horizontal ? (mPrep[j * 10 + 9] + dy) : (mPrep[j * 10 + 7] + dx); @@ -427,8 +518,8 @@ namespace OpenNest.Gpu if (sv1 >= mMin - eps && sv1 <= mMax + eps) { var d = RayEdgeLeanMoving(s1x, s1y, mPrep, j, dx, dy, oppDir, eps); - if (d < minDist) - minDist = d; + Consider(ref nearest, d, s1x, s1y, + oppDir, vertexMoves: false); } // Test stationary vertex 2 against moving edge j @@ -436,13 +527,13 @@ namespace OpenNest.Gpu if (sv2 >= mMin - eps && sv2 <= mMax + eps) { var d = RayEdgeLeanMoving(s2x, s2y, mPrep, j, dx, dy, oppDir, eps); - if (d < minDist) - minDist = d; + Consider(ref nearest, d, s2x, s2y, + oppDir, vertexMoves: false); } } } - return minDist; + return nearest; } private static double RayEdgeLean( @@ -472,9 +563,7 @@ namespace OpenNest.Gpu var ix = p1x + t * (p2x - p1x); var dist = (direction == 2) ? (vx - ix) : (ix - vx); - if (dist > eps) - return dist; - return (dist >= -eps) ? 0.0 : double.MaxValue; + return dist >= -eps ? dist : double.MaxValue; } else // Vertical (Up=0, Down=1) { @@ -489,9 +578,7 @@ namespace OpenNest.Gpu var iy = p1y + t * (p2y - p1y); var dist = (direction == 1) ? (vy - iy) : (iy - vy); - if (dist > eps) - return dist; - return (dist >= -eps) ? 0.0 : double.MaxValue; + return dist >= -eps ? dist : double.MaxValue; } } @@ -524,9 +611,7 @@ namespace OpenNest.Gpu var ix = p1x + t * (p2x - p1x); var dist = (direction == 2) ? (vx - ix) : (ix - vx); - if (dist > eps) - return dist; - return (dist >= -eps) ? 0.0 : double.MaxValue; + return dist >= -eps ? dist : double.MaxValue; } else // Vertical { @@ -541,9 +626,7 @@ namespace OpenNest.Gpu var iy = p1y + t * (p2y - p1y); var dist = (direction == 1) ? (vy - iy) : (iy - vy); - if (dist > eps) - return dist; - return (dist >= -eps) ? 0.0 : double.MaxValue; + return dist >= -eps ? dist : double.MaxValue; } } diff --git a/OpenNest.Tests/Fill/CompactorTests.cs b/OpenNest.Tests/Fill/CompactorTests.cs index 0cc7cb7..1128c77 100644 --- a/OpenNest.Tests/Fill/CompactorTests.cs +++ b/OpenNest.Tests/Fill/CompactorTests.cs @@ -298,22 +298,111 @@ namespace OpenNest.Tests.Fill Assert.Equal(32, moving.BoundingBox.Left, 7); } + [Theory] + [InlineData(PushDirection.Right)] + [InlineData(PushDirection.Up)] + [InlineData(PushDirection.Down)] + public void Push_WithSpacing_ContactFromPreviousPushDoesNotBlockOtherDirections( + PushDirection next + ) + { + var workArea = new Box(0, 0, 100, 100); + var obstacle = MakeRectPart(20, 40, 10, 10); + var moving = MakeRectPart(60, 40, 10, 10); + var parts = new List { moving }; + var obstacles = new List { obstacle }; + + Assert.True(Compactor.Push(parts, obstacles, workArea, 2, PushDirection.Left) > 0); + Assert.Equal(32, moving.BoundingBox.Left, 7); + + var before = moving.Location; + var distance = Compactor.Push(parts, obstacles, workArea, 2, next); + + Assert.True(distance > 1, $"Push {next} after contact moved only {distance:R}"); + Assert.NotEqual(before, moving.Location); + AssertClearance(moving, obstacle, 2); + } + + [Fact] + public void Push_WithSpacing_ContactStillBlocksTheSameDirection() + { + var workArea = new Box(0, 0, 100, 100); + var obstacle = MakeRectPart(20, 40, 10, 10); + var moving = MakeRectPart(60, 40, 10, 10); + var parts = new List { moving }; + var obstacles = new List { obstacle }; + + Compactor.Push(parts, obstacles, workArea, 2, PushDirection.Left); + var distance = Compactor.Push(parts, obstacles, workArea, 2, PushDirection.Left); + + Assert.Equal(0, distance); + Assert.Equal(32, moving.BoundingBox.Left, 7); + AssertClearance(moving, obstacle, 2); + } + + [Theory] + [InlineData(0)] + [InlineData(2)] + public void Push_SlidingAlongWall_StopsAtItsLaterHook(double spacing) + { + var hook = MakeTrianglePart(new Vector(10, 10), new Vector(20, 10), + new Vector(20, 50), new Vector(50, 50), new Vector(50, 60), new Vector(10, 60)); + var moving = MakeRectPart(20 + spacing, 20, 5, 5); + var distance = Compactor.Push(new List { moving }, new List { hook }, + new Box(0, 0, 100, 100), spacing, PushDirection.Up); + Assert.Equal(25 - spacing, distance, 7); + Assert.False(moving.Intersects(hook, out _)); + if (spacing > 0) + AssertClearance(moving, hook, spacing); + } + + [Theory] + [InlineData(0, false)] + [InlineData(2, false)] + [InlineData(0, true)] + [InlineData(2, true)] + public void Push_InsideStationaryHole_CannotPassThroughItsWall(double spacing, bool plateEntry) + { + var program = MakeRectDrawing(60, 60).Program; + program.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(10, 10))); + program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(50, 10))); + program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(50, 50))); + program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(10, 50))); + program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(10, 10))); + var frame = new Part(new Drawing("frame", program)); + var moving = MakeRectPart(10 + spacing, 20, 5, 5); + var plate = new Plate(100, 100) { PartSpacing = spacing }; + plate.Parts.Add(frame); + plate.Parts.Add(moving); + var distance = plateEntry + ? Compactor.Push(new List { moving }, plate, PushDirection.Right) + : Compactor.Push(new List { moving }, new List { frame }, + new Box(0, 0, 100, 100), spacing, PushDirection.Right); + Assert.Equal(35 - 2 * spacing, distance, 7); + // Independent rectangular-hole oracle; Part.Intersects ignores cutouts. + Assert.Equal(50 - spacing, moving.BoundingBox.Right, 7); + Assert.InRange(moving.BoundingBox.Bottom, 10 + spacing, 50 - spacing); + Assert.InRange(moving.BoundingBox.Top, 10 + spacing, 50 - spacing); + if (spacing > 0) + AssertClearance(moving, frame, spacing); + } + private static void AssertClearance(Part moving, Part obstacle, double spacing) { var clearance = double.MaxValue; foreach (var a in PartGeometry.GetPartLines(moving)) - foreach (var b in PartGeometry.GetPartLines(obstacle)) - { - Assert.False(Intersect.Intersects(a, b, out _)); - clearance = System.Math.Min( - clearance, - a.StartPoint.DistanceTo(b.ClosestPointTo(a.StartPoint)) - ); - clearance = System.Math.Min( - clearance, - b.StartPoint.DistanceTo(a.ClosestPointTo(b.StartPoint)) - ); - } + foreach (var b in PartGeometry.GetPartLines(obstacle)) + { + Assert.False(Intersect.Intersects(a, b, out _)); + clearance = System.Math.Min( + clearance, + a.StartPoint.DistanceTo(b.ClosestPointTo(a.StartPoint)) + ); + clearance = System.Math.Min( + clearance, + b.StartPoint.DistanceTo(a.ClosestPointTo(b.StartPoint)) + ); + } Assert.True( clearance >= spacing - 1e-7, $"Clearance {clearance:R} is less than spacing {spacing:R}" diff --git a/OpenNest.Tests/Geometry/SlideContactTests.cs b/OpenNest.Tests/Geometry/SlideContactTests.cs new file mode 100644 index 0000000..4206556 --- /dev/null +++ b/OpenNest.Tests/Geometry/SlideContactTests.cs @@ -0,0 +1,241 @@ +using OpenNest.Engine.BestFit; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Geometry; + +public class SlideContactTests +{ + public static IEnumerable LinePaths() + { + foreach (var path in new[] { "axis", "offset", "edges", "vector", "entities", "cpu-lines", "cpu-entities" }) + foreach (var reverse in new[] { false, true }) + yield return new object[] { path, reverse }; + } + + [Theory] + [MemberData(nameof(LinePaths))] + public void TouchingRectangles_LeaveOrSlideButCannotEnter(string path, bool reverse) + { + var stationary = Rect(0, 0, 2, 2); + var moving = Rect(2, 0, 2, 2); + if (reverse) + { + Reverse(stationary); + Reverse(moving); + } + Assert.Equal(0, Distance(path, moving, stationary, PushDirection.Left)); + Assert.Equal(double.MaxValue, Distance(path, moving, stationary, PushDirection.Right)); + Assert.Equal(double.MaxValue, Distance(path, moving, stationary, PushDirection.Up)); + Assert.Equal(double.MaxValue, Distance(path, moving, stationary, PushDirection.Down)); + } + + [Theory] + [MemberData(nameof(LinePaths))] + public void SlidingContact_StillStopsAtLaterHookOnSameObstacle(string path, bool reverse) + { + var stationary = Loop((0, 0), (2, 0), (2, 4), (5, 4), (5, 6), (0, 6)); + var moving = Rect(2, 0, 1, 1); + if (reverse) + { + Reverse(stationary); + Reverse(moving); + } + Assert.Equal(3, Distance(path, moving, stationary, PushDirection.Up), 9); + } + + [Theory] + [MemberData(nameof(LinePaths))] + public void HoleContact_LeavingWallStillStopsAtOppositeWall(string path, bool reverse) + { + var stationary = Rect(0, 0, 10, 10); + stationary.AddRange(Rect(2, 2, 6, 6)); // depth, not winding, defines the hole + var moving = Rect(2, 3, 1, 1); + if (reverse) + { + Reverse(stationary); + Reverse(moving); + } + Assert.Equal(0, Distance(path, moving, stationary, PushDirection.Left)); + Assert.Equal(5, Distance(path, moving, stationary, PushDirection.Right), 9); + Assert.Equal(4, Distance(path, moving, stationary, PushDirection.Up), 9); + } + + [Theory] + [InlineData(false, 0)] + [InlineData(true, 0)] + [InlineData(false, 0.37)] + [InlineData(true, 0.37)] + public void RotatedHook_StopsAtFirstBlockingContact(bool cpu, double angle) + { + var stationary = Loop((0, 0), (2, 0), (2, 4), (5, 4), (5, 6), (0, 6)).Cast().ToList(); + var moving = Rect(2, 0, 1, 1).Cast().ToList(); + foreach (var entity in stationary.Concat(moving)) + { + entity.Rotate(angle); + entity.Offset(17, -23); + } + Assert.Equal(3, EntityDistance(cpu, moving, stationary, new Vector(0, 1).Rotate(angle)), 8); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Circles_TangentEscapeAndEnteringContact(bool cpu) + { + var moving = new List { new Circle(2, 0, 1) }; + var stationary = new List { new Circle(0, 0, 1) }; + Assert.Equal(0, EntityDistance(cpu, moving, stationary, new Vector(-1, 0))); + Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(1, 0))); + Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(0, 1))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CircleInsideHole_TangentBlocksButDepartureFindsFarSide(bool cpu) + { + var moving = new List { new Circle(3, 0, 1) }; + var stationary = new List { new Circle(0, 0, 6), new Circle(0, 0, 4) }; + Assert.Equal(0, EntityDistance(cpu, moving, stationary, new Vector(1, 0))); + Assert.Equal(0, EntityDistance(cpu, moving, stationary, new Vector(0, 1))); + Assert.Equal(6, EntityDistance(cpu, moving, stationary, new Vector(-1, 0)), 9); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PositiveGrazingContact_DoesNotHideLaterCircle(bool cpu) + { + var moving = new List { new Circle(0, 0, 1) }; + var stationary = new List { new Circle(4, 2, 1), new Circle(10, 0, 1) }; + Assert.Equal(8, EntityDistance(cpu, moving, stationary, new Vector(1, 0)), 9); + } + + [Fact] + public void ReusedEdgeArrays_KeepTopologyAfterPreviousQuerySortedThem() + { + var moving = Rect(2, 0, 2, 2).Select(l => (l.StartPoint, l.EndPoint)).ToArray(); + var stationary = Rect(0, 0, 2, 2).Select(l => (l.StartPoint, l.EndPoint)).ToArray(); + Assert.Equal(0, SpatialQuery.DirectionalDistance(moving, Vector.Zero, stationary, Vector.Zero, PushDirection.Left)); + Assert.Equal(double.MaxValue, SpatialQuery.DirectionalDistance(moving, Vector.Zero, stationary, Vector.Zero, PushDirection.Right)); + Assert.Equal(double.MaxValue, SpatialQuery.DirectionalDistance(moving, Vector.Zero, stationary, Vector.Zero, PushDirection.Up)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FullCircleArcSeam_AllowsSeparation(bool cpu) + { + var moving = new List { new Arc(0, 0, 1, 0, 2 * System.Math.PI) }; + var stationary = new List { new Circle(2, 0, 1) }; + Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(-1, 0))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ConcaveCorner_DoesNotBlockSlidingAlongItsStraightSide(bool cpu) + { + var moving = Rect(3, -1, 2, 1).Cast().ToList(); + var stationary = new List + { + new Arc(0, 0, 5, System.Math.PI / 2, 0, true), + new Line(5, 0, 10, 0), + new Arc(0, 0, 10, 0, System.Math.PI / 2), + new Line(0, 10, 0, 5), + }; + Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(1, 0))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ThinRing_ArcSeamDoesNotChangeHoleClassification(bool cpu) + { + var a = System.Math.PI / 72; + var moving = new List { new Circle(8.995, 0, 1) }; + var stationary = new List + { + new Circle(0, 0, 10), + new Arc(0, 0, 9.995, a, a + System.Math.PI), + new Arc(0, 0, 9.995, a + System.Math.PI, a + 2 * System.Math.PI), + }; + Assert.Equal(17.99, EntityDistance(cpu, moving, stationary, new Vector(-1, 0)), 9); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CircleAgainstInclinedWall_UsesInteriorCurveContact(bool cpu) + { + var moving = new List { new Circle(0, 0, 1) }; + var stationary = Loop((-10, 0), (10, 10), (10, 12), (-10, 2)).Cast().ToList(); + Assert.Equal(5 - System.Math.Sqrt(1.25), EntityDistance(cpu, moving, stationary, new Vector(0, 1)), 9); + } + + [Fact] + public void GpuAdapter_ArbitraryDirectionUsesExactCpuFallback() + { + using var axesOnly = new RejectSlideComputer(); + var distance = new GpuDistanceComputer(axesOnly).ComputeDistances(Rect(10, 10, 2, 2), Rect(0, 0, 2, 2), + new[] { new SlideOffset(0, 0, 0.6, 0.8) })[0]; + Assert.Equal(8 / 0.6, distance, 9); + } + + private sealed class RejectSlideComputer : ISlideComputer + { + public double[] ComputeBatch(double[] s, int sc, double[] m, int mc, double[] o, int oc, PushDirection d) => + throw new InvalidOperationException("Non-cardinal direction reached axis-only GPU"); + public double[] ComputeBatchMultiDir(double[] s, int sc, double[] m, int mc, double[] o, int oc, int[] d) => + throw new InvalidOperationException("Non-cardinal direction reached axis-only GPU"); + public void Dispose() { } + } + + [Fact] + public void OpenBoundaries_RemainConservative() + { + var moving = new List { new Line(2, 0, 2, 2) }; + var stationary = new List { new Line(2, 0, 2, 2) }; + Assert.Equal(0, SpatialQuery.DirectionalDistance(moving, stationary, PushDirection.Right)); + } + + private static double EntityDistance(bool cpu, List moving, List stationary, Vector direction) => + cpu ? new CpuDistanceComputer().ComputeDistances(stationary, moving, + new[] { new SlideOffset(0, 0, direction.X, direction.Y) })[0] + : SpatialQuery.DirectionalDistance(moving, stationary, direction); + + private static double Distance(string path, List moving, List stationary, PushDirection direction) + { + var unit = SpatialQuery.DirectionToOffset(direction, 1); + // A nonzero template origin catches mixed local/world contact coordinates. + var origin = new Vector(13, -7); + var local = moving.Select(l => new Line(l.StartPoint - origin, l.EndPoint - origin)).ToList(); + return path switch + { + "axis" => SpatialQuery.DirectionalDistance(moving, stationary, direction), + "offset" => SpatialQuery.DirectionalDistance(local, origin.X, origin.Y, stationary, direction), + "edges" => SpatialQuery.DirectionalDistance(local.Select(l => (l.StartPoint, l.EndPoint)).ToArray(), origin, + stationary.Select(l => (l.StartPoint, l.EndPoint)).ToArray(), Vector.Zero, direction), + "vector" => SpatialQuery.DirectionalDistance(moving, stationary, unit), + "entities" => SpatialQuery.DirectionalDistance(moving.Cast().ToList(), stationary.Cast().ToList(), unit), + "cpu-lines" => new CpuDistanceComputer().ComputeDistances(stationary, local, + new[] { new SlideOffset(origin.X, origin.Y, unit.X, unit.Y) })[0], + "cpu-entities" => new CpuDistanceComputer().ComputeDistances(stationary.Cast().ToList(), local.Cast().ToList(), + new[] { new SlideOffset(origin.X, origin.Y, unit.X, unit.Y) })[0], + _ => throw new ArgumentOutOfRangeException(nameof(path)), + }; + } + + private static void Reverse(List lines) + { + lines.Reverse(); + foreach (var line in lines) + line.Reverse(); + } + + private static List Rect(double x, double y, double w, double h) => + Loop((x, y), (x + w, y), (x + w, y + h), (x, y + h)); + + private static List Loop(params (double X, double Y)[] points) => + points.Select((p, i) => new Line(p.X, p.Y, points[(i + 1) % points.Length].X, points[(i + 1) % points.Length].Y)).ToList(); +} diff --git a/OpenNest.WinForms.Tests/GpuSlideContactTests.cs b/OpenNest.WinForms.Tests/GpuSlideContactTests.cs new file mode 100644 index 0000000..6d42faf --- /dev/null +++ b/OpenNest.WinForms.Tests/GpuSlideContactTests.cs @@ -0,0 +1,308 @@ +using System.Reflection; +using ILGPU.Runtime; +using OpenNest.Geometry; +using OpenNest.Gpu; +using OpenNest.Math; +using Xunit; +using Xunit.Abstractions; + +namespace OpenNest.WinForms.Tests; + +// The real ILGPU kernels run on its deterministic CPU accelerator, not a mock or +// CpuDistanceComputer. This also runs without GPU hardware on Windows CI. +public sealed class GpuSlideContactFixture : IDisposable +{ + public GpuSlideComputer Computer { get; } = new GpuSlideComputer(preferCPU: true); + + public void Dispose() => Computer.Dispose(); +} + +public class GpuSlideContactTests : IClassFixture +{ + private readonly GpuSlideComputer computer; + private readonly ITestOutputHelper output; + + public GpuSlideContactTests(GpuSlideContactFixture fixture, ITestOutputHelper output) + { + computer = fixture.Computer; + this.output = output; + } + + public static IEnumerable Paths() + { + foreach (var multiDir in new[] { false, true }) + foreach (var reverse in new[] { false, true }) + for (var turns = 0; turns < 4; turns++) + yield return new object[] { multiDir, reverse, turns }; + } + + [Fact] + public void Kernels_ExecuteOnCpuAccelerator() + { + var accelerator = Assert.IsAssignableFrom(typeof(GpuSlideComputer) + .GetField("_accelerator", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(computer)); + output.WriteLine($"ILGPU backend: {accelerator.AcceleratorType}; device: {accelerator.Name}"); + Assert.Equal(AcceleratorType.CPU, accelerator.AcceleratorType); + } + + [Theory] + [MemberData(nameof(Paths))] + public void TouchingRectangles_EnterBlocksButDepartureAndTangentsDoNot( + bool multiDir, bool reverse, int turns) + { + var stationary = Rect(0, 0, 2, 2); + var moving = Rect(2, 0, 2, 2); + AssertSlide(multiDir, reverse, turns, stationary, moving, PushDirection.Left, 0); + AssertSlide(multiDir, reverse, turns, stationary, moving, PushDirection.Right, double.MaxValue); + AssertSlide(multiDir, reverse, turns, stationary, moving, PushDirection.Up, double.MaxValue); + AssertSlide(multiDir, reverse, turns, stationary, moving, PushDirection.Down, double.MaxValue); + } + + [Theory] + [MemberData(nameof(Paths))] + public void SlidingContact_StopsAtLaterHookOnSameObstacle( + bool multiDir, bool reverse, int turns) + { + var stationary = Loop((0, 0), (2, 0), (2, 4), (5, 4), (5, 6), (0, 6)); + AssertSlide(multiDir, reverse, turns, stationary, Rect(2, 0, 1, 1), PushDirection.Up, 3); + } + + [Theory] + [MemberData(nameof(Paths))] + public void PositiveGrazingContact_StopsAtLaterFeatureOnSameObstacle( + bool multiDir, bool reverse, int turns) + { + var stationary = Loop((4, 1), (10, 1), (10, -2), (12, -2), (12, 3), (4, 3)); + AssertSlide(multiDir, reverse, turns, stationary, Rect(0, 0, 1, 1), PushDirection.Right, 9); + } + + [Theory] + [MemberData(nameof(Paths))] + public void PositiveGrazingContact_WithoutLaterBlockerIsUnbounded( + bool multiDir, bool reverse, int turns) + { + AssertSlide(multiDir, reverse, turns, Rect(4, 1, 2, 2), Rect(0, 0, 1, 1), + PushDirection.Right, double.MaxValue); + } + + [Theory] + [MemberData(nameof(Paths))] + public void HoleContact_LeavingWallStillStopsAtOppositeWall( + bool multiDir, bool reverse, int turns) + { + var stationary = Rect(0, 0, 10, 10); + stationary.AddRange(Rect(2, 2, 6, 6)); // Hole depth must not depend on winding. + AssertSlide(multiDir, reverse, turns, stationary, Rect(2, 3, 1, 1), PushDirection.Right, 5); + AssertSlide(multiDir, reverse, turns, stationary, Rect(2, 3, 1, 1), PushDirection.Up, 4); + } + + [Theory] + [MemberData(nameof(Paths))] + public void ReverseRayWitness_StationaryVertexHitsMiddleOfMovingEdge( + bool multiDir, bool reverse, int turns) + { + // No moving vertex can hit the shorter stationary rectangle. + AssertSlide(multiDir, reverse, turns, Rect(5, 2, 1, 1), Rect(0, 0, 1, 6), + PushDirection.Right, 4); + } + + [Theory] + [MemberData(nameof(Paths))] + public void SnappedZeroDistance_KeepsUnsnappedWitnessOnBothBoundaries( + bool multiDir, bool reverse, int turns) + { + var gap = Tolerance.Epsilon / 2; + AssertSlide(multiDir, reverse, turns, Rect(1 + gap, 0, 1, 1), Rect(0, 0, 1, 1), + PushDirection.Right, 0); + AssertSlide(multiDir, reverse, turns, Rect(1 + gap, 2, 1, 1), Rect(0, 0, 1, 6), + PushDirection.Right, 0); + } + + [Theory] + [MemberData(nameof(Paths))] + public void ToleranceNearMiss_DoesNotBecomeABlockingWitness( + bool multiDir, bool reverse, int turns) + { + AssertSlide(multiDir, reverse, turns, Rect(4, 0, 1, 1), + Rect(0, 1 + Tolerance.Epsilon / 2, 1, 1), PushDirection.Right, double.MaxValue); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void NearParallelEdges_UseTheSharedAxisRayTolerance(bool multiDir) + { + var dy = Tolerance.Epsilon / 2; + AssertSlide(multiDir, false, 0, + new List { new Line(2, 0, 3, dy) }, + new List { new Line(0, 0, 1, dy) }, PushDirection.Right, double.MaxValue); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TiedGrazingAndBlockingContacts_DoNotDropBlockingContact(bool multiDir) + { + var stationary = Rect(4, 1, 1, 1); + stationary.AddRange(Rect(4, -2, 1, 2.5)); + var moving = Loop((1, 1), (0, 1), (0, 0), (1, 0)); + AssertSlide(multiDir, false, 0, stationary, moving, PushDirection.Right, 3); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OpenBoundaries_RemainConservative(bool multiDir) + { + AssertSlide(multiDir, false, 0, + new List { new Line(2, 0, 2, 2) }, + new List { new Line(2, 0, 2, 2) }, PushDirection.Right, 0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void EmptyGeometryAndOffsets_ReturnNoHitWithoutStaleResults(bool multiDir) + { + var rectangle = SpatialQuery.FlattenLines(Rect(0, 0, 1, 1)); + var offsets = new[] { 0.0, 0.0, 2.0, 0.0 }; + var directions = new[] { (int)PushDirection.Left, (int)PushDirection.Left }; + Assert.Equal(new[] { 0.0, 1.0 }, + Compute(multiDir, rectangle, 4, rectangle, 4, offsets, 2, directions, PushDirection.Left)); + Assert.Empty(Compute(multiDir, rectangle, 4, rectangle, 4, offsets, 0, directions)); + Assert.All(Compute(multiDir, Array.Empty(), 0, rectangle, 4, offsets, 2, directions), + distance => Assert.Equal(double.MaxValue, distance)); + Assert.All(Compute(multiDir, rectangle, 4, Array.Empty(), 0, offsets, 2, directions), + distance => Assert.Equal(double.MaxValue, distance)); + Assert.Equal(new[] { 0.0, 1.0 }, + Compute(multiDir, rectangle, 4, rectangle, 4, offsets, 2, directions, PushDirection.Left)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReusedOffsetBuffers_GrowShrinkAndRespectActivePrefixes(bool multiDir) + { + var stationary = Rect(0, 0, 2, 2); + var moving = Rect(0, 0, 1, 1); + var stationaryData = SpatialQuery.FlattenLines(stationary); + var movingData = SpatialQuery.FlattenLines(moving); + // Odd lengths exercise rounded-up thread groups and retained excess capacity. + foreach (var count in new[] { 1, 37, 3, 65, 2, 97, 0, 5, 129, 1 }) + { + var offsets = new double[(count + 7) * 2]; + var directions = new int[count + 7]; + var expected = new double[count]; + for (var i = 0; i < count; i++) + { + var dx = 2 + i % 4; + var dy = i % 3; + var direction = multiDir ? (PushDirection)(i % 4) : PushDirection.Left; + offsets[i * 2] = dx; + offsets[i * 2 + 1] = dy; + directions[i] = (int)direction; + expected[i] = SpatialQuery.DirectionalDistance(moving, dx, dy, stationary, direction); + } + var actual = Compute(multiDir, stationaryData, 4, movingData, 4, offsets, count, + directions, PushDirection.Left); + Assert.Equal(expected, actual); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReusedSegmentArrays_ChangingCountsAndCoordinatesRefreshesBothCaches(bool multiDir) + { + var stationary = SpatialQuery.FlattenLines(Rect(4, 0, 1, 1).Concat(Rect(2, 0, 1, 1)).ToList()); + var moving = SpatialQuery.FlattenLines(Rect(0, 0, 1, 1).Concat(Rect(2, 0, 1, 1)).ToList()); + var offsets = new[] { 0.0, 0.0 }; + var directions = new[] { (int)PushDirection.Right }; + foreach (var counts in new[] { (4, 4, 3.0), (8, 4, 1.0), (4, 8, 1.0), (4, 4, 3.0) }) + Assert.Equal(counts.Item3, + Compute(multiDir, stationary, counts.Item1, moving, counts.Item2, offsets, 1, directions)[0]); + + for (var i = 0; i < stationary.Length; i += 2) + stationary[i] += 1; + Assert.Equal(4, Compute(multiDir, stationary, 4, moving, 4, offsets, 1, directions)[0]); + for (var i = 0; i < moving.Length; i += 2) + moving[i] -= 1; + Assert.Equal(5, Compute(multiDir, stationary, 4, moving, 4, offsets, 1, directions)[0]); + computer.InvalidateStationary(); + computer.InvalidateMoving(); + Assert.Equal(5, Compute(multiDir, stationary, 4, moving, 4, offsets, 1, directions)[0]); + } + + [Fact] + public void MultiDir_UsesEachOffsetAndDirectionIndependently() + { + var stationary = SpatialQuery.FlattenLines(Rect(0, 0, 2, 2)); + var moving = SpatialQuery.FlattenLines(Rect(0, 0, 1, 1)); + var offsets = new[] { 2.0, 0.0, 2.0, 0.0, 2.0, 0.0, 2.0, 0.0, -3.0, 0.0, 0.0, -4.0 }; + var directions = new[] { PushDirection.Left, PushDirection.Right, PushDirection.Up, + PushDirection.Down, PushDirection.Right, PushDirection.Up }.Select(d => (int)d).ToArray(); + Assert.Equal(new[] { 0.0, double.MaxValue, double.MaxValue, double.MaxValue, 2.0, 3.0 }, + computer.ComputeBatchMultiDir(stationary, 4, moving, 4, offsets, 6, directions)); + } + + private void AssertSlide(bool multiDir, bool reverse, int turns, List stationary, + List moving, PushDirection direction, double expected) + { + // Exact quarter turns cover all axis signs without trigonometric rounding. + // Offset the world and template independently to expose mixed-frame witnesses. + var origin = new Vector(13, -7); + stationary = Transform(stationary, turns, new Vector(17, -23), reverse); + moving = Transform(moving, turns, new Vector(17, -23) - origin, reverse); + for (var turn = 0; turn < turns; turn++) + direction = direction switch + { + PushDirection.Right => PushDirection.Up, + PushDirection.Up => PushDirection.Left, + PushDirection.Left => PushDirection.Down, + _ => PushDirection.Right, + }; + + Assert.Equal(expected, + SpatialQuery.DirectionalDistance(moving, origin.X, origin.Y, stationary, direction), 9); + var actual = Compute(multiDir, SpatialQuery.FlattenLines(stationary), stationary.Count, + SpatialQuery.FlattenLines(moving), moving.Count, new[] { origin.X, origin.Y }, 1, + new[] { (int)direction }, direction); + Assert.Single(actual); + Assert.Equal(expected, actual[0], 9); + } + + private double[] Compute(bool multiDir, double[] stationary, int stationaryCount, + double[] moving, int movingCount, double[] offsets, int count, int[] directions, + PushDirection direction = PushDirection.Right) => + multiDir + ? computer.ComputeBatchMultiDir(stationary, stationaryCount, moving, movingCount, + offsets, count, directions) + : computer.ComputeBatch(stationary, stationaryCount, moving, movingCount, + offsets, count, direction); + + private static List Transform(List lines, int turns, Vector origin, bool reverse) + { + Vector Map(Vector point) + { + for (var i = 0; i < turns; i++) + point = new Vector(-point.Y, point.X); + return point + origin; + } + var result = lines.Select(line => new Line(Map(line.StartPoint), Map(line.EndPoint))).ToList(); + if (reverse) + { + result.Reverse(); + foreach (var line in result) + line.Reverse(); + } + return result; + } + + private static List Rect(double x, double y, double width, double height) => + Loop((x, y), (x + width, y), (x + width, y + height), (x, y + height)); + + private static List Loop(params (double X, double Y)[] points) => + points.Select((point, i) => new Line(point.X, point.Y, + points[(i + 1) % points.Length].X, points[(i + 1) % points.Length].Y)).ToList(); +} diff --git a/README.md b/README.md index 5d4e31b..56cb6b5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges - **Import / export** — DXF & DWG parts (ACadSharp), Excel BOMs, bend-line detection, built-in parametric shapes; export DXF or post-processed G-code. - **Nesting** — pluggable whole-job engines (Default, Strip, Vertical/Horizontal Remnant, StockLadder, plus DLL plugins), NFP-based interlocking pair evaluation, gravity compaction, rotation sweeps, multi-plate/multi-material jobs. -- **Plate operations** — sheet cut-offs, oversized-part splitting (straight, weld-gap tabs, spike-groove), interactive editing. +- **Plate operations** — sheet cut-offs, oversized-part splitting (straight, weld-gap tabs, spike-groove), interactive editing, and spacing-aware pushes that can slide along or away from touching parts. - **CNC output** — configurable lead-ins/outs and tabs, contour editing, user-defined G-code variables (`$name` → `#200+` machine variables), plugin post-processors (Cincinnati CL-707/800/900/940/CLX included). ## Requirements diff --git a/docs/geometry/directional-slides.md b/docs/geometry/directional-slides.md new file mode 100644 index 0000000..551ef94 --- /dev/null +++ b/docs/geometry/directional-slides.md @@ -0,0 +1,37 @@ +# Directional slides and repeated pushes + +## Behavior contract + +For initially non-overlapping closed material boundaries, a directional slide stops at the first contact that blocks forward motion. Leaving an existing contact or sliding along a straight shared edge is legal. Skipping a contact must not skip the rest of that obstacle: a later hook or the opposite wall of a hole still stops the slide. Distances retain the existing `Tolerance.Epsilon` snapping and `double.MaxValue` no-hit convention; supplied vector directions are unit vectors. + +`SpatialQuery.DirectionalDistance` overloads and CPU best-fit batches use the same event sources and contact resolver. Events carry both contact points in their initial world frames, rather than only a snapped distance. The classifier uses closed-loop material sectors, native-curve containment for hole depth, and the curvature of the supporting boundary at a tangential contact. A full-circle arc has no physical corner at its seam. Raw ray helpers remain first-touch primitives, not material-aware slide queries. + +Open/incomplete chains and ambiguous contacts conservatively block. This is not an overlap-repair operation or a general replacement for layout validation. Caller-provided contact topology must represent the same boundaries and offsets as the query. Prepared geometry must not be mutated; prepare a classifier before sharing it between parallel queries. Edge-array queries still sort their arrays, and recover loop order from private copies before classifying contacts. + +## Callers + +- PlateView uses `SelectionManager.PushSelected` → `Compactor.Push`. The zero-spacing nudge-and-discard workaround is removed. Cutout contours on stationary obstacles are retained, and the plate entry's existing-overlap filter accounts for holes rather than relying solely on `Part.Intersects` (which compares outer perimeters). +- Linear fill inherits the shared native-entity query unchanged. Extents fill passes complete boundary loops instead of direction-filtered fragments. +- CPU best-fit batches prepare contact topology once and use all vertices plus curve/line interior and curve/curve tangency events. The old leading-half vertex filter cannot establish the next blocker after a skipped touch. +- GPU kernels retain nearest-hit reduction and return unsnapped contact witnesses. The shared CPU classifier accepts a blocking witness or replays the full query after a nonblocking witness, preserving tied/later blockers. Both batch APIs honor active buffer lengths and refresh mutated/reused segment arrays. The GPU distance adapter sends only exact cardinal directions to the axis-only slide interface; arbitrary directions and native curves use the shared CPU path. + +## Regression coverage + +`SlideContactTests` exercises cardinal line, translated line, reused edge-array, arbitrary-vector, native-entity, and both CPU batch paths. Cases include winding reversal, nonzero origins, rotated hooks, holes, separating circles, positive-distance grazing followed by a blocker, full-circle arc seams, concave/straight junctions, thin rings, and circle/line interior contact. + +`CompactorTests` covers the reported sequence (push left with spacing, then right/up/down), genuine zero-distance blocking, zero/nonzero-spacing later hooks, and inside-hole pushes through both direct and plate entry points. Physical spacing is measured from raw outlines rather than the inflated contours used by the solver. + +Verification commands: + +```sh +dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~SlideContactTests|FullyQualifiedName~CurveContactDistanceTests|FullyQualifiedName~CompactorTests' +dotnet test OpenNest.Tests/OpenNest.Tests.csproj +dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj +dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj +``` + +The isolated repair tree (excluding other sessions' fill-performance and spacing-expander work) passed 130 targeted cases and the full Debug suites: main 1758 passed / 21 skipped, engine 300 passed, IO 41 passed. The main Release suite passed 1728 / 21 skipped. Skips are not counted as passes. The 142 `GpuSlideContactTests` also pass in a net8 harness linking the production GPU class and test source, using ILGPU 1.5.1's CPU accelerator (not a mocked distance solver). The Windows desktop/test project cross-build passes in Release. Neither physical GPU execution nor Windows UI interaction was runtime-verified on Linux. + +## Remaining hardening + +This repair does not change saved best-fit cache versioning, add a clearance acceptance gate to every fill entry, repair existing overlapping layouts, or claim the earlier real-DXF/grid-validator discrepancies in [pair-spacing checks](pair-spacing.md) are resolved. That document's measured candidate counts describe its earlier tree; removing CPU projection filtering and adding interior curve/line contacts does not substitute for rerunning its corpus. General `Part.Intersects` hole semantics remain unchanged outside Compactor. Profile the new classification path before attempting optimizations; retain the first-blocking-contact regressions. diff --git a/docs/geometry/pair-spacing.md b/docs/geometry/pair-spacing.md index 512d0bf..5942c99 100644 --- a/docs/geometry/pair-spacing.md +++ b/docs/geometry/pair-spacing.md @@ -4,7 +4,7 @@ CPU best-fit slides and shared directional-distance queries now check both external and internal curve tangency. A convex offset corner inside a concave slot contacts at the difference of the radii, not their sum. Both forward ray/circle roots must be checked: the nearer root can be outside an arc's angular span while the farther root is the first actual contact. Tangent-point directions differ for internal contact, including when the moving curve is the larger one. -`SpatialQuery.CurveTangencyDistance` shares this calculation between the two callers. It assumes a unit direction, nonnegative radii and world-frame centers. An optional arc supplies only angular limits; null represents a full circle. This helper supplements the existing vertex/line phases rather than replacing them. Equal-radius coincident curves have no isolated internal tangent and remain the vertex phases' responsibility; zero-radius curves are points. No spacing tolerances or acceptance policies were changed. +The raw `SpatialQuery.CurveTangencyDistance` helper and shared slide events implement this calculation. The subsequent [directional-slide repair](directional-slides.md) routes both callers through material-aware contact events; the measurements below describe the earlier native-tangency repair. It assumes a unit direction, nonnegative radii and world-frame centers. An optional arc supplies only angular limits; null represents a full circle. This helper supplements the existing vertex/line phases rather than replacing them. Equal-radius coincident curves have no isolated internal tangent and remain the vertex phases' responsibility; zero-radius curves are points. No spacing tolerances or acceptance policies were changed. ### Reproduced U-shaped part