From cafb34b51091ecc736dea3abeafbe25366244c72 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 11 Nov 2026 07:53:24 -0500 Subject: [PATCH] feat(clone): choose Shift+click push order and add box-only Ctrl+click Shift+click in Clone Parts always kept whichever axis order landed the copy nearest the origin, which can park it in a pocket the user didn't want. Options > Clone push placement now picks Closest to origin (the old behavior, still the default), Push left then down, or Push down then left. Ctrl+click settles the copy on bounding boxes alone, so it stops square to its neighbors' boxes instead of nesting into their shapes. Shift+click keeps its box pass then geometry settle; both follow the chosen order. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Engine/Fill/Compactor.cs | 28 +++++++--- OpenNest.Engine/Fill/SettleOrder.cs | 18 ++++++ OpenNest.Tests/Fill/CompactorTests.cs | 52 +++++++++++++++++ .../Controls/ActionCloneTests.cs | 22 ++++++++ OpenNest/Actions/ActionClone.cs | 26 ++++++++- OpenNest/Forms/OptionsForm.Designer.cs | 56 ++++++++++++++----- OpenNest/Forms/OptionsForm.cs | 19 +++++++ OpenNest/Properties/Settings.Designer.cs | 13 +++++ OpenNest/Properties/Settings.settings | 3 + docs/geometry/directional-slides.md | 4 +- 10 files changed, 216 insertions(+), 25 deletions(-) create mode 100644 OpenNest.Engine/Fill/SettleOrder.cs 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.