feat(sequencing): mute parts until a line or click sequences them in Sequence by Path

With every part drawn the same, it was hard to see which parts the drawn
lines had not reached yet. Parts no line reaches are now drawn muted, and
update live while dragging. Clicking a part adds it as a one-point line, so
a stray part can be picked without drawing a line to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-21 13:50:12 -04:00
1 parent d1c77b8ec7
commit 64eca9775f
7 files changed
+95 -20

No files matched your search

@@ -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.
/// </summary>
public static class DrawnPathSequencing
{
/// <summary>Reorders the plate's parts; false when no line has length or nothing moves.</summary>
/// <summary>Reorders the plate's parts; false when no line can sequence or nothing moves.</summary>
public static bool Apply(Plate plate, IReadOnlyList<IReadOnlyList<Vector>> lines)
{
var cutOrder = CutOrder(plate, lines);
@@ -29,13 +30,13 @@ namespace OpenNest.Engine.Sequencing
/// <summary>
/// The order <see cref="Apply"/> 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.
/// </summary>
public static List<Part> CutOrder(Plate plate, IReadOnlyList<IReadOnlyList<Vector>> 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;
}
/// <summary>Whether a line crosses the part, so it is cut in that line's order rather than after the rest.</summary>
public static bool Reaches(IReadOnlyList<IReadOnlyList<Vector>> lines, Part part)
{
var box = part.BoundingBox;
return lines.Any(line => CanSequence(line) && Crosses(line, box));
}
/// <summary>Whether the path is a click or a line long enough to sequence along.</summary>
public static bool CanSequence(IReadOnlyList<Vector> path) => path.Count == 1 || HasLength(path);
/// <summary>Whether the line is long enough to sequence along.</summary>
public static bool HasLength(IReadOnlyList<Vector> path)
{
@@ -108,9 +119,11 @@ namespace OpenNest.Engine.Sequencing
return length > Tolerance.Epsilon;
}
/// <summary>Whether any segment of the path touches the box.</summary>
/// <summary>Whether any segment of the path touches the box, or a click lands in it.</summary>
public static bool Crosses(IReadOnlyList<Vector> 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)
@@ -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<Vector>())));
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);
}
}
+27 -5
View File
@@ -12,8 +12,9 @@ namespace OpenNest.Actions
{
/// <summary>
/// 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.
/// </summary>
[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
/// <summary>Numbers the parts as if the line being drawn were finished, without reordering the plate.</summary>
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();
}
/// <summary>Mutes the parts the lines do not reach, so what is left to sequence stands out.</summary>
private void UpdateMuted(IReadOnlyList<IReadOnlyList<Vector>> paths)
{
var muted = new HashSet<Part>(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();
}
+2 -1
View File
@@ -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);
+3
View File
@@ -318,6 +318,9 @@ namespace OpenNest.Controls
/// reordering the plate; null shows the plate's own order.
/// </summary>
internal IReadOnlyDictionary<Part, int> PreviewSequence { get; set; }
/// <summary>Parts a tool draws muted, compared by reference; null mutes none.</summary>
internal IReadOnlySet<Part> MutedParts { get; set; }
internal (float Scale, PointF Origin) CaptureViewport() => (ViewScale, origin);
internal void RestoreViewport((float Scale, PointF Origin) viewport)
{
+2 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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.