diff --git a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs
index e3a9c9ea..ab5dfa27 100644
--- a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs
+++ b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs
@@ -1,10 +1,14 @@
using System.Collections.Generic;
using OpenNest.Geometry;
+using OpenNest.Math;
namespace OpenNest.Engine.BestFit
{
public class RotationSlideStrategy : IBestFitStrategy
{
+ /// Settled positions closer than this to an earlier one are duplicates.
+ private const double PositionResolution = 1e-4;
+
private readonly IDistanceComputer _distanceComputer;
private static readonly (double DirX, double DirY)[] PushDirections =
@@ -61,7 +65,8 @@ namespace OpenNest.Engine.BestFit
offsets
);
- var testNumber = 0;
+ // Where each slide came to rest, as an offset of the part 2 template.
+ var rests = new List();
for (var i = 0; i < offsets.Length; i++)
{
@@ -69,28 +74,111 @@ namespace OpenNest.Engine.BestFit
if (slideDist >= double.MaxValue || slideDist < 0)
continue;
- var finalPosition = new Vector(
- part2Template.Location.X + offsets[i].Dx + offsets[i].DirX * slideDist,
- part2Template.Location.Y + offsets[i].Dy + offsets[i].DirY * slideDist
- );
-
- candidates.Add(
- new PairCandidate
- {
- Drawing = drawing,
- Part1Rotation = 0,
- Part2Rotation = Part2Rotation,
- Part2Offset = finalPosition,
- StrategyIndex = StrategyIndex,
- TestNumber = testNumber++,
- Spacing = spacing,
- }
+ rests.Add(
+ new SlideOffset(
+ offsets[i].Dx + offsets[i].DirX * slideDist,
+ offsets[i].Dy + offsets[i].DirY * slideDist,
+ offsets[i].DirX,
+ offsets[i].DirY
+ )
);
}
+ var testNumber = 0;
+
+ foreach (var rest in rests)
+ candidates.Add(CreateCandidate(drawing, part2Template, rest, spacing, testNumber++));
+
+ foreach (var settled in Settle(part1Entities, part2Entities, rests))
+ candidates.Add(CreateCandidate(drawing, part2Template, settled, spacing, testNumber++));
+
return candidates;
}
+ ///
+ /// Slides every resting position sideways, both ways, until part 2 meets part 1 again.
+ /// The first slide starts on the step grid, so it can stop up to a step short of a
+ /// corner that locks the pair tighter (two interlocking channels, say); the sideways
+ /// slide closes that gap exactly. Positions that cannot move, never meet part 1 again
+ /// or repeat an earlier one are dropped.
+ ///
+ private List Settle(
+ List part1Entities,
+ List part2Entities,
+ List rests
+ )
+ {
+ var settled = new List();
+
+ if (rests.Count == 0)
+ return settled;
+
+ var sideways = new SlideOffset[rests.Count * 2];
+
+ for (var i = 0; i < rests.Count; i++)
+ {
+ var rest = rests[i];
+ // Perpendicular to the first slide: (x, y) -> (-y, x) and its opposite.
+ sideways[i * 2] = new SlideOffset(rest.Dx, rest.Dy, -rest.DirY, rest.DirX);
+ sideways[i * 2 + 1] = new SlideOffset(rest.Dx, rest.Dy, rest.DirY, -rest.DirX);
+ }
+
+ var distances = _distanceComputer.ComputeDistances(
+ part1Entities,
+ part2Entities,
+ sideways
+ );
+
+ var seen = new HashSet<(long X, long Y)>();
+
+ foreach (var rest in rests)
+ seen.Add(PositionKey(rest.Dx, rest.Dy));
+
+ for (var i = 0; i < sideways.Length; i++)
+ {
+ var slideDist = distances[i];
+ if (slideDist >= double.MaxValue || slideDist <= Tolerance.Epsilon)
+ continue;
+
+ var dx = sideways[i].Dx + sideways[i].DirX * slideDist;
+ var dy = sideways[i].Dy + sideways[i].DirY * slideDist;
+
+ if (seen.Add(PositionKey(dx, dy)))
+ settled.Add(new SlideOffset(dx, dy, sideways[i].DirX, sideways[i].DirY));
+ }
+
+ return settled;
+ }
+
+ private static (long X, long Y) PositionKey(double dx, double dy) =>
+ (
+ (long)System.Math.Round(dx / PositionResolution),
+ (long)System.Math.Round(dy / PositionResolution)
+ );
+
+ private PairCandidate CreateCandidate(
+ Drawing drawing,
+ Part part2Template,
+ SlideOffset rest,
+ double spacing,
+ int testNumber
+ )
+ {
+ return new PairCandidate
+ {
+ Drawing = drawing,
+ Part1Rotation = 0,
+ Part2Rotation = Part2Rotation,
+ Part2Offset = new Vector(
+ part2Template.Location.X + rest.Dx,
+ part2Template.Location.Y + rest.Dy
+ ),
+ StrategyIndex = StrategyIndex,
+ TestNumber = testNumber,
+ Spacing = spacing,
+ };
+ }
+
private static SlideOffset[] BuildOffsets(
Box bbox1,
Box bbox2,
diff --git a/OpenNest.Tests/BestFit/BestFitSettleTests.cs b/OpenNest.Tests/BestFit/BestFitSettleTests.cs
new file mode 100644
index 00000000..b5fbffac
--- /dev/null
+++ b/OpenNest.Tests/BestFit/BestFitSettleTests.cs
@@ -0,0 +1,43 @@
+using OpenNest.CNC;
+using OpenNest.Engine.BestFit;
+
+namespace OpenNest.Tests.BestFit;
+
+///
+/// The pair search slides part 2 in from offsets on a step grid, so a pair that locks in a
+/// corner needs a second, sideways slide to close the gap the grid leaves.
+///
+public class BestFitSettleTests
+{
+ private const double Spacing = 0.25;
+ private const double LegWidth = 1.3;
+
+ /// 10 x 3 channel: a 10 x 1 bar with a 1.3-wide, 2-tall leg at each end.
+ private static Drawing Channel()
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(10, 0);
+ program.LineTo(10, 3);
+ program.LineTo(10 - LegWidth, 3);
+ program.LineTo(10 - LegWidth, 1);
+ program.LineTo(LegWidth, 1);
+ program.LineTo(LegWidth, 3);
+ program.LineTo(0, 3);
+ program.LineTo(0, 0);
+ return new Drawing("channel", program);
+ }
+
+ [Fact]
+ public void InterlockedChannels_CloseTheGapTheStepGridLeaves()
+ {
+ // Two channels facing each other, one shifted by a leg width plus the spacing, so each
+ // has a leg inside the other. That shift is not a multiple of the step.
+ var results = new BestFitFinder(60, 120).FindBestFits(Channel(), Spacing, stepSize: 0.25);
+
+ var best = results.First(r => r.Keep);
+
+ Assert.Equal(10 + LegWidth + Spacing, best.BoundingWidth, 3);
+ Assert.Equal(3 + 1 + Spacing, best.BoundingHeight, 3);
+ }
+}
diff --git a/docs/geometry/directional-slides.md b/docs/geometry/directional-slides.md
index ca4d3ac3..c4155a38 100644
--- a/docs/geometry/directional-slides.md
+++ b/docs/geometry/directional-slides.md
@@ -14,6 +14,7 @@ Open/incomplete chains and ambiguous contacts conservatively block. Loops are re
- With **Calculated distance** checked in Tools > Set Offset Increment, PlateView's arrow keys use `SelectionManager.StepSelected` → `Compactor.Step`. The step is the selection's linear-fill copy distance (`FillLinear.CopyDistance`) at the plate's part spacing, so a shape that nests into its own copy steps less than its size. That copy distance is the same in both directions along an axis. The move is not a slide: like a fixed nudge it always moves the full step, ignoring the work-area edge, clamps and other parts, because interlocked rows (staggered triangles, say) cannot slide straight past their neighbors to the next row. It can land off the plate or on a part; the layout check reports that as it does for any nudge.
- 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.
+- The best-fit pair search (`RotationSlideStrategy`) starts each slide from an offset on its step grid, then slides every resting position sideways, both ways, and keeps the new resting positions as extra candidates. A pair that locks in a corner (two interlocking channels) therefore closes to the part spacing on both axes instead of stopping up to a step short. This relies on leaving an existing contact being legal.
- 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.
- Shift-click while cloning parts settles the copied group toward the plate quadrant. If its starting bounds overlap any placed part's bounds, it skips the coarse bounding-box pass and uses geometry directly; otherwise each axis order starts with a coarse box pass. Each candidate then alternates geometry pushes until movement is negligible (at most 20 iterations). The coarse pass remains useful for avoiding sawtooth/rung traps when the starting boxes do not overlap. A box push also stops at an obstacle whose box is beside the path but nearer than the part spacing, so the coarse pass cannot park the copy inside a neighbor's spacing. A push ignores a part the copy starts on, so a candidate can end clear of that part yet inside its spacing: a candidate that keeps the part spacing (measured on `NativeContours`, as the spacing review measures it) always beats one that does not, and a named order that ends too close falls back to the other order. When no order keeps the spacing the copy still lands too close; this is not overlap repair.