fix(geometry): resolve blocking contacts across directional slide paths

This commit is contained in:
aj
2026-09-27 23:24:31 -04:00
parent a04460c57b
commit 2b78fb3a75
15 changed files with 2736 additions and 945 deletions
+831
View File
@@ -0,0 +1,831 @@
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry
{
internal enum ContactSide
{
/// <summary>The boundary could not be decomposed into closed loops.</summary>
Unresolved,
/// <summary>The point is not on the boundary: a tolerance near-miss, not a contact.</summary>
Off,
/// <summary>Several boundary runs meet here, or the corner is a cusp or spike.</summary>
Ambiguous,
/// <summary>The material sector is known.</summary>
Sector,
}
/// <summary>
/// Closed boundary loops of one entity list, prepared so a directional slide can tell
/// which side of each boundary point is material. Immutable after
/// <see cref="Prepare"/>, so one instance may be shared by concurrent queries.
/// </summary>
/// <remarks>
/// Loops are recovered from contiguous runs whose end points chain back to their start
/// (the order produced by <see cref="ShapeBuilder"/> 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.
/// </remarks>
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<Entity> entities;
private readonly int[] loopOf;
private readonly int[] previous;
private readonly int[] following;
private readonly bool[] materialLeft;
private SlideContactGeometry(
List<Entity> entities,
int[] loopOf,
int[] previous,
int[] following,
bool[] materialLeft
)
{
this.entities = entities;
this.loopOf = loopOf;
this.previous = previous;
this.following = following;
this.materialLeft = materialLeft;
}
/// <summary>True when every entity belongs to a closed loop with a known material side.</summary>
public bool IsResolved => materialLeft != null;
public static SlideContactGeometry Prepare(List<Entity> 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<Entity> entities) =>
new SlideContactGeometry(entities, null, null, null, null);
/// <summary>
/// Material directions at a boundary point: an angular sector starting at
/// <paramref name="start"/> and sweeping CCW by <paramref name="width"/>.
/// 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.
/// </summary>
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<Entity> 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<Entity> 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<double> { 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;
}
}
/// <summary>
/// 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
/// <see cref="Prepare"/> 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.
/// </summary>
public sealed class SlideContactClassifier
{
private readonly System.Func<List<Entity>> movingSource;
private readonly System.Func<List<Entity>> stationarySource;
private SlideContactGeometry moving;
private SlideContactGeometry stationary;
public SlideContactClassifier(List<Entity> movingEntities, List<Entity> stationaryEntities)
: this(movingEntities, Vector.Zero, stationaryEntities, Vector.Zero) { }
public SlideContactClassifier(
List<Entity> movingEntities,
Vector movingOrigin,
List<Entity> 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<List<Entity>> movingSource,
Vector movingOrigin,
System.Func<List<Entity>> 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<Line> movingLines,
Vector movingOrigin,
List<Line> stationaryLines,
Vector stationaryOrigin
) =>
new SlideContactClassifier(
() => new List<Entity>(movingLines),
movingOrigin,
() => new List<Entity>(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<Entity> ToLines((Vector start, Vector end)[] edges)
{
var lines = new List<Entity>(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<Entity>(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<Entity>());
stationary ??= SlideContactGeometry.Prepare(
stationarySource?.Invoke() ?? new List<Entity>()
);
return this;
}
/// <summary>The same prepared boundaries placed at other origins.</summary>
public SlideContactClassifier At(Vector movingOrigin, Vector stationaryOrigin)
{
Prepare();
return new SlideContactClassifier(moving, movingOrigin, stationary, stationaryOrigin);
}
/// <summary>
/// True when moving along (dirX, dirY) from this world-space contact would push
/// material into material, or the contact cannot be classified.
/// </summary>
public bool Blocks(Vector movingPoint, Vector stationaryPoint, double dirX, double dirY)
{
Prepare();
return SlideContact.Blocks(
moving,
movingPoint - MovingOrigin,
stationary,
stationaryPoint - StationaryOrigin,
dirX,
dirY
);
}
}
/// <summary>Receives candidate contact events from a directional slide query.</summary>
public interface ISlideEventSink
{
/// <summary>True once further events cannot change this sink's result.</summary>
bool IsDone { get; }
/// <param name="distance">Travel to the contact, snapped to zero within Tolerance.Epsilon.</param>
/// <param name="movingPoint">Contact on the moving boundary, at its start position.</param>
/// <param name="stationaryPoint">Contact on the stationary boundary.</param>
void Add(double distance, Vector movingPoint, Vector stationaryPoint);
}
/// <summary>
/// Enumerates every candidate contact of one slide. Must yield the same events each
/// time it is enumerated.
/// </summary>
public interface ISlideEventSource
{
void Enumerate<TSink>(ref TSink sink)
where TSink : struct, ISlideEventSink;
}
/// <summary>Keeps the nearest event; stops at a contact that is already touching.</summary>
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
{
/// <summary>
/// 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.
/// </summary>
public static double FirstBlocking<TSource>(
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;
}
}
/// <summary>
/// Decides whether a first-contact event found by a directional slide stops the slide.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
/// <summary>
/// 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.
/// </summary>
/// <param name="movingPoint">Contact point in the moving entities' own frame.</param>
/// <param name="stationaryPoint">The same contact in the stationary frame.</param>
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),
};
}
/// <summary>
/// Convex cone generated by two convex sectors. False when it is the whole plane.
/// </summary>
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;
}
}
}
+806
View File
@@ -0,0 +1,806 @@
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry
{
/// <summary>
/// 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.
/// </summary>
internal static class SlideEvents
{
private const double Eps = Tolerance.Epsilon;
private static double Snap(double t) => t > Eps ? t : 0;
/// <summary>
/// Ray from a vertex against one entity. When <paramref name="vertexMoves"/> 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.
/// </summary>
public static void Ray<TSink>(
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;
}
}
}
/// <summary>Same hit rule as <see cref="SpatialQuery.RayEdgeDistance(double, double, double, double, double, double, double, double)"/>.</summary>
public static void RayLine<TSink>(
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);
}
/// <summary>
/// Axis-aligned ray against a segment, with the same hit rule as the
/// <see cref="PushDirection"/> kernel.
/// </summary>
public static void AxisRayLine<TSink>(
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);
}
/// <summary>
/// Closest-approach points of arcs against lines, which vertex sampling can miss.
/// </summary>
public static void ArcToLine<TSink>(
ref TSink sink,
List<Entity> arcEntities,
double arcDx,
double arcDy,
List<Entity> 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;
}
}
}
}
/// <summary>
/// External and internal tangencies of two curves along a unit direction. Radii must
/// be nonnegative; a null arc is a full circle.
/// </summary>
public static void CurveTangency<TSink>(
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<TSink>(
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;
}
}
/// <summary>
/// Slide events between native Line/Arc/Circle boundaries. The moving entities and
/// vertices are translated by (movingDx, movingDy); vertex arrays may be subsets.
/// </summary>
public struct EntitySlideEvents : ISlideEventSource
{
private readonly List<Entity> moving;
private readonly Vector[] movingVertices;
private readonly double movingDx;
private readonly double movingDy;
private readonly List<Entity> stationary;
private readonly Vector[] stationaryVertices;
private readonly double dirX;
private readonly double dirY;
private readonly bool arcToLine;
public EntitySlideEvents(
List<Entity> moving,
Vector[] movingVertices,
double movingDx,
double movingDy,
List<Entity> 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<TSink>(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;
}
}
}
}
/// <summary>
/// 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.
/// </summary>
public struct LineSlideEvents : ISlideEventSource
{
private readonly List<Line> moving;
private readonly Vector[] movingVertices;
private readonly double movingDx;
private readonly double movingDy;
private readonly List<Line> stationary;
private readonly Vector[] stationaryVertices;
private readonly double dirX;
private readonly double dirY;
public LineSlideEvents(
List<Line> moving,
Vector[] movingVertices,
double movingDx,
double movingDy,
List<Line> 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<TSink>(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;
}
}
}
}
/// <summary>
/// Axis-aligned slide events between edge arrays sorted for pruning, as used by the
/// <see cref="PushDirection"/> kernel. Offsets translate each side into world space.
/// </summary>
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;
/// <param name="movingVertices">World-space moving vertices.</param>
/// <param name="stationaryVertices">World-space stationary vertices.</param>
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<TSink>(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<TSink>(
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;
}
}
}
}
+168 -353
View File
@@ -320,8 +320,9 @@ namespace OpenNest.Geometry
}
/// <summary>
/// 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 <see cref="SlideContact"/>).
/// Returns double.MaxValue if no collision path exists.
/// </summary>
public static double DirectionalDistance(
@@ -334,7 +335,7 @@ namespace OpenNest.Geometry
}
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// <see cref="DirectionalDistance(List{Line}, double, double, List{Line}, PushDirection)"/>
/// with caller-supplied contact topology, for inputs that are not complete closed
/// boundaries (for example direction-filtered edges).
/// </summary>
public static double DirectionalDistance(
List<Line> movingLines,
double movingDx,
double movingDy,
List<Line> 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);
}
/// <summary>
@@ -396,8 +417,8 @@ namespace OpenNest.Geometry
}
/// <summary>
/// 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.
/// </summary>
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
)
);
}
/// <summary>
/// Edge-array overload with caller-supplied contact topology. The classifier's
/// origins must match <paramref name="movingOffset"/> and
/// <paramref name="stationaryOffset"/> in the frame of its boundaries.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public static double OneWayDistance(
Vector vertex,
(Vector start, Vector end)[] edges,
@@ -628,8 +671,8 @@ namespace OpenNest.Geometry
}
/// <summary>
/// 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.
/// </summary>
public static double DirectionalDistance(
List<Line> 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;
/// <summary>
/// <see cref="DirectionalDistance(List{Line}, List{Line}, Vector)"/> with
/// caller-supplied contact topology.
/// </summary>
public static double DirectionalDistance(
List<Line> movingLines,
List<Line> 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);
}
/// <summary>
@@ -710,10 +736,10 @@ namespace OpenNest.Geometry
}
/// <summary>
/// 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 <see cref="SlideContact"/>).
/// </summary>
public static double DirectionalDistance(
List<Entity> 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,
}
/// <summary>
/// <see cref="DirectionalDistance(List{Entity}, List{Entity}, Vector)"/> with
/// caller-supplied contact topology.
/// </summary>
public static double DirectionalDistance(
List<Entity> movingEntities,
List<Entity> 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<Entity> arcEntities,
List<Entity> 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<Entity> entities)
public static Vector[] ExtractEntityVertices(List<Entity> entities)
{
var vertices = new HashSet<Vector>();
@@ -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;
+29 -447
View File
@@ -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<Entity> entities)
{
var curves = new List<CurveParams>();
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<Entity> entities)
{
var vertices = new HashSet<Vector>();
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<Vector> 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<Line> lines)
{
var vertices = new HashSet<Vector>();
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<Line> lines) =>
lines.SelectMany(line => new[] { line.StartPoint, line.EndPoint }).Distinct().ToArray();
}
}
+10 -1
View File
@@ -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
/// <summary>
/// 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.
/// </summary>
private static int DirectionVectorToInt(double dirX, double dirY)
{
+3 -2
View File
@@ -9,7 +9,8 @@ namespace OpenNest.Engine.BestFit
public interface ISlideComputer : IDisposable
{
/// <summary>
/// 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.
/// </summary>
/// <param name="stationarySegments">Flat array [x1,y1,x2,y2, ...] for stationary edges.</param>
/// <param name="stationaryCount">Number of line segments in stationarySegments.</param>
@@ -30,7 +31,7 @@ namespace OpenNest.Engine.BestFit
);
/// <summary>
/// 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.
/// </summary>
double[] ComputeBatchMultiDir(
+20 -26
View File
@@ -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<Part> movingParts,
List<Part> obstacleParts,
+5 -10
View File
@@ -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
);
}
+175 -92
View File
@@ -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<double, Stride1D.Dense>, // stationaryPrep
ArrayView1D<double, Stride1D.Dense>, // movingPrep
ArrayView1D<double, Stride1D.Dense>, // offsets
ArrayView1D<double, Stride1D.Dense>, // results
ArrayView1D<ContactWitness, Stride1D.Dense>, // results
int,
int,
int
@@ -30,7 +35,7 @@ namespace OpenNest.Gpu
ArrayView1D<double, Stride1D.Dense>, // stationaryPrep
ArrayView1D<double, Stride1D.Dense>, // movingPrep
ArrayView1D<double, Stride1D.Dense>, // offsets
ArrayView1D<double, Stride1D.Dense>, // results
ArrayView1D<ContactWitness, Stride1D.Dense>, // results
ArrayView1D<int, Stride1D.Dense>, // directions
int,
int
@@ -47,22 +52,24 @@ namespace OpenNest.Gpu
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryRaw;
private MemoryBuffer1D<double, Stride1D.Dense>? _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<double, Stride1D.Dense>? _gpuMovingRaw;
private MemoryBuffer1D<double, Stride1D.Dense>? _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<double, Stride1D.Dense>? _gpuOffsets;
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuResults;
private MemoryBuffer1D<ContactWitness, Stride1D.Dense>? _gpuResults;
private MemoryBuffer1D<int, Stride1D.Dense>? _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<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<ContactWitness, Stride1D.Dense>,
int,
int,
int
@@ -81,7 +88,7 @@ namespace OpenNest.Gpu
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<ContactWitness, Stride1D.Dense>,
ArrayView1D<int, Stride1D.Dense>,
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<double>)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<double>)offsets.AsSpan(0, offsetCount * 2));
_gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU(
_accelerator.DefaultStream, (ReadOnlySpan<int>)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<Line>);
var stationary = default(List<Line>);
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<Line> ToLines(double[] segments)
{
var lines = new List<Line>(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<double>(_lastStationaryData).SequenceEqual(
new ReadOnlySpan<double>(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<double>(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<double>(_lastMovingData).SequenceEqual(
new ReadOnlySpan<double>(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<double>(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<double>(newCapacity * 2);
_gpuResults = _accelerator.Allocate1D<double>(newCapacity);
_gpuResults = _accelerator.Allocate1D<ContactWitness>(newCapacity);
_gpuDirs = _accelerator.Allocate1D<int>(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<double, Stride1D.Dense> stationaryPrep,
ArrayView1D<double, Stride1D.Dense> movingPrep,
ArrayView1D<double, Stride1D.Dense> offsets,
ArrayView1D<double, Stride1D.Dense> results,
ArrayView1D<ContactWitness, Stride1D.Dense> results,
int sCount,
int mCount,
int direction
@@ -337,7 +428,7 @@ namespace OpenNest.Gpu
ArrayView1D<double, Stride1D.Dense> stationaryPrep,
ArrayView1D<double, Stride1D.Dense> movingPrep,
ArrayView1D<double, Stride1D.Dense> offsets,
ArrayView1D<double, Stride1D.Dense> results,
ArrayView1D<ContactWitness, Stride1D.Dense> results,
ArrayView1D<int, Stride1D.Dense> directions,
int sCount,
int mCount
@@ -361,7 +452,7 @@ namespace OpenNest.Gpu
);
}
private static double ComputeSlideLean(
private static ContactWitness ComputeSlideLean(
ArrayView1D<double, Stride1D.Dense> sPrep,
ArrayView1D<double, Stride1D.Dense> 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;
}
}
+101 -12
View File
@@ -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<Part> { moving };
var obstacles = new List<Part> { 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<Part> { moving };
var obstacles = new List<Part> { 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<Part> { moving }, new List<Part> { 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<Part> { moving }, plate, PushDirection.Right)
: Compactor.Push(new List<Part> { moving }, new List<Part> { 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}"
@@ -0,0 +1,241 @@
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
namespace OpenNest.Tests.Geometry;
public class SlideContactTests
{
public static IEnumerable<object[]> LinePaths()
{
foreach (var path in new[] { "axis", "offset", "edges", "vector", "entities", "cpu-lines", "cpu-entities" })
foreach (var reverse in new[] { false, true })
yield return new object[] { path, reverse };
}
[Theory]
[MemberData(nameof(LinePaths))]
public void TouchingRectangles_LeaveOrSlideButCannotEnter(string path, bool reverse)
{
var stationary = Rect(0, 0, 2, 2);
var moving = Rect(2, 0, 2, 2);
if (reverse)
{
Reverse(stationary);
Reverse(moving);
}
Assert.Equal(0, Distance(path, moving, stationary, PushDirection.Left));
Assert.Equal(double.MaxValue, Distance(path, moving, stationary, PushDirection.Right));
Assert.Equal(double.MaxValue, Distance(path, moving, stationary, PushDirection.Up));
Assert.Equal(double.MaxValue, Distance(path, moving, stationary, PushDirection.Down));
}
[Theory]
[MemberData(nameof(LinePaths))]
public void SlidingContact_StillStopsAtLaterHookOnSameObstacle(string path, bool reverse)
{
var stationary = Loop((0, 0), (2, 0), (2, 4), (5, 4), (5, 6), (0, 6));
var moving = Rect(2, 0, 1, 1);
if (reverse)
{
Reverse(stationary);
Reverse(moving);
}
Assert.Equal(3, Distance(path, moving, stationary, PushDirection.Up), 9);
}
[Theory]
[MemberData(nameof(LinePaths))]
public void HoleContact_LeavingWallStillStopsAtOppositeWall(string path, bool reverse)
{
var stationary = Rect(0, 0, 10, 10);
stationary.AddRange(Rect(2, 2, 6, 6)); // depth, not winding, defines the hole
var moving = Rect(2, 3, 1, 1);
if (reverse)
{
Reverse(stationary);
Reverse(moving);
}
Assert.Equal(0, Distance(path, moving, stationary, PushDirection.Left));
Assert.Equal(5, Distance(path, moving, stationary, PushDirection.Right), 9);
Assert.Equal(4, Distance(path, moving, stationary, PushDirection.Up), 9);
}
[Theory]
[InlineData(false, 0)]
[InlineData(true, 0)]
[InlineData(false, 0.37)]
[InlineData(true, 0.37)]
public void RotatedHook_StopsAtFirstBlockingContact(bool cpu, double angle)
{
var stationary = Loop((0, 0), (2, 0), (2, 4), (5, 4), (5, 6), (0, 6)).Cast<Entity>().ToList();
var moving = Rect(2, 0, 1, 1).Cast<Entity>().ToList();
foreach (var entity in stationary.Concat(moving))
{
entity.Rotate(angle);
entity.Offset(17, -23);
}
Assert.Equal(3, EntityDistance(cpu, moving, stationary, new Vector(0, 1).Rotate(angle)), 8);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Circles_TangentEscapeAndEnteringContact(bool cpu)
{
var moving = new List<Entity> { new Circle(2, 0, 1) };
var stationary = new List<Entity> { new Circle(0, 0, 1) };
Assert.Equal(0, EntityDistance(cpu, moving, stationary, new Vector(-1, 0)));
Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(1, 0)));
Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(0, 1)));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CircleInsideHole_TangentBlocksButDepartureFindsFarSide(bool cpu)
{
var moving = new List<Entity> { new Circle(3, 0, 1) };
var stationary = new List<Entity> { new Circle(0, 0, 6), new Circle(0, 0, 4) };
Assert.Equal(0, EntityDistance(cpu, moving, stationary, new Vector(1, 0)));
Assert.Equal(0, EntityDistance(cpu, moving, stationary, new Vector(0, 1)));
Assert.Equal(6, EntityDistance(cpu, moving, stationary, new Vector(-1, 0)), 9);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void PositiveGrazingContact_DoesNotHideLaterCircle(bool cpu)
{
var moving = new List<Entity> { new Circle(0, 0, 1) };
var stationary = new List<Entity> { new Circle(4, 2, 1), new Circle(10, 0, 1) };
Assert.Equal(8, EntityDistance(cpu, moving, stationary, new Vector(1, 0)), 9);
}
[Fact]
public void ReusedEdgeArrays_KeepTopologyAfterPreviousQuerySortedThem()
{
var moving = Rect(2, 0, 2, 2).Select(l => (l.StartPoint, l.EndPoint)).ToArray();
var stationary = Rect(0, 0, 2, 2).Select(l => (l.StartPoint, l.EndPoint)).ToArray();
Assert.Equal(0, SpatialQuery.DirectionalDistance(moving, Vector.Zero, stationary, Vector.Zero, PushDirection.Left));
Assert.Equal(double.MaxValue, SpatialQuery.DirectionalDistance(moving, Vector.Zero, stationary, Vector.Zero, PushDirection.Right));
Assert.Equal(double.MaxValue, SpatialQuery.DirectionalDistance(moving, Vector.Zero, stationary, Vector.Zero, PushDirection.Up));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void FullCircleArcSeam_AllowsSeparation(bool cpu)
{
var moving = new List<Entity> { new Arc(0, 0, 1, 0, 2 * System.Math.PI) };
var stationary = new List<Entity> { new Circle(2, 0, 1) };
Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(-1, 0)));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ConcaveCorner_DoesNotBlockSlidingAlongItsStraightSide(bool cpu)
{
var moving = Rect(3, -1, 2, 1).Cast<Entity>().ToList();
var stationary = new List<Entity>
{
new Arc(0, 0, 5, System.Math.PI / 2, 0, true),
new Line(5, 0, 10, 0),
new Arc(0, 0, 10, 0, System.Math.PI / 2),
new Line(0, 10, 0, 5),
};
Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(1, 0)));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ThinRing_ArcSeamDoesNotChangeHoleClassification(bool cpu)
{
var a = System.Math.PI / 72;
var moving = new List<Entity> { new Circle(8.995, 0, 1) };
var stationary = new List<Entity>
{
new Circle(0, 0, 10),
new Arc(0, 0, 9.995, a, a + System.Math.PI),
new Arc(0, 0, 9.995, a + System.Math.PI, a + 2 * System.Math.PI),
};
Assert.Equal(17.99, EntityDistance(cpu, moving, stationary, new Vector(-1, 0)), 9);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CircleAgainstInclinedWall_UsesInteriorCurveContact(bool cpu)
{
var moving = new List<Entity> { new Circle(0, 0, 1) };
var stationary = Loop((-10, 0), (10, 10), (10, 12), (-10, 2)).Cast<Entity>().ToList();
Assert.Equal(5 - System.Math.Sqrt(1.25), EntityDistance(cpu, moving, stationary, new Vector(0, 1)), 9);
}
[Fact]
public void GpuAdapter_ArbitraryDirectionUsesExactCpuFallback()
{
using var axesOnly = new RejectSlideComputer();
var distance = new GpuDistanceComputer(axesOnly).ComputeDistances(Rect(10, 10, 2, 2), Rect(0, 0, 2, 2),
new[] { new SlideOffset(0, 0, 0.6, 0.8) })[0];
Assert.Equal(8 / 0.6, distance, 9);
}
private sealed class RejectSlideComputer : ISlideComputer
{
public double[] ComputeBatch(double[] s, int sc, double[] m, int mc, double[] o, int oc, PushDirection d) =>
throw new InvalidOperationException("Non-cardinal direction reached axis-only GPU");
public double[] ComputeBatchMultiDir(double[] s, int sc, double[] m, int mc, double[] o, int oc, int[] d) =>
throw new InvalidOperationException("Non-cardinal direction reached axis-only GPU");
public void Dispose() { }
}
[Fact]
public void OpenBoundaries_RemainConservative()
{
var moving = new List<Line> { new Line(2, 0, 2, 2) };
var stationary = new List<Line> { new Line(2, 0, 2, 2) };
Assert.Equal(0, SpatialQuery.DirectionalDistance(moving, stationary, PushDirection.Right));
}
private static double EntityDistance(bool cpu, List<Entity> moving, List<Entity> stationary, Vector direction) =>
cpu ? new CpuDistanceComputer().ComputeDistances(stationary, moving,
new[] { new SlideOffset(0, 0, direction.X, direction.Y) })[0]
: SpatialQuery.DirectionalDistance(moving, stationary, direction);
private static double Distance(string path, List<Line> moving, List<Line> stationary, PushDirection direction)
{
var unit = SpatialQuery.DirectionToOffset(direction, 1);
// A nonzero template origin catches mixed local/world contact coordinates.
var origin = new Vector(13, -7);
var local = moving.Select(l => new Line(l.StartPoint - origin, l.EndPoint - origin)).ToList();
return path switch
{
"axis" => SpatialQuery.DirectionalDistance(moving, stationary, direction),
"offset" => SpatialQuery.DirectionalDistance(local, origin.X, origin.Y, stationary, direction),
"edges" => SpatialQuery.DirectionalDistance(local.Select(l => (l.StartPoint, l.EndPoint)).ToArray(), origin,
stationary.Select(l => (l.StartPoint, l.EndPoint)).ToArray(), Vector.Zero, direction),
"vector" => SpatialQuery.DirectionalDistance(moving, stationary, unit),
"entities" => SpatialQuery.DirectionalDistance(moving.Cast<Entity>().ToList(), stationary.Cast<Entity>().ToList(), unit),
"cpu-lines" => new CpuDistanceComputer().ComputeDistances(stationary, local,
new[] { new SlideOffset(origin.X, origin.Y, unit.X, unit.Y) })[0],
"cpu-entities" => new CpuDistanceComputer().ComputeDistances(stationary.Cast<Entity>().ToList(), local.Cast<Entity>().ToList(),
new[] { new SlideOffset(origin.X, origin.Y, unit.X, unit.Y) })[0],
_ => throw new ArgumentOutOfRangeException(nameof(path)),
};
}
private static void Reverse(List<Line> lines)
{
lines.Reverse();
foreach (var line in lines)
line.Reverse();
}
private static List<Line> Rect(double x, double y, double w, double h) =>
Loop((x, y), (x + w, y), (x + w, y + h), (x, y + h));
private static List<Line> Loop(params (double X, double Y)[] points) =>
points.Select((p, i) => new Line(p.X, p.Y, points[(i + 1) % points.Length].X, points[(i + 1) % points.Length].Y)).ToList();
}
@@ -0,0 +1,308 @@
using System.Reflection;
using ILGPU.Runtime;
using OpenNest.Geometry;
using OpenNest.Gpu;
using OpenNest.Math;
using Xunit;
using Xunit.Abstractions;
namespace OpenNest.WinForms.Tests;
// The real ILGPU kernels run on its deterministic CPU accelerator, not a mock or
// CpuDistanceComputer. This also runs without GPU hardware on Windows CI.
public sealed class GpuSlideContactFixture : IDisposable
{
public GpuSlideComputer Computer { get; } = new GpuSlideComputer(preferCPU: true);
public void Dispose() => Computer.Dispose();
}
public class GpuSlideContactTests : IClassFixture<GpuSlideContactFixture>
{
private readonly GpuSlideComputer computer;
private readonly ITestOutputHelper output;
public GpuSlideContactTests(GpuSlideContactFixture fixture, ITestOutputHelper output)
{
computer = fixture.Computer;
this.output = output;
}
public static IEnumerable<object[]> Paths()
{
foreach (var multiDir in new[] { false, true })
foreach (var reverse in new[] { false, true })
for (var turns = 0; turns < 4; turns++)
yield return new object[] { multiDir, reverse, turns };
}
[Fact]
public void Kernels_ExecuteOnCpuAccelerator()
{
var accelerator = Assert.IsAssignableFrom<Accelerator>(typeof(GpuSlideComputer)
.GetField("_accelerator", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(computer));
output.WriteLine($"ILGPU backend: {accelerator.AcceleratorType}; device: {accelerator.Name}");
Assert.Equal(AcceleratorType.CPU, accelerator.AcceleratorType);
}
[Theory]
[MemberData(nameof(Paths))]
public void TouchingRectangles_EnterBlocksButDepartureAndTangentsDoNot(
bool multiDir, bool reverse, int turns)
{
var stationary = Rect(0, 0, 2, 2);
var moving = Rect(2, 0, 2, 2);
AssertSlide(multiDir, reverse, turns, stationary, moving, PushDirection.Left, 0);
AssertSlide(multiDir, reverse, turns, stationary, moving, PushDirection.Right, double.MaxValue);
AssertSlide(multiDir, reverse, turns, stationary, moving, PushDirection.Up, double.MaxValue);
AssertSlide(multiDir, reverse, turns, stationary, moving, PushDirection.Down, double.MaxValue);
}
[Theory]
[MemberData(nameof(Paths))]
public void SlidingContact_StopsAtLaterHookOnSameObstacle(
bool multiDir, bool reverse, int turns)
{
var stationary = Loop((0, 0), (2, 0), (2, 4), (5, 4), (5, 6), (0, 6));
AssertSlide(multiDir, reverse, turns, stationary, Rect(2, 0, 1, 1), PushDirection.Up, 3);
}
[Theory]
[MemberData(nameof(Paths))]
public void PositiveGrazingContact_StopsAtLaterFeatureOnSameObstacle(
bool multiDir, bool reverse, int turns)
{
var stationary = Loop((4, 1), (10, 1), (10, -2), (12, -2), (12, 3), (4, 3));
AssertSlide(multiDir, reverse, turns, stationary, Rect(0, 0, 1, 1), PushDirection.Right, 9);
}
[Theory]
[MemberData(nameof(Paths))]
public void PositiveGrazingContact_WithoutLaterBlockerIsUnbounded(
bool multiDir, bool reverse, int turns)
{
AssertSlide(multiDir, reverse, turns, Rect(4, 1, 2, 2), Rect(0, 0, 1, 1),
PushDirection.Right, double.MaxValue);
}
[Theory]
[MemberData(nameof(Paths))]
public void HoleContact_LeavingWallStillStopsAtOppositeWall(
bool multiDir, bool reverse, int turns)
{
var stationary = Rect(0, 0, 10, 10);
stationary.AddRange(Rect(2, 2, 6, 6)); // Hole depth must not depend on winding.
AssertSlide(multiDir, reverse, turns, stationary, Rect(2, 3, 1, 1), PushDirection.Right, 5);
AssertSlide(multiDir, reverse, turns, stationary, Rect(2, 3, 1, 1), PushDirection.Up, 4);
}
[Theory]
[MemberData(nameof(Paths))]
public void ReverseRayWitness_StationaryVertexHitsMiddleOfMovingEdge(
bool multiDir, bool reverse, int turns)
{
// No moving vertex can hit the shorter stationary rectangle.
AssertSlide(multiDir, reverse, turns, Rect(5, 2, 1, 1), Rect(0, 0, 1, 6),
PushDirection.Right, 4);
}
[Theory]
[MemberData(nameof(Paths))]
public void SnappedZeroDistance_KeepsUnsnappedWitnessOnBothBoundaries(
bool multiDir, bool reverse, int turns)
{
var gap = Tolerance.Epsilon / 2;
AssertSlide(multiDir, reverse, turns, Rect(1 + gap, 0, 1, 1), Rect(0, 0, 1, 1),
PushDirection.Right, 0);
AssertSlide(multiDir, reverse, turns, Rect(1 + gap, 2, 1, 1), Rect(0, 0, 1, 6),
PushDirection.Right, 0);
}
[Theory]
[MemberData(nameof(Paths))]
public void ToleranceNearMiss_DoesNotBecomeABlockingWitness(
bool multiDir, bool reverse, int turns)
{
AssertSlide(multiDir, reverse, turns, Rect(4, 0, 1, 1),
Rect(0, 1 + Tolerance.Epsilon / 2, 1, 1), PushDirection.Right, double.MaxValue);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void NearParallelEdges_UseTheSharedAxisRayTolerance(bool multiDir)
{
var dy = Tolerance.Epsilon / 2;
AssertSlide(multiDir, false, 0,
new List<Line> { new Line(2, 0, 3, dy) },
new List<Line> { new Line(0, 0, 1, dy) }, PushDirection.Right, double.MaxValue);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void TiedGrazingAndBlockingContacts_DoNotDropBlockingContact(bool multiDir)
{
var stationary = Rect(4, 1, 1, 1);
stationary.AddRange(Rect(4, -2, 1, 2.5));
var moving = Loop((1, 1), (0, 1), (0, 0), (1, 0));
AssertSlide(multiDir, false, 0, stationary, moving, PushDirection.Right, 3);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void OpenBoundaries_RemainConservative(bool multiDir)
{
AssertSlide(multiDir, false, 0,
new List<Line> { new Line(2, 0, 2, 2) },
new List<Line> { new Line(2, 0, 2, 2) }, PushDirection.Right, 0);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void EmptyGeometryAndOffsets_ReturnNoHitWithoutStaleResults(bool multiDir)
{
var rectangle = SpatialQuery.FlattenLines(Rect(0, 0, 1, 1));
var offsets = new[] { 0.0, 0.0, 2.0, 0.0 };
var directions = new[] { (int)PushDirection.Left, (int)PushDirection.Left };
Assert.Equal(new[] { 0.0, 1.0 },
Compute(multiDir, rectangle, 4, rectangle, 4, offsets, 2, directions, PushDirection.Left));
Assert.Empty(Compute(multiDir, rectangle, 4, rectangle, 4, offsets, 0, directions));
Assert.All(Compute(multiDir, Array.Empty<double>(), 0, rectangle, 4, offsets, 2, directions),
distance => Assert.Equal(double.MaxValue, distance));
Assert.All(Compute(multiDir, rectangle, 4, Array.Empty<double>(), 0, offsets, 2, directions),
distance => Assert.Equal(double.MaxValue, distance));
Assert.Equal(new[] { 0.0, 1.0 },
Compute(multiDir, rectangle, 4, rectangle, 4, offsets, 2, directions, PushDirection.Left));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ReusedOffsetBuffers_GrowShrinkAndRespectActivePrefixes(bool multiDir)
{
var stationary = Rect(0, 0, 2, 2);
var moving = Rect(0, 0, 1, 1);
var stationaryData = SpatialQuery.FlattenLines(stationary);
var movingData = SpatialQuery.FlattenLines(moving);
// Odd lengths exercise rounded-up thread groups and retained excess capacity.
foreach (var count in new[] { 1, 37, 3, 65, 2, 97, 0, 5, 129, 1 })
{
var offsets = new double[(count + 7) * 2];
var directions = new int[count + 7];
var expected = new double[count];
for (var i = 0; i < count; i++)
{
var dx = 2 + i % 4;
var dy = i % 3;
var direction = multiDir ? (PushDirection)(i % 4) : PushDirection.Left;
offsets[i * 2] = dx;
offsets[i * 2 + 1] = dy;
directions[i] = (int)direction;
expected[i] = SpatialQuery.DirectionalDistance(moving, dx, dy, stationary, direction);
}
var actual = Compute(multiDir, stationaryData, 4, movingData, 4, offsets, count,
directions, PushDirection.Left);
Assert.Equal(expected, actual);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ReusedSegmentArrays_ChangingCountsAndCoordinatesRefreshesBothCaches(bool multiDir)
{
var stationary = SpatialQuery.FlattenLines(Rect(4, 0, 1, 1).Concat(Rect(2, 0, 1, 1)).ToList());
var moving = SpatialQuery.FlattenLines(Rect(0, 0, 1, 1).Concat(Rect(2, 0, 1, 1)).ToList());
var offsets = new[] { 0.0, 0.0 };
var directions = new[] { (int)PushDirection.Right };
foreach (var counts in new[] { (4, 4, 3.0), (8, 4, 1.0), (4, 8, 1.0), (4, 4, 3.0) })
Assert.Equal(counts.Item3,
Compute(multiDir, stationary, counts.Item1, moving, counts.Item2, offsets, 1, directions)[0]);
for (var i = 0; i < stationary.Length; i += 2)
stationary[i] += 1;
Assert.Equal(4, Compute(multiDir, stationary, 4, moving, 4, offsets, 1, directions)[0]);
for (var i = 0; i < moving.Length; i += 2)
moving[i] -= 1;
Assert.Equal(5, Compute(multiDir, stationary, 4, moving, 4, offsets, 1, directions)[0]);
computer.InvalidateStationary();
computer.InvalidateMoving();
Assert.Equal(5, Compute(multiDir, stationary, 4, moving, 4, offsets, 1, directions)[0]);
}
[Fact]
public void MultiDir_UsesEachOffsetAndDirectionIndependently()
{
var stationary = SpatialQuery.FlattenLines(Rect(0, 0, 2, 2));
var moving = SpatialQuery.FlattenLines(Rect(0, 0, 1, 1));
var offsets = new[] { 2.0, 0.0, 2.0, 0.0, 2.0, 0.0, 2.0, 0.0, -3.0, 0.0, 0.0, -4.0 };
var directions = new[] { PushDirection.Left, PushDirection.Right, PushDirection.Up,
PushDirection.Down, PushDirection.Right, PushDirection.Up }.Select(d => (int)d).ToArray();
Assert.Equal(new[] { 0.0, double.MaxValue, double.MaxValue, double.MaxValue, 2.0, 3.0 },
computer.ComputeBatchMultiDir(stationary, 4, moving, 4, offsets, 6, directions));
}
private void AssertSlide(bool multiDir, bool reverse, int turns, List<Line> stationary,
List<Line> moving, PushDirection direction, double expected)
{
// Exact quarter turns cover all axis signs without trigonometric rounding.
// Offset the world and template independently to expose mixed-frame witnesses.
var origin = new Vector(13, -7);
stationary = Transform(stationary, turns, new Vector(17, -23), reverse);
moving = Transform(moving, turns, new Vector(17, -23) - origin, reverse);
for (var turn = 0; turn < turns; turn++)
direction = direction switch
{
PushDirection.Right => PushDirection.Up,
PushDirection.Up => PushDirection.Left,
PushDirection.Left => PushDirection.Down,
_ => PushDirection.Right,
};
Assert.Equal(expected,
SpatialQuery.DirectionalDistance(moving, origin.X, origin.Y, stationary, direction), 9);
var actual = Compute(multiDir, SpatialQuery.FlattenLines(stationary), stationary.Count,
SpatialQuery.FlattenLines(moving), moving.Count, new[] { origin.X, origin.Y }, 1,
new[] { (int)direction }, direction);
Assert.Single(actual);
Assert.Equal(expected, actual[0], 9);
}
private double[] Compute(bool multiDir, double[] stationary, int stationaryCount,
double[] moving, int movingCount, double[] offsets, int count, int[] directions,
PushDirection direction = PushDirection.Right) =>
multiDir
? computer.ComputeBatchMultiDir(stationary, stationaryCount, moving, movingCount,
offsets, count, directions)
: computer.ComputeBatch(stationary, stationaryCount, moving, movingCount,
offsets, count, direction);
private static List<Line> Transform(List<Line> lines, int turns, Vector origin, bool reverse)
{
Vector Map(Vector point)
{
for (var i = 0; i < turns; i++)
point = new Vector(-point.Y, point.X);
return point + origin;
}
var result = lines.Select(line => new Line(Map(line.StartPoint), Map(line.EndPoint))).ToList();
if (reverse)
{
result.Reverse();
foreach (var line in result)
line.Reverse();
}
return result;
}
private static List<Line> Rect(double x, double y, double width, double height) =>
Loop((x, y), (x + width, y), (x + width, y + height), (x, y + height));
private static List<Line> Loop(params (double X, double Y)[] points) =>
points.Select((point, i) => new Line(point.X, point.Y,
points[(i + 1) % points.Length].X, points[(i + 1) % points.Length].Y)).ToList();
}
+1 -1
View File
@@ -11,7 +11,7 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges
- **Import / export** — DXF & DWG parts (ACadSharp), Excel BOMs, bend-line detection, built-in parametric shapes; export DXF or post-processed G-code.
- **Nesting** — pluggable whole-job engines (Default, Strip, Vertical/Horizontal Remnant, StockLadder, plus DLL plugins), NFP-based interlocking pair evaluation, gravity compaction, rotation sweeps, multi-plate/multi-material jobs.
- **Plate operations** — sheet cut-offs, oversized-part splitting (straight, weld-gap tabs, spike-groove), interactive editing.
- **Plate operations** — sheet cut-offs, oversized-part splitting (straight, weld-gap tabs, spike-groove), interactive editing, and spacing-aware pushes that can slide along or away from touching parts.
- **CNC output** — configurable lead-ins/outs and tabs, contour editing, user-defined G-code variables (`$name` → `#200+` machine variables), plugin post-processors (Cincinnati CL-707/800/900/940/CLX included).
## Requirements
+37
View File
@@ -0,0 +1,37 @@
# Directional slides and repeated pushes
## Behavior contract
For initially non-overlapping closed material boundaries, a directional slide stops at the first contact that blocks forward motion. Leaving an existing contact or sliding along a straight shared edge is legal. Skipping a contact must not skip the rest of that obstacle: a later hook or the opposite wall of a hole still stops the slide. Distances retain the existing `Tolerance.Epsilon` snapping and `double.MaxValue` no-hit convention; supplied vector directions are unit vectors.
`SpatialQuery.DirectionalDistance` overloads and CPU best-fit batches use the same event sources and contact resolver. Events carry both contact points in their initial world frames, rather than only a snapped distance. The classifier uses closed-loop material sectors, native-curve containment for hole depth, and the curvature of the supporting boundary at a tangential contact. A full-circle arc has no physical corner at its seam. Raw ray helpers remain first-touch primitives, not material-aware slide queries.
Open/incomplete chains and ambiguous contacts conservatively block. This is not an overlap-repair operation or a general replacement for layout validation. Caller-provided contact topology must represent the same boundaries and offsets as the query. Prepared geometry must not be mutated; prepare a classifier before sharing it between parallel queries. Edge-array queries still sort their arrays, and recover loop order from private copies before classifying contacts.
## Callers
- PlateView uses `SelectionManager.PushSelected` → `Compactor.Push`. The zero-spacing nudge-and-discard workaround is removed. Cutout contours on stationary obstacles are retained, and the plate entry's existing-overlap filter accounts for holes rather than relying solely on `Part.Intersects` (which compares outer perimeters).
- Linear fill inherits the shared native-entity query unchanged. Extents fill passes complete boundary loops instead of direction-filtered fragments.
- CPU best-fit batches prepare contact topology once and use all vertices plus curve/line interior and curve/curve tangency events. The old leading-half vertex filter cannot establish the next blocker after a skipped touch.
- GPU kernels retain nearest-hit reduction and return unsnapped contact witnesses. The shared CPU classifier accepts a blocking witness or replays the full query after a nonblocking witness, preserving tied/later blockers. Both batch APIs honor active buffer lengths and refresh mutated/reused segment arrays. The GPU distance adapter sends only exact cardinal directions to the axis-only slide interface; arbitrary directions and native curves use the shared CPU path.
## Regression coverage
`SlideContactTests` exercises cardinal line, translated line, reused edge-array, arbitrary-vector, native-entity, and both CPU batch paths. Cases include winding reversal, nonzero origins, rotated hooks, holes, separating circles, positive-distance grazing followed by a blocker, full-circle arc seams, concave/straight junctions, thin rings, and circle/line interior contact.
`CompactorTests` covers the reported sequence (push left with spacing, then right/up/down), genuine zero-distance blocking, zero/nonzero-spacing later hooks, and inside-hole pushes through both direct and plate entry points. Physical spacing is measured from raw outlines rather than the inflated contours used by the solver.
Verification commands:
```sh
dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~SlideContactTests|FullyQualifiedName~CurveContactDistanceTests|FullyQualifiedName~CompactorTests'
dotnet test OpenNest.Tests/OpenNest.Tests.csproj
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
```
The isolated repair tree (excluding other sessions' fill-performance and spacing-expander work) passed 130 targeted cases and the full Debug suites: main 1758 passed / 21 skipped, engine 300 passed, IO 41 passed. The main Release suite passed 1728 / 21 skipped. Skips are not counted as passes. The 142 `GpuSlideContactTests` also pass in a net8 harness linking the production GPU class and test source, using ILGPU 1.5.1's CPU accelerator (not a mocked distance solver). The Windows desktop/test project cross-build passes in Release. Neither physical GPU execution nor Windows UI interaction was runtime-verified on Linux.
## Remaining hardening
This repair does not change saved best-fit cache versioning, add a clearance acceptance gate to every fill entry, repair existing overlapping layouts, or claim the earlier real-DXF/grid-validator discrepancies in [pair-spacing checks](pair-spacing.md) are resolved. That document's measured candidate counts describe its earlier tree; removing CPU projection filtering and adding interior curve/line contacts does not substitute for rerunning its corpus. General `Part.Intersects` hole semantics remain unchanged outside Compactor. Profile the new classification path before attempting optimizations; retain the first-blocking-contact regressions.
+1 -1
View File
@@ -4,7 +4,7 @@
CPU best-fit slides and shared directional-distance queries now check both external and internal curve tangency. A convex offset corner inside a concave slot contacts at the difference of the radii, not their sum. Both forward ray/circle roots must be checked: the nearer root can be outside an arc's angular span while the farther root is the first actual contact. Tangent-point directions differ for internal contact, including when the moving curve is the larger one.
`SpatialQuery.CurveTangencyDistance` shares this calculation between the two callers. It assumes a unit direction, nonnegative radii and world-frame centers. An optional arc supplies only angular limits; null represents a full circle. This helper supplements the existing vertex/line phases rather than replacing them. Equal-radius coincident curves have no isolated internal tangent and remain the vertex phases' responsibility; zero-radius curves are points. No spacing tolerances or acceptance policies were changed.
The raw `SpatialQuery.CurveTangencyDistance` helper and shared slide events implement this calculation. The subsequent [directional-slide repair](directional-slides.md) routes both callers through material-aware contact events; the measurements below describe the earlier native-tangency repair. It assumes a unit direction, nonnegative radii and world-frame centers. An optional arc supplies only angular limits; null represents a full circle. This helper supplements the existing vertex/line phases rather than replacing them. Equal-radius coincident curves have no isolated internal tangent and remain the vertex phases' responsibility; zero-radius curves are points. No spacing tolerances or acceptance policies were changed.
### Reproduced U-shaped part