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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-11 07:53:24 -05:00
1 parent beccbae106
commit cafb34b510
10 files changed
+216 -25

No files matched your search

+21 -7
View File
@@ -390,29 +390,43 @@ namespace OpenNest.Engine.Fill
}
/// <summary>
/// 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. <see cref="SettleOrder.ClosestToCorner"/>
/// 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
/// <paramref name="boundingBoxesOnly"/> every push uses bounding boxes alone, so the
/// group stops at the part spacing from obstacles' boxes, never nesting into their shapes.
/// </summary>
public static void SettlePlacement(
List<Part> 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<Part> 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;
}
+18
View File
@@ -0,0 +1,18 @@
namespace OpenNest.Engine.Fill
{
/// <summary>
/// How <see cref="Compactor.SettlePlacement"/> orders its pushes. Directions follow
/// the plate's origin quadrant: "horizontal" is toward the origin's side, as is "vertical".
/// </summary>
public enum SettleOrder
{
/// <summary>Try both orders and keep the placement closest to the origin corner.</summary>
ClosestToCorner,
/// <summary>Push horizontally first, then vertically, repeating until settled.</summary>
HorizontalFirst,
/// <summary>Push vertically first, then horizontally, repeating until settled.</summary>
VerticalFirst,
}
}
+52
View File
@@ -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<Part> { 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<Part> { geometry }, plate,
PushDirection.Left, PushDirection.Down, order: SettleOrder.HorizontalFirst);
Compactor.SettlePlacement(new List<Part> { 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()
{
@@ -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<ActionClone>(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(() =>
{
+23 -3
View File
@@ -200,11 +200,30 @@ namespace OpenNest.Actions
change.Complete();
}
public void Apply()
/// <summary>
/// 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.
/// </summary>
public SettleOrder? PlacementOrder { get; set; }
// An unknown stored name falls back to the closest-to-origin search.
internal static SettleOrder ReadPlacementOrder() =>
System.Enum.TryParse<SettleOrder>(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();
+42 -14
View File
@@ -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;
+19
View File
@@ -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<string>();
foreach (DataGridViewRow row in strategyGrid.Rows)
+13
View File
@@ -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;
}
}
}
}
+3
View File
@@ -116,5 +116,8 @@
<Setting Name="MadeBy" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
<Setting Name="ClonePlacementOrder" Type="System.String" Scope="User">
<Value Profile="(Default)">ClosestToCorner</Value>
</Setting>
</Settings>
</SettingsFile>
+3 -1
View File
@@ -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.