feat(nudge): step the selection by a calculated distance

Set Offset Increment gains a "Calculated distance" option. With it on, the
arrow keys move the selection by its own size plus the part spacing,
measured on its geometry like a fill's copy distance, so it lands tight
without a push back. The move stops early at an edge, clamp or part, and a
clone preview steps clear of the copy just placed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-04 22:21:56 -05:00
1 parent b5bc03bea1
commit d98bee87ec
13 files changed
+398 -16

No files matched your search

@@ -0,0 +1,110 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
/// <summary>
/// Compactor.Step moves a selection by its own repeat distance at the part spacing,
/// and like a push never passes the work-area edge or another part.
/// </summary>
public class CompactorStepTests
{
// Plate: 24 x 24, 0.5 edge spacing, part spacing 1.0; work area 0.5..23.5.
private static Plate MakePlate()
{
var plate = new Plate(new Size(24, 24));
plate.EdgeSpacing = new Spacing(0.5, 0.5);
plate.PartSpacing = 1.0;
return plate;
}
private static Program Polygon(params (double X, double Y)[] points)
{
var program = new Program();
program.MoveTo(points[0].X, points[0].Y);
for (var i = 1; i < points.Length; i++)
program.LineTo(points[i].X, points[i].Y);
program.LineTo(points[0].X, points[0].Y);
return program;
}
private static Part Add(Plate plate, Program program, double x, double y)
{
var part = new Part(new Drawing($"p{plate.Parts.Count}", program), new Vector(x, y));
plate.Parts.Add(part);
return part;
}
private static Part AddSquare(Plate plate, double x, double y) =>
Add(plate, Polygon((0, 0), (3, 0), (3, 3), (0, 3)), x, y);
[Theory]
[InlineData(PushDirection.Right, 4, 0)]
[InlineData(PushDirection.Left, -4, 0)]
[InlineData(PushDirection.Up, 0, 4)]
[InlineData(PushDirection.Down, 0, -4)]
public void SquareStepsItsSizePlusTheSpacing(PushDirection direction, double dx, double dy)
{
var plate = MakePlate();
var part = AddSquare(plate, 10, 10);
var moved = Compactor.Step(new List<Part> { part }, plate, direction);
Assert.Equal(4, moved, 3);
Assert.Equal(10 + dx, part.Location.X, 3);
Assert.Equal(10 + dy, part.Location.Y, 3);
}
[Fact]
public void ShapeThatNestsIntoItsCopyStepsLessThanItsSize()
{
// Parallelogram 6 wide whose slanted sides run along (2, 3): a copy beside it
// needs only sqrt(13)/3 beyond the 4-unit base for a 1-unit gap between them.
var plate = MakePlate();
var part = Add(plate, Polygon((0, 0), (4, 0), (6, 3), (2, 3)), 2, 10);
var moved = Compactor.Step(new List<Part> { part }, plate, PushDirection.Right);
Assert.Equal(4 + System.Math.Sqrt(13) / 3, moved, 2);
}
[Fact]
public void GroupStepsAsOnePatternKeepingItsOwnSpacing()
{
var plate = MakePlate();
var a = AddSquare(plate, 2, 10);
var b = AddSquare(plate, 6, 10);
var moved = Compactor.Step(new List<Part> { a, b }, plate, PushDirection.Right);
Assert.Equal(8, moved, 3);
Assert.Equal(10, a.Location.X, 3);
Assert.Equal(14, b.Location.X, 3);
}
[Fact]
public void StopsAtAnotherPartInsteadOfPassingIt()
{
var plate = MakePlate();
var part = AddSquare(plate, 2, 10);
AddSquare(plate, 7, 10); // 2 clear of the mover: it may close 1 at 1.0 spacing.
var moved = Compactor.Step(new List<Part> { part }, plate, PushDirection.Right);
Assert.Equal(1, moved, 3);
}
[Fact]
public void StopsAtTheWorkAreaEdge()
{
var plate = MakePlate();
var part = AddSquare(plate, 18, 10); // Right side at 21, 2.5 from the work area's.
var moved = Compactor.Step(new List<Part> { part }, plate, PushDirection.Right);
Assert.Equal(2.5, moved, 3);
}
}
+45 -7
View File
@@ -31,16 +31,46 @@ namespace OpenNest.Engine.Fill
public static double Push(List<Part> movingParts, Plate plate, double angle) =>
Push(movingParts, plate, new Vector(System.Math.Cos(angle), System.Math.Sin(angle)));
private static double Push(List<Part> movingParts, Plate plate, Vector direction)
private static double Push(List<Part> movingParts, Plate plate, Vector direction) =>
Push(movingParts, plate, direction, double.MaxValue);
private static double Push(List<Part> movingParts, Plate plate, Vector direction, double limit)
{
var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList();
return Push(movingParts, plate.WorkArea(), direction,
return Push(movingParts, plate.WorkArea(), direction, limit,
new ObstacleSet(obstacleParts, plate.PartSpacing), ZoneObstacles(plate));
}
/// <summary>
/// Moves movingParts one repeat step along <paramref name="direction"/>: the
/// distance at which a copy left behind would sit at the plate's part spacing,
/// measured on the parts' geometry like a fill's copy distance, so shapes that
/// nest into their own copy step less than their size. Like
/// <see cref="Push(List{Part}, Plate, PushDirection)"/>, the move stops early at
/// the work-area edge, a clamp zone or another part, never passing one.
/// Returns the distance moved.
/// </summary>
public static double Step(List<Part> movingParts, Plate plate, PushDirection direction)
{
if (movingParts.Count == 0)
return 0;
var pattern = new Pattern();
pattern.Parts.AddRange(movingParts);
pattern.UpdateBounds();
var axis = SpatialQuery.IsHorizontalDirection(direction)
? NestDirection.Horizontal
: NestDirection.Vertical;
var step = new FillLinear(plate.WorkArea(), plate.PartSpacing).CopyDistance(pattern, axis);
if (!double.IsFinite(step) || step <= 0)
return 0;
return Push(movingParts, plate, SpatialQuery.DirectionToOffset(direction, 1.0), step);
}
/// <summary>
/// Pushes movingParts along an arbitrary angle (radians, 0 = right, π/2 = up).
/// </summary>
@@ -52,19 +82,27 @@ namespace OpenNest.Engine.Fill
Vector direction
) => Push(movingParts, workArea, direction, new ObstacleSet(obstacleParts, partSpacing));
/// <summary>
/// Pushes movingParts to the first contact with the work-area edge or an obstacle,
/// keeping each obstacle set's own spacing.
/// </summary>
private static double Push(
List<Part> movingParts,
Box workArea,
Vector direction,
params ObstacleSet[] obstacleSets
) => Push(movingParts, workArea, direction, double.MaxValue, obstacleSets);
/// <summary>
/// Pushes movingParts at most <paramref name="limit"/> toward the first contact
/// with the work-area edge or an obstacle, keeping each obstacle set's own spacing.
/// </summary>
private static double Push(
List<Part> movingParts,
Box workArea,
Vector direction,
double limit,
params ObstacleSet[] obstacleSets
)
{
var opposite = -direction;
var distance = double.MaxValue;
var distance = limit;
foreach (var moving in movingParts)
{
+8
View File
@@ -196,6 +196,14 @@ namespace OpenNest.Engine.Fill
return maxCopyDistance;
}
/// <summary>
/// Geometry-aware distance between a pattern and its next copy along an axis at
/// this filler's part spacing. A copy is a translation, so the distance is the
/// same in the opposite direction.
/// </summary>
public double CopyDistance(Pattern pattern, NestDirection direction) =>
FindPatternCopyDistance(pattern, direction, new OffsetPerimeterCache(HalfSpacing));
/// <summary>
/// Tiles a pattern along the given axis, returning the cloned parts
/// (does not include the original pattern's parts). For multi-part
@@ -0,0 +1,85 @@
using System.Reflection;
using System.Windows.Forms;
using OpenNest.Actions;
using OpenNest.Controls;
using OpenNest.Geometry;
using static OpenNest.WinForms.Tests.FormTesting;
namespace OpenNest.WinForms.Tests.Controls;
/// <summary>
/// The arrow keys move the selection by the fixed offset increment, or, with the calculated
/// distance on, by the selection's own size plus the part spacing, so no push back is needed.
/// </summary>
public class PlateViewStepTests
{
private static Drawing Square(string name)
{
var program = new CNC.Program();
program.Codes.Add(new CNC.RapidMove(new Vector(0, 0)));
program.Codes.Add(new CNC.LinearMove(new Vector(4, 0)));
program.Codes.Add(new CNC.LinearMove(new Vector(4, 4)));
program.Codes.Add(new CNC.LinearMove(new Vector(0, 4)));
program.Codes.Add(new CNC.LinearMove(new Vector(0, 0)));
return new Drawing(name, program);
}
private static Plate MakePlate() => new(48, 96) { PartSpacing = 1 };
private static void Arrow(PlateView view, Keys key) =>
typeof(PlateView).GetMethod("ProcessDialogKey", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(view, new object[] { key });
[Fact]
public void ArrowKeysKeepTheOffsetIncrementByDefault() => StaTestThread.Run(() =>
{
var plate = MakePlate();
var part = new Part(Square("part"), new Vector(10, 10));
plate.Parts.Add(part);
using var view = new PlateView { Plate = plate, Size = new System.Drawing.Size(600, 600) };
view.SelectAll();
Arrow(view, Keys.Right);
Assert.Equal(10 + view.OffsetIncrementDistance, part.Location.X, 6);
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
[Theory]
[InlineData(Keys.Right, 15, 10)]
[InlineData(Keys.Left, 5, 10)]
[InlineData(Keys.Up, 10, 15)]
[InlineData(Keys.Down, 10, 5)]
public void CalculatedDistanceStepsTheSizePlusTheSpacing(Keys key, double x, double y) => StaTestThread.Run(() =>
{
var plate = MakePlate();
var part = new Part(Square("part"), new Vector(10, 10));
plate.Parts.Add(part);
using var view = new PlateView { Plate = plate, Size = new System.Drawing.Size(600, 600) };
view.CalculatedOffsetDistance = true;
view.SelectAll();
Arrow(view, key);
Assert.Equal(x, part.Location.X, 3);
Assert.Equal(y, part.Location.Y, 3);
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
[Fact]
public void CalculatedDistanceStepsAClonePreviewOffTheCopyJustPlaced() => StaTestThread.Run(() =>
{
var plate = MakePlate();
using var view = new PlateView { Plate = plate, Size = new System.Drawing.Size(600, 600) };
view.ZoomToFit();
view.CalculatedOffsetDistance = true;
view.SetAction(typeof(ActionClone), Square("new"));
Raise(view, "OnMouseMove", new MouseEventArgs(MouseButtons.None, 0, 300, 300, 0));
Raise(view, "OnKeyDown", new KeyEventArgs(Keys.F1));
Arrow(view, Keys.Right);
Raise(view, "OnKeyDown", new KeyEventArgs(Keys.F1));
Assert.Equal(2, plate.Parts.Count);
Assert.Equal(5, plate.Parts[1].Location.X - plate.Parts[0].Location.X, 3);
Assert.Equal(plate.Parts[0].Location.Y, plate.Parts[1].Location.Y, 6);
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
}
@@ -0,0 +1,43 @@
using System.Windows.Forms;
using OpenNest.Forms;
using static OpenNest.WinForms.Tests.FormTesting;
namespace OpenNest.WinForms.Tests.Forms;
public class SetValueFormTests
{
[Fact]
public void OptionShowsACheckBoxThatDisablesTheValue() => StaTestThread.Run(() =>
{
using var form = new SetValueForm { ShowInTaskbar = false };
var bare = form.ClientSize;
var option = Assert.Single(All<CheckBox>(form));
var value = Assert.Single(All<OpenNest.Controls.NumericUpDown>(form));
form.OptionText = "Calculated distance (selection size + part spacing)";
form.OptionText = "Calculated distance (selection size + part spacing)"; // Grows once.
form.OptionChecked = true;
form.Show();
Assert.True(option.Visible);
Assert.True(form.ClientSize.Height > bare.Height);
Assert.True(option.Bottom <= form.ClientSize.Height);
Assert.True(option.Right <= form.ClientSize.Width);
Assert.False(value.Enabled);
form.OptionChecked = false;
Assert.True(value.Enabled);
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
[Fact]
public void WithoutAnOptionTheDialogKeepsItsSize() => StaTestThread.Run(() =>
{
using var form = new SetValueForm();
var bare = form.ClientSize;
form.OptionText = "shown";
form.OptionText = "";
Assert.Equal(bare, form.ClientSize);
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
}
+23 -4
View File
@@ -288,6 +288,12 @@ namespace OpenNest.Controls
public double OffsetIncrementDistance { get; set; }
/// <summary>
/// When true, the arrow keys step the selection by its own size plus the part
/// spacing instead of <see cref="OffsetIncrementDistance"/>.
/// </summary>
public bool CalculatedOffsetDistance { get; set; }
public Plate Plate
{
get { return plate; }
@@ -720,7 +726,7 @@ namespace OpenNest.Controls
break;
case Keys.Left:
selection.NudgeSelected(-OffsetIncrementDistance, 0);
NudgeSelected(PushDirection.Left);
break;
case Keys.X:
@@ -744,21 +750,34 @@ namespace OpenNest.Controls
break;
case Keys.Right:
selection.NudgeSelected(OffsetIncrementDistance, 0);
NudgeSelected(PushDirection.Right);
break;
case Keys.Up:
selection.NudgeSelected(0, OffsetIncrementDistance);
NudgeSelected(PushDirection.Up);
break;
case Keys.Down:
selection.NudgeSelected(0, -OffsetIncrementDistance);
NudgeSelected(PushDirection.Down);
break;
}
return base.ProcessDialogKey(keyData);
}
/// <summary>An arrow key: the fixed offset increment, or the calculated step.</summary>
public void NudgeSelected(PushDirection direction)
{
if (CalculatedOffsetDistance)
{
selection.StepSelected(direction);
return;
}
var offset = SpatialQuery.DirectionToOffset(direction, OffsetIncrementDistance);
selection.NudgeSelected(offset.X, offset.Y);
}
protected override void OnPaint(PaintEventArgs e)
{
if (displayPlate != null && displayIsCurrent?.Invoke() != true)
+20
View File
@@ -107,6 +107,26 @@ namespace OpenNest.Controls
change.Complete();
}
/// <summary>
/// Moves the selection one repeat step in <paramref name="direction"/>: its own
/// size along that axis plus the part spacing, measured on its geometry
/// (<see cref="Compactor.Step"/>). It stops early at an edge or another part.
/// Repeated steps undo as one move, like nudges.
/// </summary>
public void StepSelected(PushDirection direction)
{
if (selectedParts.Count == 0)
return;
using var change = view.BeginChange("Nudge", new SelectionKey("Nudge", view.Plate, selectedParts));
var movingParts = selectedParts.Select(p => p.BasePart).ToList();
if (Compactor.Step(movingParts, view.Plate, direction) > 0)
change.MarkChanged();
selectedParts.ForEach(p => p.IsDirty = true);
view.Invalidate();
change.Complete();
}
public void AlignSelected(AlignType alignType)
{
if (selectedParts.Count == 0)
+5
View File
@@ -152,9 +152,14 @@ namespace OpenNest.Forms
using var form = new SetValueForm();
form.Text = "Set Offset Increment";
form.Value = activeForm.PlateView.OffsetIncrementDistance;
form.OptionText = "Calculated distance (selection size + part spacing)";
form.OptionChecked = activeForm.PlateView.CalculatedOffsetDistance;
if (form.ShowDialog(this) == DialogResult.OK)
{
activeForm.PlateView.OffsetIncrementDistance = form.Value;
activeForm.PlateView.CalculatedOffsetDistance = form.OptionChecked;
}
}
private void SetRotationIncrement_Click(object sender, EventArgs e)
+18 -2
View File
@@ -32,6 +32,7 @@
this.tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel();
this.labelIncrement = new System.Windows.Forms.Label();
this.numericUpDownValue = new OpenNest.Controls.NumericUpDown();
this.checkBoxOption = new System.Windows.Forms.CheckBox();
this.tableLayoutPanel1.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownValue)).BeginInit();
this.SuspendLayout();
@@ -56,11 +57,13 @@
this.tableLayoutPanel1.Controls.Add(this.buttonSet, 2, 0);
this.tableLayoutPanel1.Controls.Add(this.labelIncrement, 0, 0);
this.tableLayoutPanel1.Controls.Add(this.numericUpDownValue, 1, 0);
this.tableLayoutPanel1.Controls.Add(this.checkBoxOption, 0, 1);
this.tableLayoutPanel1.Dock = System.Windows.Forms.DockStyle.Fill;
this.tableLayoutPanel1.Location = new System.Drawing.Point(6, 5);
this.tableLayoutPanel1.Name = "tableLayoutPanel1";
this.tableLayoutPanel1.RowCount = 1;
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F));
this.tableLayoutPanel1.RowCount = 2;
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Absolute, 41F));
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle());
this.tableLayoutPanel1.Size = new System.Drawing.Size(344, 41);
this.tableLayoutPanel1.TabIndex = 0;
//
@@ -90,6 +93,18 @@
this.numericUpDownValue.Size = new System.Drawing.Size(171, 22);
this.numericUpDownValue.TabIndex = 1;
//
// checkBoxOption
//
this.checkBoxOption.AutoSize = true;
this.tableLayoutPanel1.SetColumnSpan(this.checkBoxOption, 3);
this.checkBoxOption.Location = new System.Drawing.Point(3, 44);
this.checkBoxOption.Name = "checkBoxOption";
this.checkBoxOption.Size = new System.Drawing.Size(15, 14);
this.checkBoxOption.TabIndex = 3;
this.checkBoxOption.UseVisualStyleBackColor = true;
this.checkBoxOption.Visible = false;
this.checkBoxOption.CheckedChanged += new System.EventHandler(this.checkBoxOption_CheckedChanged);
//
// SetValueForm
//
this.AcceptButton = this.buttonSet;
@@ -118,5 +133,6 @@
private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1;
private System.Windows.Forms.Label labelIncrement;
private Controls.NumericUpDown numericUpDownValue;
private System.Windows.Forms.CheckBox checkBoxOption;
}
}
+38 -1
View File
@@ -1,9 +1,12 @@
using System.Windows.Forms;
using System.Drawing;
using System.Windows.Forms;
namespace OpenNest.Forms
{
public partial class SetValueForm : Form
{
private int optionRowHeight;
public SetValueForm()
{
InitializeComponent();
@@ -44,5 +47,39 @@ namespace OpenNest.Forms
get { return (double)numericUpDownValue.Value; }
set { numericUpDownValue.Value = (decimal)value; }
}
/// <summary>
/// Shows a check box under the value with this text; checking it disables the
/// value. Hidden while empty.
/// </summary>
public string OptionText
{
get { return checkBoxOption.Text; }
set
{
var show = !string.IsNullOrEmpty(value);
checkBoxOption.Text = value;
checkBoxOption.Visible = show;
// Visible reads false until the form is shown, so track the extra row here.
var rowHeight = show
? checkBoxOption.PreferredSize.Height + checkBoxOption.Margin.Vertical
: 0;
var width = show
? System.Math.Max(ClientSize.Width,
checkBoxOption.PreferredSize.Width + checkBoxOption.Margin.Horizontal + Padding.Horizontal)
: ClientSize.Width;
ClientSize = new Size(width, ClientSize.Height - optionRowHeight + rowHeight);
optionRowHeight = rowHeight;
}
}
public bool OptionChecked
{
get { return checkBoxOption.Checked; }
set { checkBoxOption.Checked = value; }
}
private void checkBoxOption_CheckedChanged(object sender, System.EventArgs e) =>
numericUpDownValue.Enabled = !checkBoxOption.Checked;
}
}
+1 -1
View File
@@ -113,7 +113,7 @@ Default compares the built-in Irregular, Rectangles and Circles engines and keep
## Keyboard Shortcuts
`Ctrl+F` fill area · `F` zoom to fit · `Shift+wheel` / middle-click rotate · `X`/`Y` push · arrows nudge · `Shift+arrow` push.
`Ctrl+F` fill area · `F` zoom to fit · `Shift+wheel` / middle-click rotate · `X`/`Y` push · arrows nudge by the offset increment (`F5`), or with its calculated distance by the selection's own size plus part spacing, landing tight with no push back · `Shift+arrow` push.
## Status & License
+1
View File
@@ -11,6 +11,7 @@ Open/incomplete chains and ambiguous contacts conservatively block. Loops are re
## Callers
- PlateView uses `SelectionManager.PushSelected` → `Compactor.Push`. The zero-spacing nudge-and-discard workaround is removed. Cutout contours on stationary obstacles are retained, and the plate entry's existing-overlap filter accounts for holes rather than relying solely on `Part.Intersects` (which compares outer perimeters).
- 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 then a push capped at that step, so it stops at the work-area edge, a clamp zone or another part and never passes one. A clone preview resting on the copy just placed ignores that copy, as a push does with overlapping parts, and so steps clear of it.
- 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.
- 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.
+1 -1
View File
@@ -80,7 +80,7 @@ change.Complete();
open the scope at their final commit and record only the result they keep. A stopped run
that keeps nothing records nothing; an interactive drag records one entry on mouse-up.
- Repeated edits with an equal `coalesceKey` within one second undo as one (arrow-key nudges of
one selection). An Undo, Redo or save in between ends the run.
one selection, fixed or calculated). An Undo, Redo or save in between ends the run.
### The "Change" safety net