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