diff --git a/OpenNest.Engine.Tests/Fill/CompactorStepTests.cs b/OpenNest.Engine.Tests/Fill/CompactorStepTests.cs
new file mode 100644
index 00000000..99827f9d
--- /dev/null
+++ b/OpenNest.Engine.Tests/Fill/CompactorStepTests.cs
@@ -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;
+
+///
+/// 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.
+///
+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 }, 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 }, 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 { 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 }, 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 }, plate, PushDirection.Right);
+
+ Assert.Equal(2.5, moved, 3);
+ }
+}
diff --git a/OpenNest.Engine/Fill/Compactor.cs b/OpenNest.Engine/Fill/Compactor.cs
index 07eeefe5..19b181b2 100644
--- a/OpenNest.Engine/Fill/Compactor.cs
+++ b/OpenNest.Engine/Fill/Compactor.cs
@@ -31,16 +31,46 @@ namespace OpenNest.Engine.Fill
public static double Push(List movingParts, Plate plate, double angle) =>
Push(movingParts, plate, new Vector(System.Math.Cos(angle), System.Math.Sin(angle)));
- private static double Push(List movingParts, Plate plate, Vector direction)
+ private static double Push(List movingParts, Plate plate, Vector direction) =>
+ Push(movingParts, plate, direction, double.MaxValue);
+
+ private static double Push(List 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));
}
+ ///
+ /// Moves movingParts one repeat step along : 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
+ /// , the move stops early at
+ /// the work-area edge, a clamp zone or another part, never passing one.
+ /// Returns the distance moved.
+ ///
+ public static double Step(List 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);
+ }
+
///
/// Pushes movingParts along an arbitrary angle (radians, 0 = right, π/2 = up).
///
@@ -52,19 +82,27 @@ namespace OpenNest.Engine.Fill
Vector direction
) => Push(movingParts, workArea, direction, new ObstacleSet(obstacleParts, partSpacing));
- ///
- /// Pushes movingParts to the first contact with the work-area edge or an obstacle,
- /// keeping each obstacle set's own spacing.
- ///
private static double Push(
List movingParts,
Box workArea,
Vector direction,
params ObstacleSet[] obstacleSets
+ ) => Push(movingParts, workArea, direction, double.MaxValue, obstacleSets);
+
+ ///
+ /// Pushes movingParts at most toward the first contact
+ /// with the work-area edge or an obstacle, keeping each obstacle set's own spacing.
+ ///
+ private static double Push(
+ List 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)
{
diff --git a/OpenNest.Engine/Fill/FillLinear.cs b/OpenNest.Engine/Fill/FillLinear.cs
index dbc248f5..4d837f37 100644
--- a/OpenNest.Engine/Fill/FillLinear.cs
+++ b/OpenNest.Engine/Fill/FillLinear.cs
@@ -196,6 +196,14 @@ namespace OpenNest.Engine.Fill
return maxCopyDistance;
}
+ ///
+ /// 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.
+ ///
+ public double CopyDistance(Pattern pattern, NestDirection direction) =>
+ FindPatternCopyDistance(pattern, direction, new OffsetPerimeterCache(HalfSpacing));
+
///
/// Tiles a pattern along the given axis, returning the cloned parts
/// (does not include the original pattern's parts). For multi-part
diff --git a/OpenNest.WinForms.Tests/Controls/PlateViewStepTests.cs b/OpenNest.WinForms.Tests/Controls/PlateViewStepTests.cs
new file mode 100644
index 00000000..ec72923c
--- /dev/null
+++ b/OpenNest.WinForms.Tests/Controls/PlateViewStepTests.cs
@@ -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;
+
+///
+/// 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.
+///
+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.");
+}
diff --git a/OpenNest.WinForms.Tests/Forms/SetValueFormTests.cs b/OpenNest.WinForms.Tests/Forms/SetValueFormTests.cs
new file mode 100644
index 00000000..fd3b2648
--- /dev/null
+++ b/OpenNest.WinForms.Tests/Forms/SetValueFormTests.cs
@@ -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(form));
+ var value = Assert.Single(All(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.");
+}
diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs
index ad3ef539..2a7fa0b1 100644
--- a/OpenNest/Controls/PlateView.cs
+++ b/OpenNest/Controls/PlateView.cs
@@ -288,6 +288,12 @@ namespace OpenNest.Controls
public double OffsetIncrementDistance { get; set; }
+ ///
+ /// When true, the arrow keys step the selection by its own size plus the part
+ /// spacing instead of .
+ ///
+ 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);
}
+ /// An arrow key: the fixed offset increment, or the calculated step.
+ 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)
diff --git a/OpenNest/Controls/SelectionManager.cs b/OpenNest/Controls/SelectionManager.cs
index 6c814e7d..e78fd154 100644
--- a/OpenNest/Controls/SelectionManager.cs
+++ b/OpenNest/Controls/SelectionManager.cs
@@ -107,6 +107,26 @@ namespace OpenNest.Controls
change.Complete();
}
+ ///
+ /// Moves the selection one repeat step in : its own
+ /// size along that axis plus the part spacing, measured on its geometry
+ /// (). It stops early at an edge or another part.
+ /// Repeated steps undo as one move, like nudges.
+ ///
+ 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)
diff --git a/OpenNest/Forms/MainForm.Tools.cs b/OpenNest/Forms/MainForm.Tools.cs
index 02d7e2b5..93698f36 100644
--- a/OpenNest/Forms/MainForm.Tools.cs
+++ b/OpenNest/Forms/MainForm.Tools.cs
@@ -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)
diff --git a/OpenNest/Forms/SetValueForm.Designer.cs b/OpenNest/Forms/SetValueForm.Designer.cs
index 82d49aca..479ad2c7 100644
--- a/OpenNest/Forms/SetValueForm.Designer.cs
+++ b/OpenNest/Forms/SetValueForm.Designer.cs
@@ -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;
}
}
\ No newline at end of file
diff --git a/OpenNest/Forms/SetValueForm.cs b/OpenNest/Forms/SetValueForm.cs
index 8a9be2cb..37fafd32 100644
--- a/OpenNest/Forms/SetValueForm.cs
+++ b/OpenNest/Forms/SetValueForm.cs
@@ -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; }
}
+
+ ///
+ /// Shows a check box under the value with this text; checking it disables the
+ /// value. Hidden while empty.
+ ///
+ 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;
}
}
diff --git a/README.md b/README.md
index e48a8d51..bbd7c78b 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/geometry/directional-slides.md b/docs/geometry/directional-slides.md
index 1915cffa..5fb4adbc 100644
--- a/docs/geometry/directional-slides.md
+++ b/docs/geometry/directional-slides.md
@@ -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.
diff --git a/docs/undo-redo.md b/docs/undo-redo.md
index 48ab34f6..209e9232 100644
--- a/docs/undo-redo.md
+++ b/docs/undo-redo.md
@@ -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