diff --git a/OpenNest.Engine/Fill/Compactor.cs b/OpenNest.Engine/Fill/Compactor.cs
index 8593fb6..dc3b019 100644
--- a/OpenNest.Engine/Fill/Compactor.cs
+++ b/OpenNest.Engine/Fill/Compactor.cs
@@ -290,6 +290,63 @@ namespace OpenNest.Engine.Fill
return 0;
}
+ ///
+ /// Settles a copied placement against the plate in both axis orders, choosing
+ /// the group closest to the quadrant's work-area corner. A coarse box pass
+ /// runs only when no moving box starts inside an existing part's box.
+ ///
+ public static void SettlePlacement(
+ List movingParts,
+ Plate plate,
+ PushDirection horizontal,
+ PushDirection vertical,
+ int maxIterations = 20
+ )
+ {
+ if (movingParts.Count == 0)
+ return;
+
+ var workArea = plate.WorkArea();
+ var skipBoxes = movingParts.Any(moving =>
+ plate.Parts.Any(obstacle => moving.BoundingBox.Intersects(obstacle.BoundingBox))
+ );
+ var bestScore = double.MaxValue;
+ Vector[] best = null;
+
+ foreach (var first in new[] { horizontal, vertical })
+ {
+ var second = first == horizontal ? vertical : horizontal;
+ var trial = movingParts.Select(p => (Part)p.Clone()).ToList();
+ if (!skipBoxes)
+ {
+ PushBoundingBox(trial, plate, first);
+ PushBoundingBox(trial, plate, second);
+ }
+
+ for (var i = 0; i < maxIterations; i++)
+ {
+ var moved = Push(trial, plate, first) + Push(trial, plate, second);
+ if (moved < 0.01)
+ break;
+ }
+
+ var bounds = trial.GetBoundingBox();
+ var dx = horizontal == PushDirection.Left
+ ? bounds.Left - workArea.Left : workArea.Right - bounds.Right;
+ var dy = vertical == PushDirection.Down
+ ? bounds.Bottom - workArea.Bottom : workArea.Top - bounds.Top;
+ var score = dx * dx + dy * dy;
+ if (score < bestScore)
+ {
+ bestScore = score;
+ best = trial.Select(p => p.Location).ToArray();
+ }
+ }
+
+ for (var i = 0; i < movingParts.Count; i++)
+ movingParts[i].Location = best[i];
+ }
+
///
/// Repeatedly pushes parts left then down until total movement per
/// iteration falls below the given threshold.
diff --git a/OpenNest.Tests/Fill/CompactorTests.cs b/OpenNest.Tests/Fill/CompactorTests.cs
index 1128c77..591a14e 100644
--- a/OpenNest.Tests/Fill/CompactorTests.cs
+++ b/OpenNest.Tests/Fill/CompactorTests.cs
@@ -527,6 +527,72 @@ namespace OpenNest.Tests.Fill
Assert.True(part.BoundingBox.Bottom < 1);
}
+ [Fact]
+ public void SettlePlacement_RepeatsAfterVerticalMovementOpensHorizontalPath()
+ {
+ var plate = new Plate(100, 100);
+ plate.Parts.Add(MakeRectPart(20, 20, 20, 30));
+ var moving = MakeRectPart(60, 35, 10, 10);
+
+ Compactor.SettlePlacement(new List { moving }, plate,
+ PushDirection.Left, PushDirection.Down);
+
+ Assert.Equal(0, moving.BoundingBox.Left, 6);
+ Assert.Equal(0, moving.BoundingBox.Bottom, 6);
+ Assert.False(moving.Intersects(plate.Parts[0], out _));
+ }
+
+ [Fact]
+ public void SettlePlacement_MovesCopiedGroupTogether()
+ {
+ var plate = new Plate(100, 100);
+ var left = MakeRectPart(40, 40, 5, 5);
+ var right = MakeRectPart(50, 40, 5, 5);
+ var originalGap = right.Location.X - left.Location.X;
+
+ Compactor.SettlePlacement(new List { left, right }, plate,
+ PushDirection.Left, PushDirection.Down);
+
+ Assert.Equal(0, left.BoundingBox.Left, 6);
+ Assert.Equal(0, left.BoundingBox.Bottom, 6);
+ Assert.Equal(originalGap, right.Location.X - left.Location.X, 6);
+ }
+
+ [Fact]
+ public void SettlePlacement_GeometryRepeatsWhenInitialBoxPassIsSkipped()
+ {
+ var plate = new Plate(100, 100);
+ plate.Parts.Add(MakeRectPart(20, 20, 20, 30));
+ // Its bounding box covers the start, but its material is above it.
+ plate.Parts.Add(MakeTrianglePart(new Vector(0, 100),
+ new Vector(100, 100), new Vector(100, 40)));
+ var moving = MakeRectPart(60, 35, 10, 10);
+
+ Compactor.SettlePlacement(new List { moving }, plate,
+ PushDirection.Left, PushDirection.Down);
+
+ Assert.Equal(0, moving.BoundingBox.Left, 6);
+ Assert.Equal(0, moving.BoundingBox.Bottom, 6);
+ Assert.All(plate.Parts, obstacle => Assert.False(moving.Intersects(obstacle, out _)));
+ }
+
+ [Fact]
+ public void SettlePlacement_SkipsBoxPassWhenAlreadyInsideObstacleBounds()
+ {
+ // Triangle material sits below the diagonal; the free pocket at (25,25)
+ // is within its bounding box. A box-only slide would falsely block it.
+ var plate = new Plate(100, 100);
+ plate.Parts.Add(MakeTrianglePart(new Vector(0, 0),
+ new Vector(40, 0), new Vector(0, 40)));
+ var moving = MakeRectPart(25, 25, 5, 5);
+ var start = moving.Location;
+
+ Compactor.SettlePlacement(new List { moving }, plate,
+ PushDirection.Left, PushDirection.Down, maxIterations: 0);
+
+ Assert.Equal(start, moving.Location);
+ }
+
[Fact]
public void PushBoundingBox_Left_MovesPartTowardEdge()
{
diff --git a/OpenNest/Actions/ActionClone.cs b/OpenNest/Actions/ActionClone.cs
index baabd13..c57cec9 100644
--- a/OpenNest/Actions/ActionClone.cs
+++ b/OpenNest/Actions/ActionClone.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Windows.Forms;
@@ -178,11 +178,7 @@ namespace OpenNest.Actions
break;
}
- Compactor.PushBoundingBox(movingParts, plateView.Plate, hDir);
- Compactor.PushBoundingBox(movingParts, plateView.Plate, vDir);
-
- Compactor.Push(movingParts, plateView.Plate, hDir);
- Compactor.Push(movingParts, plateView.Plate, vDir);
+ Compactor.SettlePlacement(movingParts, plateView.Plate, hDir, vDir);
parts.ForEach(p => p.IsDirty = true);
plateView.Invalidate();
diff --git a/docs/geometry/directional-slides.md b/docs/geometry/directional-slides.md
index 551ef94..9cc63c0 100644
--- a/docs/geometry/directional-slides.md
+++ b/docs/geometry/directional-slides.md
@@ -15,8 +15,9 @@ Open/incomplete chains and ambiguous contacts conservatively block. This is not
- 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
+- 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 it tries coarse horizontal/vertical and vertical/horizontal orders. Each candidate then alternates geometry pushes until movement is negligible (at most 20 iterations), and the group nearest the quadrant's work-area corner wins. The coarse pass remains useful for avoiding sawtooth/rung traps when the starting boxes do not overlap.
+## 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.