From d98bee87ec617df6fe227fad4829251fd76c349d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 4 Nov 2026 22:21:42 -0500 Subject: [PATCH] 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 --- .../Fill/CompactorStepTests.cs | 110 ++++++++++++++++++ OpenNest.Engine/Fill/Compactor.cs | 52 +++++++-- OpenNest.Engine/Fill/FillLinear.cs | 8 ++ .../Controls/PlateViewStepTests.cs | 85 ++++++++++++++ .../Forms/SetValueFormTests.cs | 43 +++++++ OpenNest/Controls/PlateView.cs | 27 ++++- OpenNest/Controls/SelectionManager.cs | 20 ++++ OpenNest/Forms/MainForm.Tools.cs | 5 + OpenNest/Forms/SetValueForm.Designer.cs | 20 +++- OpenNest/Forms/SetValueForm.cs | 39 ++++++- README.md | 2 +- docs/geometry/directional-slides.md | 1 + docs/undo-redo.md | 2 +- 13 files changed, 398 insertions(+), 16 deletions(-) create mode 100644 OpenNest.Engine.Tests/Fill/CompactorStepTests.cs create mode 100644 OpenNest.WinForms.Tests/Controls/PlateViewStepTests.cs create mode 100644 OpenNest.WinForms.Tests/Forms/SetValueFormTests.cs 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