diff --git a/OpenNest.Engine/Sequencing/DrawnPathSequencing.cs b/OpenNest.Engine/Sequencing/DrawnPathSequencing.cs
index 8b3e3704..52cd6794 100644
--- a/OpenNest.Engine/Sequencing/DrawnPathSequencing.cs
+++ b/OpenNest.Engine/Sequencing/DrawnPathSequencing.cs
@@ -10,13 +10,14 @@ 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 crossing 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. Parts no line crosses are cut after the rest, ordered the same
- /// way by their nearest line. The jump from one line to the next is a rapid and claims
+ /// the closest pass decides. A single point is a click, and crosses the parts whose boxes
+ /// hold it. Parts no line crosses are cut after the rest, ordered the same way by their
+ /// nearest line. 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.
+ /// Reorders the plate's parts; false when no line can sequence or nothing moves.
public static bool Apply(Plate plate, IReadOnlyList> lines)
{
var cutOrder = CutOrder(plate, lines);
@@ -29,13 +30,13 @@ namespace OpenNest.Engine.Sequencing
///
/// The order would give the plate, without changing it; null when
- /// no line has length or there is nothing to order.
+ /// no line can sequence or there is nothing to order.
///
public static List CutOrder(Plate plate, IReadOnlyList> lines)
{
ArgumentNullException.ThrowIfNull(plate);
ArgumentNullException.ThrowIfNull(lines);
- if (plate.Parts.Count < 2 || !lines.Any(HasLength))
+ if (plate.Parts.Count < 2 || !lines.Any(CanSequence))
return null;
var first = plate.Parts.Where(p => p.BaseDrawing.CutFirst);
@@ -99,6 +100,16 @@ namespace OpenNest.Engine.Sequencing
return best;
}
+ /// Whether a line crosses the part, so it is cut in that line's order rather than after the rest.
+ public static bool Reaches(IReadOnlyList> lines, Part part)
+ {
+ var box = part.BoundingBox;
+ return lines.Any(line => CanSequence(line) && Crosses(line, box));
+ }
+
+ /// Whether the path is a click or a line long enough to sequence along.
+ public static bool CanSequence(IReadOnlyList path) => path.Count == 1 || HasLength(path);
+
/// Whether the line is long enough to sequence along.
public static bool HasLength(IReadOnlyList path)
{
@@ -108,9 +119,11 @@ namespace OpenNest.Engine.Sequencing
return length > Tolerance.Epsilon;
}
- /// Whether any segment of the path touches the box.
+ /// Whether any segment of the path touches the box, or a click lands in it.
public static bool Crosses(IReadOnlyList path, Box box)
{
+ if (path.Count == 1)
+ return box.Contains(path[0]);
for (var i = 1; i < path.Count; i++)
{
if (SegmentTouchesBox(path[i - 1], path[i], box))
@@ -152,7 +165,7 @@ namespace OpenNest.Engine.Sequencing
var best = (Line: int.MaxValue, Along: 0.0, Distance: double.PositiveInfinity);
for (var i = 0; i < lines.Count; i++)
{
- if (!HasLength(lines[i]) || !qualifies(lines[i]))
+ if (!CanSequence(lines[i]) || !qualifies(lines[i]))
continue;
var (along, distance) = Project(point, lines[i]);
if (distance < best.Distance - Tolerance.Epsilon)
diff --git a/OpenNest.Tests/Sequencing/DrawnPathSequencingTests.cs b/OpenNest.Tests/Sequencing/DrawnPathSequencingTests.cs
index 07869e8d..495b09a7 100644
--- a/OpenNest.Tests/Sequencing/DrawnPathSequencingTests.cs
+++ b/OpenNest.Tests/Sequencing/DrawnPathSequencingTests.cs
@@ -64,7 +64,7 @@ public class DrawnPathSequencingTests
Assert.Equal(new[] { a, b, c }, preview);
Assert.Equal(new[] { c, a, b }, plate.Parts);
Assert.Equal(0, reorders);
- Assert.Null(DrawnPathSequencing.CutOrder(plate, Lines(new[] { new Vector(0, 0) })));
+ Assert.Null(DrawnPathSequencing.CutOrder(plate, Lines(new[] { new Vector(0, 0), new Vector(0, 0) })));
Assert.True(DrawnPathSequencing.Apply(plate, lines));
Assert.Equal(preview, plate.Parts);
@@ -103,6 +103,42 @@ public class DrawnPathSequencingTests
Assert.Equal(new[] { stack[1], stack[2], stack[3], stack[0] }, plate.Parts);
}
+ [Fact]
+ public void Apply_ClickCutsThePartItLandsIn()
+ {
+ var drawing = TestHelpers.MakeSquareDrawing();
+ var a = new Part(drawing, new Vector(0, 0));
+ var b = new Part(drawing, new Vector(20, 0));
+ var c = new Part(drawing, new Vector(40, 0));
+ var plate = TestHelpers.MakePlate(60, 120, a, b, c);
+
+ // A line through 'b' and 'a', then a click on 'c'.
+ var lines = Lines(
+ new[] { new Vector(25, 5), new Vector(5, 5) },
+ new[] { new Vector(45, 5) });
+
+ Assert.True(DrawnPathSequencing.Apply(plate, lines));
+
+ Assert.Equal(new[] { b, a, c }, plate.Parts);
+ }
+
+ [Fact]
+ public void Reaches_OnlyPartsALineCrossesOrAClickLandsIn()
+ {
+ var drawing = TestHelpers.MakeSquareDrawing();
+ var crossed = new Part(drawing, new Vector(0, 0));
+ var clicked = new Part(drawing, new Vector(20, 0));
+ var missed = new Part(drawing, new Vector(40, 0));
+ var lines = Lines(
+ new[] { new Vector(5, -5), new Vector(5, 15) },
+ new[] { new Vector(25, 5) });
+
+ Assert.True(DrawnPathSequencing.Reaches(lines, crossed));
+ Assert.True(DrawnPathSequencing.Reaches(lines, clicked));
+ Assert.False(DrawnPathSequencing.Reaches(lines, missed));
+ Assert.False(DrawnPathSequencing.Reaches(Lines(), crossed));
+ }
+
[Fact]
public void Apply_PartPassedTwiceFollowsTheNearerPass()
{
@@ -150,13 +186,13 @@ public class DrawnPathSequencingTests
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(System.Array.Empty())));
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) })));
+ // A zero-length line drawn first does not count as line one.
+ Assert.True(DrawnPathSequencing.Apply(plate, Lines(new[] { new Vector(30, 5), 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
index 208462ec..be0a481f 100644
--- a/OpenNest/Actions/ActionSequenceByPath.cs
+++ b/OpenNest/Actions/ActionSequenceByPath.cs
@@ -12,8 +12,9 @@ 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
+ /// they are drawn; clicking a part adds it as a one-point line. Lifting the button between
+ /// lines is a rapid that claims no parts. The plate renumbers after each line, and parts no
+ /// line reaches yet are drawn muted. 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")]
@@ -67,6 +68,7 @@ namespace OpenNest.Actions
plateView.MouseUp += plateView_MouseUp;
plateView.KeyDown += plateView_KeyDown;
plateView.Paint += plateView_Paint;
+ UpdateMuted(lines);
}
private void plateView_MouseDown(object sender, MouseEventArgs e)
@@ -99,7 +101,9 @@ namespace OpenNest.Actions
/// Numbers the parts as if the line being drawn were finished, without reordering the plate.
private void UpdatePreview()
{
- var order = DrawnPathSequencing.CutOrder(plateView.Plate, lines.Append(drawing).ToList());
+ var previewLines = lines.Append(drawing).ToList();
+ UpdateMuted(previewLines);
+ var order = DrawnPathSequencing.CutOrder(plateView.Plate, previewLines);
if (order == null)
{
plateView.PreviewSequence = null;
@@ -124,12 +128,15 @@ namespace OpenNest.Actions
if (point.DistanceTo(line[^1]) > 0)
line.Add(point);
- // A click without a drag is not a line.
- if (DrawnPathSequencing.HasLength(line))
+ // A click only counts on a part.
+ if (DrawnPathSequencing.HasLength(line)
+ || line.Count == 1 && plateView.GetPartAtPoint(line[0]) != null)
{
lines.Add(line);
Resequence();
}
+ else
+ UpdateMuted(lines);
plateView.Invalidate();
}
@@ -168,9 +175,22 @@ namespace OpenNest.Actions
plate.Parts.Reorder(originalOrder);
form.SetLineCount(lines.Count);
+ UpdateMuted(lines);
plateView.Invalidate();
}
+ /// Mutes the parts the lines do not reach, so what is left to sequence stands out.
+ private void UpdateMuted(IReadOnlyList> paths)
+ {
+ var muted = new HashSet(ReferenceEqualityComparer.Instance);
+ foreach (var part in plateView.Plate.Parts)
+ {
+ if (!DrawnPathSequencing.Reaches(paths, part))
+ muted.Add(part);
+ }
+ plateView.MutedParts = muted;
+ }
+
private void plateView_Paint(object sender, PaintEventArgs e)
{
var g = e.Graphics;
@@ -211,6 +231,7 @@ namespace OpenNest.Actions
plateView.KeyDown -= plateView_KeyDown;
plateView.Paint -= plateView_Paint;
plateView.PreviewSequence = null;
+ plateView.MutedParts = null;
form.Close();
form.Dispose();
linePen.Dispose();
@@ -226,6 +247,7 @@ namespace OpenNest.Actions
{
drawing = null;
plateView.PreviewSequence = null;
+ UpdateMuted(lines);
plateView.Invalidate();
}
diff --git a/OpenNest/Controls/PlateRenderer.cs b/OpenNest/Controls/PlateRenderer.cs
index b49c0c8c..ef7658da 100644
--- a/OpenNest/Controls/PlateRenderer.cs
+++ b/OpenNest/Controls/PlateRenderer.cs
@@ -123,7 +123,8 @@ namespace OpenNest.Controls
var finding = view.ValidationOverlay.Finding;
var muted = finding != null && finding.PartNumber != i + 1 && finding.OtherPartNumber != i + 1
- || !view.HasProposalDisplay && view.CutSimulation.IsPartPending(i);
+ || !view.HasProposalDisplay && view.CutSimulation.IsPartPending(i)
+ || view.MutedParts?.Contains(part.BasePart) == true;
var number = view.PreviewSequence != null
&& view.PreviewSequence.TryGetValue(part.BasePart, out var preview) ? preview : i + 1;
part.Draw(g, number.ToString(), muted);
diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs
index 8001f659..7b0147dc 100644
--- a/OpenNest/Controls/PlateView.cs
+++ b/OpenNest/Controls/PlateView.cs
@@ -318,6 +318,9 @@ namespace OpenNest.Controls
/// reordering the plate; null shows the plate's own order.
///
internal IReadOnlyDictionary PreviewSequence { get; set; }
+
+ /// Parts a tool draws muted, compared by reference; null mutes none.
+ internal IReadOnlySet MutedParts { get; set; }
internal (float Scale, PointF Origin) CaptureViewport() => (ViewScale, origin);
internal void RestoreViewport((float Scale, PointF Origin) viewport)
{
diff --git a/OpenNest/Forms/SequenceByPathForm.cs b/OpenNest/Forms/SequenceByPathForm.cs
index 702ebea2..5d9565cc 100644
--- a/OpenNest/Forms/SequenceByPathForm.cs
+++ b/OpenNest/Forms/SequenceByPathForm.cs
@@ -18,8 +18,8 @@ namespace OpenNest.Forms
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.";
+ ? "Drag a line through the parts, or click them, in cut order."
+ : $"{count} line{(count == 1 ? "" : "s")}. Drag or click to add the next.";
undoButton.Enabled = count > 0;
clearButton.Enabled = count > 0;
}
diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md
index b94ec56c..409c7ffb 100644
--- a/docs/automatic-scrap-cutoffs.md
+++ b/docs/automatic-scrap-cutoffs.md
@@ -45,7 +45,7 @@ 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 crossing its bounding box that passes 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. Parts no line crosses are cut after all the others, in the order they would follow their nearest line. The numbers update as you drag, and the plate is reordered when you release. **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.
+For **Plate > Sequence Parts > Sequence by Path**, drag lines over the sheet with the left button in the order you want the parts cut; clicking a part adds it as a one-point line that claims the parts whose bounding boxes hold the click. 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 crossing its bounding box that passes 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. Parts no line crosses are cut after all the others, in the order they would follow their nearest line, and are drawn muted until a line or click reaches them. The numbers update as you drag, and the plate is reordered when you release. **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.