From aeb90d8a865a71d77f16044d12b5f3b53168d9ba Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 20 Oct 2026 20:12:04 -0400 Subject: [PATCH] feat(desktop): sequence parts along lines drawn over the plate Plate > Sequence Parts > Sequence by Path: each left-drag adds a line, cut in drawing order; the jump between lines is a rapid and claims no parts. Each part belongs to the line passing closest to its center and is ordered by where that nearest point falls along the line. Cut-first parts stay ahead and cutoff dependency rules still apply. A small window offers Undo Line, Clear (restores the starting order) and Done. Co-Authored-By: Claude Opus 5.5 --- .../Sequencing/DrawnPathSequencing.cs | 106 +++++++++ OpenNest.Engine/Sequencing/PlateSequencing.cs | 2 +- .../Sequencing/DrawnPathSequencingTests.cs | 125 ++++++++++ OpenNest/Actions/ActionSequenceByPath.cs | 214 ++++++++++++++++++ OpenNest/Forms/MainForm.Designer.cs | 11 +- OpenNest/Forms/MainForm.Plate.cs | 8 + OpenNest/Forms/SequenceByPathForm.Designer.cs | 133 +++++++++++ OpenNest/Forms/SequenceByPathForm.cs | 33 +++ docs/automatic-scrap-cutoffs.md | 2 + 9 files changed, 632 insertions(+), 2 deletions(-) create mode 100644 OpenNest.Engine/Sequencing/DrawnPathSequencing.cs create mode 100644 OpenNest.Tests/Sequencing/DrawnPathSequencingTests.cs create mode 100644 OpenNest/Actions/ActionSequenceByPath.cs create mode 100644 OpenNest/Forms/SequenceByPathForm.Designer.cs create mode 100644 OpenNest/Forms/SequenceByPathForm.cs diff --git a/OpenNest.Engine/Sequencing/DrawnPathSequencing.cs b/OpenNest.Engine/Sequencing/DrawnPathSequencing.cs new file mode 100644 index 00000000..1ecc37cb --- /dev/null +++ b/OpenNest.Engine/Sequencing/DrawnPathSequencing.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.Sequencing +{ + /// + /// Sequences a plate along lines the user draws over it, in the order they were drawn. + /// Each part belongs to the line passing closest to its center, and is cut in the order + /// that nearest point falls along the line; where a line passes a part more than once, + /// the closest pass decides. The jump from one line to the next is a rapid and claims + /// nothing. Cut-first parts stay ahead of the rest and the cutoff rules still apply. + /// + public static class DrawnPathSequencing + { + /// Reorders the plate's parts; false when no line has length or nothing moves. + public static bool Apply(Plate plate, IReadOnlyList> lines) + { + ArgumentNullException.ThrowIfNull(plate); + ArgumentNullException.ThrowIfNull(lines); + if (plate.Parts.Count < 2 || !lines.Any(HasLength)) + return false; + + var first = plate.Parts.Where(p => p.BaseDrawing.CutFirst); + var rest = plate.Parts.Where(p => !p.BaseDrawing.CutFirst); + var ordered = Order(first, lines).Concat(Order(rest, lines)).ToList(); + var cutOrder = PlateSequencing.OrderCutOffsFirst(ordered, plate); + if (cutOrder.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance)) + return false; + + plate.Parts.Reorder(cutOrder); + return true; + } + + /// The parts in line order; ties keep their current order. + public static List Order(IEnumerable parts, IReadOnlyList> lines) + { + return parts + .Select(part => (Part: part, Station: Nearest(part.BoundingBox.Center, lines))) + .OrderBy(item => item.Station.Line) + .ThenBy(item => item.Station.Along) + .ThenBy(item => item.Station.Distance) + .Select(item => item.Part) + .ToList(); + } + + /// + /// The distance along the path to the point nearest , and how + /// far away that point is. The earliest of equally near points wins. + /// + public static (double Along, double Distance) Project(Vector point, IReadOnlyList path) + { + if (path.Count == 0) + return (0, double.PositiveInfinity); + + var best = (Along: 0.0, Distance: point.DistanceTo(path[0])); + var travelled = 0.0; + for (var i = 1; i < path.Count; i++) + { + var start = path[i - 1]; + var end = path[i]; + var dx = end.X - start.X; + var dy = end.Y - start.Y; + var lengthSquared = dx * dx + dy * dy; + var length = System.Math.Sqrt(lengthSquared); + if (length <= Tolerance.Epsilon) + continue; + + var t = ((point.X - start.X) * dx + (point.Y - start.Y) * dy) / lengthSquared; + t = System.Math.Clamp(t, 0.0, 1.0); + var nearest = new Vector(start.X + dx * t, start.Y + dy * t); + var distance = point.DistanceTo(nearest); + if (distance < best.Distance - Tolerance.Epsilon) + best = (travelled + length * t, distance); + travelled += length; + } + return best; + } + + /// Whether the line is long enough to sequence along. + public static bool HasLength(IReadOnlyList path) + { + var length = 0.0; + for (var i = 1; i < path.Count; i++) + length += path[i - 1].DistanceTo(path[i]); + return length > Tolerance.Epsilon; + } + + // The earliest of equally near lines wins. + private static (int Line, double Along, double Distance) Nearest(Vector point, IReadOnlyList> lines) + { + var best = (Line: int.MaxValue, Along: 0.0, Distance: double.PositiveInfinity); + for (var i = 0; i < lines.Count; i++) + { + if (!HasLength(lines[i])) + continue; + var (along, distance) = Project(point, lines[i]); + if (distance < best.Distance - Tolerance.Epsilon) + best = (i, along, distance); + } + return best; + } + } +} diff --git a/OpenNest.Engine/Sequencing/PlateSequencing.cs b/OpenNest.Engine/Sequencing/PlateSequencing.cs index 2e1d2bdb..4c4d6f86 100644 --- a/OpenNest.Engine/Sequencing/PlateSequencing.cs +++ b/OpenNest.Engine/Sequencing/PlateSequencing.cs @@ -95,7 +95,7 @@ namespace OpenNest.Engine.Sequencing return sequencer.Sequence(parts, plate).Select(p => p.Part).Reverse(); } - private static List OrderCutOffsFirst(List ordered, Plate plate) + internal static List OrderCutOffsFirst(List ordered, Plate plate) { var definitions = new Dictionary(ReferenceEqualityComparer.Instance); foreach (var cutOff in plate.CutOffs) diff --git a/OpenNest.Tests/Sequencing/DrawnPathSequencingTests.cs b/OpenNest.Tests/Sequencing/DrawnPathSequencingTests.cs new file mode 100644 index 00000000..7cda22e6 --- /dev/null +++ b/OpenNest.Tests/Sequencing/DrawnPathSequencingTests.cs @@ -0,0 +1,125 @@ +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Sequencing; + +public class DrawnPathSequencingTests +{ + private static Vector[][] Lines(params Vector[][] lines) => lines; + + [Fact] + public void Apply_CutsPartsInTheOrderTheSnakePathReachesThem() + { + // Two rows of three 10x10 squares; the path runs along the bottom row + // left to right, then back along the top row right to left. + var drawing = TestHelpers.MakeSquareDrawing(); + var parts = new[] + { + new Part(drawing, new Vector(0, 0)), new Part(drawing, new Vector(20, 0)), new Part(drawing, new Vector(40, 0)), + new Part(drawing, new Vector(0, 20)), new Part(drawing, new Vector(20, 20)), new Part(drawing, new Vector(40, 20)), + }; + var plate = TestHelpers.MakePlate(60, 120, parts[4], parts[0], parts[5], parts[2], parts[3], parts[1]); + var nested = drawing.Quantity.Nested; + var path = new[] { new Vector(-5, 5), new Vector(55, 5), new Vector(55, 25), new Vector(-5, 25) }; + + Assert.True(DrawnPathSequencing.Apply(plate, Lines(path))); + + Assert.Equal(new[] { parts[0], parts[1], parts[2], parts[5], parts[4], parts[3] }, plate.Parts); + Assert.Equal(nested, drawing.Quantity.Nested); + } + + [Fact] + public void Apply_CutsLineByLineAndTheJumpBetweenLinesClaimsNothing() + { + // Both rows run left to right. The jump from the bottom row's end back to the + // top row's start crosses the middle part of each row but is not a line. + var drawing = TestHelpers.MakeSquareDrawing(); + var bottom = new[] { new Part(drawing, new Vector(0, 0)), new Part(drawing, new Vector(20, 0)), new Part(drawing, new Vector(40, 0)) }; + var top = new[] { new Part(drawing, new Vector(0, 20)), new Part(drawing, new Vector(20, 20)), new Part(drawing, new Vector(40, 20)) }; + var plate = TestHelpers.MakePlate(60, 120, top[2], bottom[1], top[0], bottom[2], top[1], bottom[0]); + + var lines = Lines( + new[] { new Vector(-5, 5), new Vector(55, 5) }, + new[] { new Vector(-5, 25), new Vector(55, 25) }); + + Assert.True(DrawnPathSequencing.Apply(plate, lines)); + + Assert.Equal(bottom.Concat(top), plate.Parts); + } + + [Fact] + public void Apply_PartGoesToTheNearestLineEvenWhenDrawnLater() + { + var drawing = TestHelpers.MakeSquareDrawing(); + var a = new Part(drawing, new Vector(0, 0)); + var b = new Part(drawing, new Vector(20, 0)); + var stray = new Part(drawing, new Vector(40, 30)); + var plate = TestHelpers.MakePlate(60, 120, stray, b, a); + + // The first line passes 'stray' at a distance; the second runs right through it. + var lines = Lines( + new[] { new Vector(5, 5), new Vector(45, 5), new Vector(45, 20) }, + new[] { new Vector(45, 50), new Vector(45, 30) }); + + Assert.True(DrawnPathSequencing.Apply(plate, lines)); + + Assert.Equal(new[] { a, b, stray }, plate.Parts); + } + + [Fact] + public void Apply_PartPassedTwiceFollowsTheNearerPass() + { + var drawing = TestHelpers.MakeSquareDrawing(); + var start = new Part(drawing, new Vector(0, 0)); + var passed = new Part(drawing, new Vector(40, 0)); + var top = new Part(drawing, new Vector(40, 40)); + var plate = TestHelpers.MakePlate(60, 120, passed, top, start); + + // The path skirts 'passed' on the way up to 'top', then comes back through it. + var path = new[] { new Vector(5, 5), new Vector(45, 15), new Vector(45, 45), new Vector(45, 5) }; + + Assert.True(DrawnPathSequencing.Apply(plate, Lines(path))); + Assert.Equal(new[] { start, top, passed }, plate.Parts); + + // Reversed, the path runs through 'passed' first. + Assert.True(DrawnPathSequencing.Apply(plate, Lines(path.Reverse().ToArray()))); + Assert.Equal(new[] { passed, top, start }, plate.Parts); + } + + [Fact] + public void Apply_KeepsCutFirstPartsAheadOfTheRest() + { + var drawing = TestHelpers.MakeSquareDrawing(); + var coupon = TestHelpers.MakeSquareDrawing(); + coupon.CutFirst = true; + var a = new Part(drawing, new Vector(0, 0)); + var b = new Part(drawing, new Vector(20, 0)); + var test = new Part(coupon, new Vector(40, 0)); + var plate = TestHelpers.MakePlate(60, 120, a, b, test); + + Assert.True(DrawnPathSequencing.Apply(plate, Lines(new[] { new Vector(55, 5), new Vector(-5, 5) }))); + + Assert.Equal(new[] { test, b, a }, plate.Parts); + } + + [Fact] + public void Apply_IgnoresLinesWithoutLength() + { + var drawing = TestHelpers.MakeSquareDrawing(); + var a = new Part(drawing, new Vector(20, 0)); + var b = new Part(drawing, new Vector(0, 0)); + var plate = TestHelpers.MakePlate(60, 120, a, b); + var reorders = 0; + plate.PartsReordered += (_, _) => reorders++; + + Assert.False(DrawnPathSequencing.Apply(plate, Lines())); + Assert.False(DrawnPathSequencing.Apply(plate, Lines(new[] { new Vector(0, 0) }))); + Assert.False(DrawnPathSequencing.Apply(plate, Lines(new[] { new Vector(3, 3), new Vector(3, 3) }))); + Assert.Equal(new[] { a, b }, plate.Parts); + Assert.Equal(0, reorders); + + // A dot drawn first does not count as line one. + Assert.True(DrawnPathSequencing.Apply(plate, Lines(new[] { new Vector(30, 5) }, new[] { new Vector(-5, 5), new Vector(35, 5) }))); + Assert.Equal(new[] { b, a }, plate.Parts); + } +} diff --git a/OpenNest/Actions/ActionSequenceByPath.cs b/OpenNest/Actions/ActionSequenceByPath.cs new file mode 100644 index 00000000..8a727554 --- /dev/null +++ b/OpenNest/Actions/ActionSequenceByPath.cs @@ -0,0 +1,214 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Linq; +using System.Windows.Forms; +using OpenNest.Controls; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; + +namespace OpenNest.Actions +{ + /// + /// Sequences the plate along lines dragged over it with the left button, cut in the order + /// they are drawn; lifting the button between lines is a rapid that claims no parts. The + /// plate renumbers after each line. Backspace or Ctrl+Z removes the last line, right-click + /// clears them all, and Escape or the window's Done ends the tool. + /// + [DisplayName("Sequence by Path")] + public class ActionSequenceByPath : Action + { + // Screen distance between recorded points, so a line stays smooth but small. + private const float PointSpacingPixels = 4f; + private const float LabelRadius = 8f; + + private readonly Forms.SequenceByPathForm form; + private readonly List> lines = new(); + private readonly List originalOrder; + private readonly Pen linePen; + private readonly Pen jumpPen; + private readonly Brush labelBrush = new SolidBrush(Color.Red); + private readonly Font labelFont = new Font("Segoe UI", 7.5f, FontStyle.Bold); + private readonly StringFormat centered = new StringFormat + { + Alignment = StringAlignment.Center, + LineAlignment = StringAlignment.Center, + }; + private List drawing; + private bool disconnecting; + + public override bool ResumeOnEscape => false; + + public ActionSequenceByPath(PlateView plateView) + : base(plateView) + { + originalOrder = plateView.Plate.Parts.ToList(); + linePen = new Pen(Color.Red, 2.0f) + { + CustomEndCap = new AdjustableArrowCap(4f, 5f), + LineJoin = LineJoin.Round, + }; + jumpPen = new Pen(Color.Gray, 1.0f) { DashStyle = DashStyle.Dash }; + + form = new Forms.SequenceByPathForm(); + form.UndoClicked += (_, _) => RemoveLastLine(); + form.ClearClicked += (_, _) => ClearLines(); + form.DoneClicked += (_, _) => plateView.EndAction(); + form.FormClosed += (_, _) => + { + if (!disconnecting) + plateView.EndAction(); + }; + form.Show(plateView.FindForm()); + + plateView.MouseDown += plateView_MouseDown; + plateView.MouseMove += plateView_MouseMove; + plateView.MouseUp += plateView_MouseUp; + plateView.KeyDown += plateView_KeyDown; + plateView.Paint += plateView_Paint; + } + + private void plateView_MouseDown(object sender, MouseEventArgs e) + { + if (e.Button == MouseButtons.Right) + { + ClearLines(); + return; + } + if (e.Button != MouseButtons.Left) + return; + + drawing = new List { plateView.PointControlToWorld(e.Location) }; + plateView.Invalidate(); + } + + private void plateView_MouseMove(object sender, MouseEventArgs e) + { + if (drawing == null || e.Button != MouseButtons.Left) + return; + + var point = plateView.PointControlToWorld(e.Location); + if (plateView.LengthWorldToGui(point.DistanceTo(drawing[^1])) < PointSpacingPixels) + return; + drawing.Add(point); + plateView.Invalidate(); + } + + private void plateView_MouseUp(object sender, MouseEventArgs e) + { + if (drawing == null || e.Button != MouseButtons.Left) + return; + + var line = drawing; + drawing = null; + var point = plateView.PointControlToWorld(e.Location); + if (point.DistanceTo(line[^1]) > 0) + line.Add(point); + + // A click without a drag is not a line. + if (DrawnPathSequencing.HasLength(line)) + { + lines.Add(line); + Resequence(); + } + plateView.Invalidate(); + } + + private void plateView_KeyDown(object sender, KeyEventArgs e) + { + if (e.KeyData is Keys.Back or (Keys.Control | Keys.Z)) + { + RemoveLastLine(); + e.Handled = true; + } + } + + private void RemoveLastLine() + { + if (lines.Count == 0) + return; + lines.RemoveAt(lines.Count - 1); + Resequence(); + } + + private void ClearLines() + { + if (lines.Count == 0) + return; + lines.Clear(); + Resequence(); + } + + /// Sequences by the current lines, or restores the order the tool started with when none are left. + private void Resequence() + { + var plate = plateView.Plate; + if (lines.Count > 0) + DrawnPathSequencing.Apply(plate, lines); + else if (plate.Parts.Count == originalOrder.Count && originalOrder.All(plate.Parts.Contains)) + plate.Parts.Reorder(originalOrder); + + form.SetLineCount(lines.Count); + plateView.Invalidate(); + } + + private void plateView_Paint(object sender, PaintEventArgs e) + { + var g = e.Graphics; + var smoothing = g.SmoothingMode; + g.SmoothingMode = SmoothingMode.AntiAlias; + + for (var i = 1; i < lines.Count; i++) + g.DrawLine(jumpPen, ToGraph(lines[i - 1][^1]), ToGraph(lines[i][0])); + if (drawing != null && lines.Count > 0) + g.DrawLine(jumpPen, ToGraph(lines[^1][^1]), ToGraph(drawing[0])); + + for (var i = 0; i < lines.Count; i++) + DrawLine(g, lines[i], i + 1); + if (drawing != null) + DrawLine(g, drawing, lines.Count + 1); + + g.SmoothingMode = smoothing; + } + + private void DrawLine(Graphics g, List line, int number) + { + if (line.Count > 1) + g.DrawLines(linePen, line.Select(ToGraph).ToArray()); + + var start = ToGraph(line[0]); + g.FillEllipse(labelBrush, start.X - LabelRadius, start.Y - LabelRadius, LabelRadius * 2, LabelRadius * 2); + g.DrawString(number.ToString(), labelFont, Brushes.White, start, centered); + } + + private PointF ToGraph(Vector point) => plateView.PointWorldToGraph(point); + + public override void DisconnectEvents() + { + disconnecting = true; + plateView.MouseDown -= plateView_MouseDown; + plateView.MouseMove -= plateView_MouseMove; + plateView.MouseUp -= plateView_MouseUp; + plateView.KeyDown -= plateView_KeyDown; + plateView.Paint -= plateView_Paint; + form.Close(); + form.Dispose(); + linePen.Dispose(); + jumpPen.Dispose(); + labelBrush.Dispose(); + labelFont.Dispose(); + centered.Dispose(); + plateView.Invalidate(); + } + + // Escape mid-drag drops only the line being drawn. + public override void CancelAction() + { + drawing = null; + plateView.Invalidate(); + } + + public override bool IsBusy() => drawing != null; + } +} diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index 7792024b..acf85830 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -147,6 +147,7 @@ namespace OpenNest.Forms mnuSequenceParts = new System.Windows.Forms.ToolStripMenuItem(); autoSequenceCurrentPlateToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); manualSequenceToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + sequenceByPathToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); calculateCutTimeToolStripMenuItem1 = new System.Windows.Forms.ToolStripMenuItem(); mnuPlateSimulateCutting = new System.Windows.Forms.ToolStripMenuItem(); toolStripMenuItem21 = new System.Windows.Forms.ToolStripSeparator(); @@ -990,7 +991,7 @@ namespace OpenNest.Forms // // mnuSequenceParts // - mnuSequenceParts.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { autoSequenceCurrentPlateToolStripMenuItem, manualSequenceToolStripMenuItem }); + mnuSequenceParts.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { autoSequenceCurrentPlateToolStripMenuItem, manualSequenceToolStripMenuItem, sequenceByPathToolStripMenuItem }); mnuSequenceParts.Name = "mnuSequenceParts"; mnuSequenceParts.Size = new System.Drawing.Size(177, 22); mnuSequenceParts.Text = "Sequence Parts"; @@ -1009,6 +1010,13 @@ namespace OpenNest.Forms manualSequenceToolStripMenuItem.Text = "Manual Sequence"; manualSequenceToolStripMenuItem.Click += ManualSequenceParts_Click; // + // sequenceByPathToolStripMenuItem + // + sequenceByPathToolStripMenuItem.Name = "sequenceByPathToolStripMenuItem"; + sequenceByPathToolStripMenuItem.Size = new System.Drawing.Size(168, 22); + sequenceByPathToolStripMenuItem.Text = "Sequence by Path"; + sequenceByPathToolStripMenuItem.Click += SequenceByPath_Click; + // // calculateCutTimeToolStripMenuItem1 // calculateCutTimeToolStripMenuItem1.Name = "calculateCutTimeToolStripMenuItem1"; @@ -1452,6 +1460,7 @@ namespace OpenNest.Forms private System.Windows.Forms.ToolStripMenuItem runAutoNestToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem autoSequenceCurrentPlateToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem manualSequenceToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem sequenceByPathToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem autoSequenceAllPlatesToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem mnuToolsMeasureArea; private System.Windows.Forms.ToolStripMenuItem mnuToolsBestFitViewer; diff --git a/OpenNest/Forms/MainForm.Plate.cs b/OpenNest/Forms/MainForm.Plate.cs index b0d14626..c369b064 100644 --- a/OpenNest/Forms/MainForm.Plate.cs +++ b/OpenNest/Forms/MainForm.Plate.cs @@ -53,6 +53,14 @@ namespace OpenNest.Forms activeForm.PlateView.SetAction(typeof(ActionSetSequence)); } + private void SequenceByPath_Click(object sender, EventArgs e) + { + if (activeForm == null || activeForm.PlateView.Plate.Parts.Count < 2) + return; + + activeForm.PlateView.SetAction(typeof(ActionSequenceByPath)); + } + private void CutOff_Click(object sender, EventArgs e) => WithActive(f => f.PlateView.SetAction(typeof(ActionCutOff))); private void EditClamps_Click(object sender, EventArgs e) => WithActive(f => f.PlateView.SetAction(typeof(ActionClampEdit))); diff --git a/OpenNest/Forms/SequenceByPathForm.Designer.cs b/OpenNest/Forms/SequenceByPathForm.Designer.cs new file mode 100644 index 00000000..b7b8a1d3 --- /dev/null +++ b/OpenNest/Forms/SequenceByPathForm.Designer.cs @@ -0,0 +1,133 @@ +namespace OpenNest.Forms +{ + partial class SequenceByPathForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel(); + statusLabel = new System.Windows.Forms.Label(); + undoButton = new System.Windows.Forms.Button(); + clearButton = new System.Windows.Forms.Button(); + doneButton = new System.Windows.Forms.Button(); + tableLayoutPanel1.SuspendLayout(); + SuspendLayout(); + // + // tableLayoutPanel1 + // + tableLayoutPanel1.ColumnCount = 3; + tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 33.33F)); + tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 33.33F)); + tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 33.34F)); + tableLayoutPanel1.Controls.Add(statusLabel, 0, 0); + tableLayoutPanel1.SetColumnSpan(statusLabel, 3); + tableLayoutPanel1.Controls.Add(undoButton, 0, 1); + tableLayoutPanel1.Controls.Add(clearButton, 1, 1); + tableLayoutPanel1.Controls.Add(doneButton, 2, 1); + tableLayoutPanel1.Dock = System.Windows.Forms.DockStyle.Fill; + tableLayoutPanel1.Location = new System.Drawing.Point(0, 0); + tableLayoutPanel1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + tableLayoutPanel1.Name = "tableLayoutPanel1"; + tableLayoutPanel1.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3); + tableLayoutPanel1.RowCount = 2; + tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Absolute, 35F)); + tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); + tableLayoutPanel1.Size = new System.Drawing.Size(296, 74); + tableLayoutPanel1.TabIndex = 0; + // + // statusLabel + // + statusLabel.Anchor = System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + statusLabel.AutoSize = true; + statusLabel.Location = new System.Drawing.Point(8, 13); + statusLabel.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + statusLabel.Name = "statusLabel"; + statusLabel.Size = new System.Drawing.Size(280, 15); + statusLabel.TabIndex = 0; + // + // undoButton + // + undoButton.Anchor = System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + undoButton.Location = new System.Drawing.Point(8, 40); + undoButton.Name = "undoButton"; + undoButton.Size = new System.Drawing.Size(89, 28); + undoButton.TabIndex = 1; + undoButton.Text = "Undo Line"; + undoButton.UseVisualStyleBackColor = true; + undoButton.Click += undoButton_Click; + // + // clearButton + // + clearButton.Anchor = System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + clearButton.Location = new System.Drawing.Point(103, 40); + clearButton.Name = "clearButton"; + clearButton.Size = new System.Drawing.Size(89, 28); + clearButton.TabIndex = 2; + clearButton.Text = "Clear"; + clearButton.UseVisualStyleBackColor = true; + clearButton.Click += clearButton_Click; + // + // doneButton + // + doneButton.Anchor = System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + doneButton.Location = new System.Drawing.Point(198, 40); + doneButton.Name = "doneButton"; + doneButton.Size = new System.Drawing.Size(90, 28); + doneButton.TabIndex = 3; + doneButton.Text = "Done"; + doneButton.UseVisualStyleBackColor = true; + doneButton.Click += doneButton_Click; + // + // SequenceByPathForm + // + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + ClientSize = new System.Drawing.Size(296, 74); + Controls.Add(tableLayoutPanel1); + FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow; + Location = new System.Drawing.Point(100, 100); + Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + MaximizeBox = false; + MinimizeBox = false; + Name = "SequenceByPathForm"; + ShowIcon = false; + ShowInTaskbar = false; + StartPosition = System.Windows.Forms.FormStartPosition.Manual; + Text = "Sequence by Path"; + tableLayoutPanel1.ResumeLayout(false); + tableLayoutPanel1.PerformLayout(); + ResumeLayout(false); + } + + #endregion + + private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1; + private System.Windows.Forms.Label statusLabel; + private System.Windows.Forms.Button undoButton; + private System.Windows.Forms.Button clearButton; + private System.Windows.Forms.Button doneButton; + } +} diff --git a/OpenNest/Forms/SequenceByPathForm.cs b/OpenNest/Forms/SequenceByPathForm.cs new file mode 100644 index 00000000..702ebea2 --- /dev/null +++ b/OpenNest/Forms/SequenceByPathForm.cs @@ -0,0 +1,33 @@ +using System; +using System.Windows.Forms; + +namespace OpenNest.Forms +{ + public partial class SequenceByPathForm : Form + { + public SequenceByPathForm() + { + InitializeComponent(); + SetLineCount(0); + } + + public event EventHandler UndoClicked; + public event EventHandler ClearClicked; + public event EventHandler DoneClicked; + + public void SetLineCount(int count) + { + statusLabel.Text = count == 0 + ? "Drag a line through the parts in cut order." + : $"{count} line{(count == 1 ? "" : "s")}. Drag again to add the next."; + undoButton.Enabled = count > 0; + clearButton.Enabled = count > 0; + } + + private void undoButton_Click(object sender, EventArgs e) => UndoClicked?.Invoke(this, EventArgs.Empty); + + private void clearButton_Click(object sender, EventArgs e) => ClearClicked?.Invoke(this, EventArgs.Empty); + + private void doneButton_Click(object sender, EventArgs e) => DoneClicked?.Invoke(this, EventArgs.Empty); + } +} diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md index 69cdc99e..25ae914d 100644 --- a/docs/automatic-scrap-cutoffs.md +++ b/docs/automatic-scrap-cutoffs.md @@ -45,6 +45,8 @@ Existing full-width vertical cut-offs inside the used span become fixed strip bo For **Plate > Sequence Parts > Manual Sequence**, choose the one-based sequence number and hover a part on the sheet; only the part under the pointer is outlined. Click it to place it at that exact position, then the number advances up to the last part. Clicking empty sheet space changes nothing. Use **Done**, the window close button, right-click on the sheet, or Escape to leave the tool. Manual reordering changes neither geometry nor quantities. +For **Plate > Sequence Parts > Sequence by Path**, drag lines over the sheet with the left button in the order you want the parts cut. Lifting the button between lines is a rapid: the dashed jump to the next line's numbered start claims no parts. Each part belongs to the line passing closest to its bounding-box center, and lines are cut in drawing order, each in the order its nearest points fall along it; where a line passes a part more than once, the closest pass decides. The plate renumbers after every line. **Undo Line**, Backspace or Ctrl+Z removes the last line; **Clear** or right-click removes them all and restores the order the tool started with. Cut-first parts stay ahead of the rest, and cutoffs follow the same dependency rules as Part Sequencing below. Escape mid-drag drops only that line; otherwise Escape, **Done** or closing the window leaves the tool. + After adding cutoffs, apply **Part Sequencing** to the current plate or all plates. Each cutoff is moved earlier as needed so it is cut before every part its nominal line passes through. Ordinary parts retain their relative order from the chosen sequencing route. A cutoff that splits the whole sheet — one continuous cut from sheet edge to sheet edge, such as a tail separator — goes after every part and every other cutoff, so the head never travels back across the separated edge. Cutoffs that cross no part but leave gaps keep their normal route position. Cutoff geometry, clearance, part programs, placements, and quantities are unchanged. The dependency check uses the nominal horizontal/vertical line and its start/end limits, not the trimmed cutting segments, which intentionally skip the parts. It conservatively checks placed part bounds (including edge contacts), so a cutoff through a concave recess can also move ahead of that part. Definitions are matched by drawing identity, not their displayed names. A cutoff part with no matching definition is conservatively ordered before all ordinary parts.