diff --git a/OpenNest.Engine/Fill/Compactor.cs b/OpenNest.Engine/Fill/Compactor.cs
index eafa83aa..7929a323 100644
--- a/OpenNest.Engine/Fill/Compactor.cs
+++ b/OpenNest.Engine/Fill/Compactor.cs
@@ -390,29 +390,43 @@ namespace OpenNest.Engine.Fill
}
///
- /// 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.
+ /// Settles a copied placement against the plate.
+ /// tries both axis orders and keeps the group closest to the quadrant's work-area
+ /// corner; the other orders push along the named axis first. A coarse box pass
+ /// runs only when no moving box starts inside an existing part's box. With
+ /// every push uses bounding boxes alone, so the
+ /// group stops at the part spacing from obstacles' boxes, never nesting into their shapes.
///
public static void SettlePlacement(
List movingParts,
Plate plate,
PushDirection horizontal,
PushDirection vertical,
- int maxIterations = 20
+ int maxIterations = 20,
+ SettleOrder order = SettleOrder.ClosestToCorner,
+ bool boundingBoxesOnly = false
)
{
if (movingParts.Count == 0)
return;
var workArea = plate.WorkArea();
- var skipBoxes = movingParts.Any(moving =>
+ var skipBoxes = boundingBoxesOnly || movingParts.Any(moving =>
plate.Parts.Any(obstacle => moving.BoundingBox.Intersects(obstacle.BoundingBox))
);
+ double PushOnce(List trial, PushDirection direction) => boundingBoxesOnly
+ ? PushBoundingBox(trial, plate, direction)
+ : Push(trial, plate, direction);
var bestScore = double.MaxValue;
Vector[] best = null;
+ var firstAxes = order switch
+ {
+ SettleOrder.HorizontalFirst => new[] { horizontal },
+ SettleOrder.VerticalFirst => new[] { vertical },
+ _ => new[] { horizontal, vertical },
+ };
- foreach (var first in new[] { horizontal, vertical })
+ foreach (var first in firstAxes)
{
var second = first == horizontal ? vertical : horizontal;
var trial = movingParts.Select(p => (Part)p.Clone()).ToList();
@@ -424,7 +438,7 @@ namespace OpenNest.Engine.Fill
for (var i = 0; i < maxIterations; i++)
{
- var moved = Push(trial, plate, first) + Push(trial, plate, second);
+ var moved = PushOnce(trial, first) + PushOnce(trial, second);
if (moved < 0.01)
break;
}
diff --git a/OpenNest.Engine/Fill/SettleOrder.cs b/OpenNest.Engine/Fill/SettleOrder.cs
new file mode 100644
index 00000000..20c5c57b
--- /dev/null
+++ b/OpenNest.Engine/Fill/SettleOrder.cs
@@ -0,0 +1,18 @@
+namespace OpenNest.Engine.Fill
+{
+ ///
+ /// How orders its pushes. Directions follow
+ /// the plate's origin quadrant: "horizontal" is toward the origin's side, as is "vertical".
+ ///
+ public enum SettleOrder
+ {
+ /// Try both orders and keep the placement closest to the origin corner.
+ ClosestToCorner,
+
+ /// Push horizontally first, then vertically, repeating until settled.
+ HorizontalFirst,
+
+ /// Push vertically first, then horizontally, repeating until settled.
+ VerticalFirst,
+ }
+}
diff --git a/OpenNest.Tests/Fill/CompactorTests.cs b/OpenNest.Tests/Fill/CompactorTests.cs
index beb824ee..4bd75ccd 100644
--- a/OpenNest.Tests/Fill/CompactorTests.cs
+++ b/OpenNest.Tests/Fill/CompactorTests.cs
@@ -644,6 +644,58 @@ namespace OpenNest.Tests.Fill
Assert.Equal(start, moving.Location);
}
+ // Two pockets: pushing left first parks the part on the tall block at (20, 55);
+ // pushing down first parks it on the low floor at (55, 10), nearer the origin.
+ private static Plate TwoPocketPlate()
+ {
+ var plate = new Plate(100, 100);
+ plate.Parts.Add(MakeRectPart(0, 0, 20, 100));
+ plate.Parts.Add(MakeRectPart(20, 0, 35, 55));
+ plate.Parts.Add(MakeRectPart(55, 0, 45, 10));
+ return plate;
+ }
+
+ [Theory]
+ [InlineData(SettleOrder.ClosestToCorner, 55, 10)]
+ [InlineData(SettleOrder.HorizontalFirst, 20, 55)]
+ [InlineData(SettleOrder.VerticalFirst, 55, 10)]
+ public void SettlePlacement_OrderChoosesThePocket(SettleOrder order, double left, double bottom)
+ {
+ var plate = TwoPocketPlate();
+ var moving = MakeRectPart(60, 60, 10, 10);
+
+ Compactor.SettlePlacement(new List { moving }, plate,
+ PushDirection.Left, PushDirection.Down, order: order);
+
+ Assert.Equal(left, moving.BoundingBox.Left, 3);
+ Assert.Equal(bottom, moving.BoundingBox.Bottom, 3);
+ Assert.All(plate.Parts, obstacle => Assert.False(moving.Intersects(obstacle, out _)));
+ }
+
+ [Fact]
+ public void SettlePlacement_BoundingBoxesOnlyStopsAtTheObstacleBox()
+ {
+ // Triangle material is below the diagonal; geometry lets the square slide in
+ // over the hypotenuse, boxes stop it square to the triangle's bounds. The floor
+ // keeps both above y = 5.
+ var plate = new Plate(100, 100);
+ plate.Parts.Add(MakeTrianglePart(new Vector(0, 0),
+ new Vector(40, 0), new Vector(0, 40)));
+ plate.Parts.Add(MakeRectPart(40, 0, 60, 5));
+ var geometry = MakeRectPart(50, 6, 5, 5);
+ var boxes = MakeRectPart(50, 6, 5, 5);
+
+ Compactor.SettlePlacement(new List { geometry }, plate,
+ PushDirection.Left, PushDirection.Down, order: SettleOrder.HorizontalFirst);
+ Compactor.SettlePlacement(new List { boxes }, plate,
+ PushDirection.Left, PushDirection.Down, order: SettleOrder.HorizontalFirst,
+ boundingBoxesOnly: true);
+
+ Assert.True(geometry.BoundingBox.Left < 39, $"geometry left {geometry.BoundingBox.Left}");
+ Assert.InRange(boxes.BoundingBox.Left, 40, 40.01);
+ Assert.InRange(boxes.BoundingBox.Bottom, 5, 5.01);
+ }
+
[Fact]
public void PushBoundingBox_Left_MovesPartTowardEdge()
{
diff --git a/OpenNest.WinForms.Tests/Controls/ActionCloneTests.cs b/OpenNest.WinForms.Tests/Controls/ActionCloneTests.cs
index f6592d37..48082853 100644
--- a/OpenNest.WinForms.Tests/Controls/ActionCloneTests.cs
+++ b/OpenNest.WinForms.Tests/Controls/ActionCloneTests.cs
@@ -92,6 +92,28 @@ public class ActionCloneTests
Assert.Single(plate.Parts);
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
+ [Theory]
+ [InlineData(Keys.Shift)]
+ [InlineData(Keys.Control)]
+ public void ModifiedClickPushesTheCopyAgainstPlacedParts(Keys modifiers) => StaTestThread.Run(() =>
+ {
+ var plate = new Plate(48, 96) { PartSpacing = 0 };
+ plate.Parts.Add(new Part(Square("placed")));
+ using var view = new PlateView { Plate = plate, Size = new System.Drawing.Size(600, 600) };
+ view.ZoomToFit();
+ view.SetAction(typeof(ActionClone), Square("new"));
+ var action = Assert.IsType(view.Actions.CurrentAction);
+ action.PlacementOrder = OpenNest.Engine.Fill.SettleOrder.VerticalFirst;
+ var preview = Assert.Single(view.SelectedParts);
+ preview.BasePart.Location = new Vector(20, 1);
+
+ action.Apply(modifiers);
+
+ var copy = plate.Parts[1].BoundingBox;
+ Assert.InRange(copy.Left, 4, 4.01);
+ Assert.Equal(0, copy.Bottom, 6);
+ }, TimeSpan.FromMinutes(1), "The STA test did not complete.");
+
[Fact]
public void ADialogsFakeMouseMoveLeavesNudgedPreviewsInPlace() => StaTestThread.Run(() =>
{
diff --git a/OpenNest/Actions/ActionClone.cs b/OpenNest/Actions/ActionClone.cs
index f1483c5f..ac7dae1c 100644
--- a/OpenNest/Actions/ActionClone.cs
+++ b/OpenNest/Actions/ActionClone.cs
@@ -200,11 +200,30 @@ namespace OpenNest.Actions
change.Complete();
}
- public void Apply()
+ ///
+ /// Which way Shift+click and Ctrl+click push the copy; null reads the user's Options
+ /// setting at each click, so a change there applies without restarting the action.
+ ///
+ public SettleOrder? PlacementOrder { get; set; }
+
+ // An unknown stored name falls back to the closest-to-origin search.
+ internal static SettleOrder ReadPlacementOrder() =>
+ System.Enum.TryParse(Properties.Settings.Default.ClonePlacementOrder, out var order)
+ && System.Enum.IsDefined(order)
+ ? order
+ : SettleOrder.ClosestToCorner;
+
+ public void Apply() => Apply(Control.ModifierKeys);
+
+ // Shift settles on the parts' geometry (a box pass first when clear); Ctrl settles on
+ // bounding boxes alone, so the copy stops square to its neighbors' boxes.
+ internal void Apply(Keys modifiers)
{
using var change = plateView.BeginChange(parts.Count == 1 ? "Add Part" : "Add Parts");
change.MarkChanged();
- if ((Control.ModifierKeys & Keys.Shift) == Keys.Shift)
+ var shift = (modifiers & Keys.Shift) == Keys.Shift;
+ var boxesOnly = !shift && (modifiers & Keys.Control) == Keys.Control;
+ if (shift || boxesOnly)
{
var movingParts = parts.Select(p => p.BasePart).ToList();
@@ -234,7 +253,8 @@ namespace OpenNest.Actions
break;
}
- Compactor.SettlePlacement(movingParts, plateView.Plate, hDir, vDir);
+ Compactor.SettlePlacement(movingParts, plateView.Plate, hDir, vDir,
+ order: PlacementOrder ?? ReadPlacementOrder(), boundingBoxesOnly: boxesOnly);
parts.ForEach(p => p.IsDirty = true);
plateView.Invalidate();
diff --git a/OpenNest/Forms/OptionsForm.Designer.cs b/OpenNest/Forms/OptionsForm.Designer.cs
index 818a499e..1a07e013 100644
--- a/OpenNest/Forms/OptionsForm.Designer.cs
+++ b/OpenNest/Forms/OptionsForm.Designer.cs
@@ -43,6 +43,8 @@
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
this.colorSchemeLabel = new System.Windows.Forms.Label();
this.colorSchemeCombo = new System.Windows.Forms.ComboBox();
+ this.clonePlacementLabel = new System.Windows.Forms.Label();
+ this.clonePlacementCombo = new System.Windows.Forms.ComboBox();
this.serverGroupBox = new System.Windows.Forms.GroupBox();
this.serverUrlBox = new System.Windows.Forms.TextBox();
this.setServerButton = new System.Windows.Forms.Button();
@@ -71,7 +73,7 @@
this.checkBox1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
this.checkBox1.AutoSize = true;
this.tableLayoutPanel1.SetColumnSpan(this.checkBox1, 2);
- this.checkBox1.Location = new System.Drawing.Point(3, 165);
+ this.checkBox1.Location = new System.Drawing.Point(3, 213);
this.checkBox1.Name = "checkBox1";
this.checkBox1.Size = new System.Drawing.Size(281, 20);
this.checkBox1.TabIndex = 7;
@@ -143,16 +145,19 @@
this.tableLayoutPanel1.Controls.Add(this.colorSchemeCombo, 1, 1);
this.tableLayoutPanel1.Controls.Add(this.planningEffortLabel, 0, 2);
this.tableLayoutPanel1.Controls.Add(this.planningEffortBox, 1, 2);
- this.tableLayoutPanel1.Controls.Add(this.checkBox1, 0, 3);
+ this.tableLayoutPanel1.Controls.Add(this.clonePlacementLabel, 0, 3);
+ this.tableLayoutPanel1.Controls.Add(this.clonePlacementCombo, 1, 3);
+ this.tableLayoutPanel1.Controls.Add(this.checkBox1, 0, 4);
this.tableLayoutPanel1.Controls.Add(this.numericUpDown1, 1, 0);
this.tableLayoutPanel1.Location = new System.Drawing.Point(12, 12);
this.tableLayoutPanel1.Name = "tableLayoutPanel1";
- this.tableLayoutPanel1.RowCount = 4;
- this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
- this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
- this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
- this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
- this.tableLayoutPanel1.Size = new System.Drawing.Size(684, 200);
+ this.tableLayoutPanel1.RowCount = 5;
+ this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 20F));
+ this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 20F));
+ this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 20F));
+ this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 20F));
+ this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 20F));
+ this.tableLayoutPanel1.Size = new System.Drawing.Size(684, 240);
this.tableLayoutPanel1.TabIndex = 0;
//
// saveButton
@@ -185,7 +190,7 @@
this.bottomPanel1.Controls.Add(this.cancelButton);
this.bottomPanel1.Controls.Add(this.saveButton);
this.bottomPanel1.Dock = System.Windows.Forms.DockStyle.Bottom;
- this.bottomPanel1.Location = new System.Drawing.Point(0, 647);
+ this.bottomPanel1.Location = new System.Drawing.Point(0, 715);
this.bottomPanel1.Name = "bottomPanel1";
this.bottomPanel1.Size = new System.Drawing.Size(708, 50);
this.bottomPanel1.TabIndex = 1;
@@ -204,7 +209,7 @@
// strategyGroupBox
//
this.strategyGroupBox.Controls.Add(this.strategyGrid);
- this.strategyGroupBox.Location = new System.Drawing.Point(12, 485);
+ this.strategyGroupBox.Location = new System.Drawing.Point(12, 525);
this.strategyGroupBox.Name = "strategyGroupBox";
this.strategyGroupBox.Size = new System.Drawing.Size(684, 180);
this.strategyGroupBox.TabIndex = 2;
@@ -230,11 +235,32 @@
this.colorSchemeCombo.Size = new System.Drawing.Size(130, 24);
this.colorSchemeCombo.TabIndex = 11;
//
+ // clonePlacementLabel
+ //
+ this.clonePlacementLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
+ this.clonePlacementLabel.AutoSize = true;
+ this.clonePlacementLabel.Location = new System.Drawing.Point(3, 172);
+ this.clonePlacementLabel.Name = "clonePlacementLabel";
+ this.clonePlacementLabel.Size = new System.Drawing.Size(145, 16);
+ this.clonePlacementLabel.TabIndex = 14;
+ this.clonePlacementLabel.Text = "Clone push placement:";
+ //
+ // clonePlacementCombo
+ //
+ this.clonePlacementCombo.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
+ this.clonePlacementCombo.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
+ this.clonePlacementCombo.Location = new System.Drawing.Point(154, 168);
+ this.clonePlacementCombo.Name = "clonePlacementCombo";
+ this.clonePlacementCombo.Size = new System.Drawing.Size(130, 24);
+ this.clonePlacementCombo.TabIndex = 15;
+ this.toolTip1.SetToolTip(this.clonePlacementCombo, "Where Clone Parts pushes the copy on Shift+click (part geometry) "
+ + "or Ctrl+click (bounding boxes). Left and down mirror for plates whose origin is in another corner.");
+ //
// serverGroupBox
//
this.serverGroupBox.Controls.Add(this.serverUrlBox);
this.serverGroupBox.Controls.Add(this.setServerButton);
- this.serverGroupBox.Location = new System.Drawing.Point(12, 218);
+ this.serverGroupBox.Location = new System.Drawing.Point(12, 258);
this.serverGroupBox.Name = "serverGroupBox";
this.serverGroupBox.Size = new System.Drawing.Size(684, 69);
this.serverGroupBox.TabIndex = 1;
@@ -264,7 +290,7 @@
this.updateGroupBox.Controls.Add(this.updateFeedUrlBox);
this.updateGroupBox.Controls.Add(this.setUpdateFeedButton);
this.updateGroupBox.Controls.Add(this.includeDevBuildsBox);
- this.updateGroupBox.Location = new System.Drawing.Point(12, 391);
+ this.updateGroupBox.Location = new System.Drawing.Point(12, 431);
this.updateGroupBox.Name = "updateGroupBox";
this.updateGroupBox.Size = new System.Drawing.Size(684, 88);
this.updateGroupBox.TabIndex = 3;
@@ -307,7 +333,7 @@
this.ledgerGroupBox.Controls.Add(this.ledgerKeyBox);
this.ledgerGroupBox.Controls.Add(this.testLedgerButton);
this.ledgerGroupBox.Controls.Add(this.saveLedgerButton);
- this.ledgerGroupBox.Location = new System.Drawing.Point(12, 293);
+ this.ledgerGroupBox.Location = new System.Drawing.Point(12, 333);
this.ledgerGroupBox.Name = "ledgerGroupBox";
this.ledgerGroupBox.Size = new System.Drawing.Size(684, 92);
this.ledgerGroupBox.TabIndex = 4;
@@ -374,7 +400,7 @@
this.AcceptButton = this.saveButton;
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
this.CancelButton = this.cancelButton;
- this.ClientSize = new System.Drawing.Size(708, 725);
+ this.ClientSize = new System.Drawing.Size(708, 765);
this.Controls.Add(this.strategyGroupBox);
this.Controls.Add(this.updateGroupBox);
this.Controls.Add(this.ledgerGroupBox);
@@ -421,6 +447,8 @@
private System.Windows.Forms.GroupBox strategyGroupBox;
private System.Windows.Forms.Label colorSchemeLabel;
private System.Windows.Forms.ComboBox colorSchemeCombo;
+ private System.Windows.Forms.Label clonePlacementLabel;
+ private System.Windows.Forms.ComboBox clonePlacementCombo;
private System.Windows.Forms.GroupBox serverGroupBox;
private System.Windows.Forms.TextBox serverUrlBox;
private System.Windows.Forms.Button setServerButton;
diff --git a/OpenNest/Forms/OptionsForm.cs b/OpenNest/Forms/OptionsForm.cs
index f01afdb3..bc9634e1 100644
--- a/OpenNest/Forms/OptionsForm.cs
+++ b/OpenNest/Forms/OptionsForm.cs
@@ -3,10 +3,12 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Windows.Forms;
+using OpenNest.Actions;
using OpenNest.Controls;
using OpenNest.Data;
using OpenNest.Data.MaterialLedger;
using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Engine.Fill;
using OpenNest.Engine.Strategies;
using OpenNest.Properties;
@@ -14,6 +16,14 @@ namespace OpenNest.Forms
{
public partial class OptionsForm : Form
{
+ // Combo order matches these; the setting stores the enum name.
+ private static readonly (SettleOrder Order, string Text)[] ClonePlacements =
+ {
+ (SettleOrder.ClosestToCorner, "Closest to origin"),
+ (SettleOrder.HorizontalFirst, "Push left, then down"),
+ (SettleOrder.VerticalFirst, "Push down, then left"),
+ };
+
public OptionsForm()
{
InitializeComponent();
@@ -96,6 +106,13 @@ namespace OpenNest.Forms
var idx = colorSchemeCombo.Items.IndexOf(active);
colorSchemeCombo.SelectedIndex = idx >= 0 ? idx : 0;
+ clonePlacementCombo.Items.Clear();
+ foreach (var (_, text) in ClonePlacements)
+ clonePlacementCombo.Items.Add(text);
+ var placement = ActionClone.ReadPlacementOrder();
+ clonePlacementCombo.SelectedIndex = System.Math.Max(0,
+ System.Array.FindIndex(ClonePlacements, p => p.Order == placement));
+
var disabledNames = ParseDisabledStrategies(Settings.Default.DisabledStrategies);
foreach (DataGridViewRow row in strategyGrid.Rows)
row.Cells["Enabled"].Value = !disabledNames.Contains(
@@ -111,6 +128,8 @@ namespace OpenNest.Forms
Settings.Default.CuttingPlanEffort = (double)planningEffortBox.Value;
Settings.Default.ActiveColorScheme =
colorSchemeCombo.SelectedItem as string ?? "Classic";
+ Settings.Default.ClonePlacementOrder =
+ ClonePlacements[System.Math.Max(0, clonePlacementCombo.SelectedIndex)].Order.ToString();
var disabledNames = new List();
foreach (DataGridViewRow row in strategyGrid.Rows)
diff --git a/OpenNest/Properties/Settings.Designer.cs b/OpenNest/Properties/Settings.Designer.cs
index 06bc4e7e..39d0fe9b 100644
--- a/OpenNest/Properties/Settings.Designer.cs
+++ b/OpenNest/Properties/Settings.Designer.cs
@@ -488,5 +488,18 @@ namespace OpenNest.Properties {
this["MadeBy"] = value;
}
}
+
+ [global::System.Configuration.UserScopedSettingAttribute()]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Configuration.DefaultSettingValueAttribute("ClosestToCorner")]
+ // Where Shift+click in Clone Parts pushes the copy: an OpenNest.Engine.Fill.SettleOrder name.
+ public string ClonePlacementOrder {
+ get {
+ return ((string)(this["ClonePlacementOrder"]));
+ }
+ set {
+ this["ClonePlacementOrder"] = value;
+ }
+ }
}
}
diff --git a/OpenNest/Properties/Settings.settings b/OpenNest/Properties/Settings.settings
index a8b1abad..bec30191 100644
--- a/OpenNest/Properties/Settings.settings
+++ b/OpenNest/Properties/Settings.settings
@@ -116,5 +116,8 @@
+
+ ClosestToCorner
+
diff --git a/docs/geometry/directional-slides.md b/docs/geometry/directional-slides.md
index 8bc53084..f5269a74 100644
--- a/docs/geometry/directional-slides.md
+++ b/docs/geometry/directional-slides.md
@@ -16,7 +16,9 @@ Open/incomplete chains and ambiguous contacts conservatively block. Loops are re
- 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.
-- 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.
+- 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.
+- Ctrl-click settles the same way on bounding boxes alone (`SettlePlacement(boundingBoxesOnly: true)`): every push stops at the part spacing from obstacles' boxes, so the copy never nests into a neighbor's shape. A group whose box already overlaps an obstacle's box does not move.
+- Options > Clone push placement (`ClonePlacementOrder`, a `SettleOrder` name) picks the axis order for both: *Closest to origin* (default) tries horizontal-first and vertical-first and keeps the group nearest the quadrant's work-area corner; *Push left, then down* and *Push down, then left* run only that order. Left and down mirror with the plate quadrant.
## 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.