diff --git a/OpenNest.Core/CutOffTrim.cs b/OpenNest.Core/CutOffTrim.cs
index 5afe3f5b..c96f1a67 100644
--- a/OpenNest.Core/CutOffTrim.cs
+++ b/OpenNest.Core/CutOffTrim.cs
@@ -58,6 +58,58 @@ namespace OpenNest
}
}
+ ///
+ /// The perpendicular lines that meet a cut-off as a drag of it starts: their limits or gap
+ /// edges sit on its center, where a trim or extend left them. takes those
+ /// stops along, so each line is extended or trimmed to keep meeting it. Every move works from
+ /// the lines as they were at the start, so a stop held back partway is restored on the way
+ /// back. A stop never passes the next one on its line: it stays a minimum segment short of it,
+ /// or of the sheet edge.
+ ///
+ public sealed class CutOffJoints
+ {
+ private readonly List<(CutOff CutOff, double? StartLimit, double? EndLimit, IReadOnlyList Gaps)> joined;
+ private readonly double from;
+ private readonly double sheetLow;
+ private readonly double sheetHigh;
+ private readonly double minimum;
+
+ internal CutOffJoints(IEnumerable lines, double from, double sheetLow, double sheetHigh, double minimum)
+ {
+ joined = lines.Select(c => (c, c.StartLimit, c.EndLimit, c.Gaps)).ToList();
+ this.from = from;
+ this.sheetLow = sheetLow;
+ this.sheetHigh = sheetHigh;
+ this.minimum = minimum;
+ }
+
+ /// The lines that follow the cut-off.
+ public IReadOnlyList Lines => joined.Select(j => j.CutOff).ToList();
+
+ /// Moves the joined stops to , the cut-off's new center.
+ public void MoveTo(double line)
+ {
+ foreach (var (cutOff, startLimit, endLimit, gaps) in joined)
+ {
+ var stops = gaps.SelectMany(g => new[] { g.Start, g.End })
+ .Append(startLimit ?? sheetLow)
+ .Append(endLimit ?? sheetHigh)
+ .ToList();
+ var below = stops.Where(s => s < from - Tolerance.Epsilon).DefaultIfEmpty(sheetLow).Max();
+ var above = stops.Where(s => s > from + Tolerance.Epsilon).DefaultIfEmpty(sheetHigh).Min();
+ var at = System.Math.Clamp(line,
+ System.Math.Min(below + minimum, from), System.Math.Max(above - minimum, from));
+
+ cutOff.StartLimit = startLimit is double start ? Follow(start, at) : null;
+ cutOff.EndLimit = endLimit is double end ? Follow(end, at) : null;
+ cutOff.Gaps = gaps.Select(g => new CutOffGap(Follow(g.Start, at), Follow(g.End, at))).ToList();
+ }
+ }
+
+ private double Follow(double stop, double at) =>
+ System.Math.Abs(stop - from) <= Tolerance.Epsilon ? at : stop;
+ }
+
///
/// Trims and extends cut-offs against the cut-offs that cross them, by changing only their
/// start and end limits and their gaps (see docs/automatic-scrap-cutoffs.md, "Trim and
@@ -178,6 +230,42 @@ namespace OpenNest
: Single(plate, new CutOffLimitChange(target, target.StartLimit, stop));
}
+ ///
+ /// The lines that meet and follow it when it moves: perpendicular
+ /// cut-offs with a limit or gap edge on its center, where its own cut pieces reach them. A
+ /// line that only ends level with it, past its end or in one of its gaps, stays put.
+ ///
+ public static CutOffJoints Joints(Plate plate, CutOff moved, CutOffSettings settings)
+ {
+ var at = Line(moved);
+ var (movedStart, movedEnd) = NominalSpan(plate, moved);
+ var pieces = moved.Pieces(movedStart, movedEnd);
+ var lines = plate.CutOffs
+ .Where(other => other != moved && other.Axis != moved.Axis)
+ .Where(other => pieces.Any(p => Within(Line(other), p.Start, p.End)))
+ .Where(other => Stops(other).Any(stop => System.Math.Abs(stop - at) <= Tolerance.Epsilon));
+
+ var sheet = plate.BoundingBox(includeParts: false);
+ var horizontal = moved.Axis == CutOffAxis.Vertical;
+ return new CutOffJoints(lines, at, horizontal ? sheet.Left : sheet.Bottom,
+ horizontal ? sheet.Right : sheet.Top,
+ System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon));
+ }
+
+ // Where the cut-off's own limits and gaps stop it, not counting the sheet edges.
+ private static IEnumerable Stops(CutOff cutOff)
+ {
+ if (cutOff.StartLimit is double start)
+ yield return start;
+ if (cutOff.EndLimit is double end)
+ yield return end;
+ foreach (var gap in cutOff.Gaps)
+ {
+ yield return gap.Start;
+ yield return gap.End;
+ }
+ }
+
///
/// Swaps which of two meeting cut-offs runs through: the one that stopped at the junction
/// extends past it (as does), and the one that ran through is trimmed
diff --git a/OpenNest.Tests/CutOffs/CutOffJointsTests.cs b/OpenNest.Tests/CutOffs/CutOffJointsTests.cs
new file mode 100644
index 00000000..37619422
--- /dev/null
+++ b/OpenNest.Tests/CutOffs/CutOffJointsTests.cs
@@ -0,0 +1,135 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CutOffs;
+
+///
+/// Lines that meet a cut-off follow it when it moves, extended or trimmed to keep meeting it.
+///
+public class CutOffJointsTests
+{
+ private static readonly CutOffSettings Settings = new() { MinSegmentLength = 0.5 };
+
+ private static (Plate Plate, CutOff Vertical, CutOff Horizontal) Junction()
+ {
+ var plate = new Plate(60, 120);
+ var vertical = new CutOff(new Vector(30, 0), CutOffAxis.Vertical);
+ var horizontal = new CutOff(new Vector(0, 20), CutOffAxis.Horizontal) { EndLimit = 30 };
+ plate.CutOffs.Add(vertical);
+ plate.CutOffs.Add(horizontal);
+ return (plate, vertical, horizontal);
+ }
+
+ [Theory]
+ [InlineData(34)]
+ [InlineData(26)]
+ public void MoveTo_ExtendsOrTrimsTheLineToKeepMeeting(double line)
+ {
+ var (plate, vertical, horizontal) = Junction();
+
+ CutOffTrim.Joints(plate, vertical, Settings).MoveTo(line);
+
+ Assert.Equal(line, horizontal.EndLimit);
+ Assert.Null(horizontal.StartLimit);
+ }
+
+ [Fact]
+ public void Joints_MoveALineStoppedOnAHorizontalToo()
+ {
+ var plate = new Plate(60, 120);
+ var horizontal = new CutOff(new Vector(0, 20), CutOffAxis.Horizontal);
+ var vertical = new CutOff(new Vector(30, 0), CutOffAxis.Vertical) { EndLimit = 20 };
+ plate.CutOffs.Add(horizontal);
+ plate.CutOffs.Add(vertical);
+
+ CutOffTrim.Joints(plate, horizontal, Settings).MoveTo(25);
+
+ Assert.Equal(25, vertical.EndLimit);
+ }
+
+ [Fact]
+ public void MoveTo_MovesAGapEdgeOnTheLine()
+ {
+ var (plate, vertical, horizontal) = Junction();
+ horizontal.EndLimit = null;
+ horizontal.Gaps = new[] { new CutOffGap(30, 50) };
+
+ CutOffTrim.Joints(plate, vertical, Settings).MoveTo(35);
+
+ Assert.Equal(new CutOffGap(35, 50), Assert.Single(horizontal.Gaps));
+ }
+
+ [Fact]
+ public void MoveTo_LeavesTheOtherEndAlone()
+ {
+ var (plate, vertical, horizontal) = Junction();
+ horizontal.StartLimit = 10;
+
+ CutOffTrim.Joints(plate, vertical, Settings).MoveTo(40);
+
+ Assert.Equal(10, horizontal.StartLimit);
+ Assert.Equal(40, horizontal.EndLimit);
+ }
+
+ [Fact]
+ public void MoveTo_HoldsAMinimumSegmentShortOfTheNextStop_AndComesBack()
+ {
+ var (plate, vertical, horizontal) = Junction();
+ horizontal.StartLimit = 10;
+ var joints = CutOffTrim.Joints(plate, vertical, Settings);
+
+ joints.MoveTo(5);
+ Assert.Equal(10.5, horizontal.EndLimit);
+
+ joints.MoveTo(25);
+ Assert.Equal(25, horizontal.EndLimit);
+ }
+
+ [Fact]
+ public void MoveTo_StaysOnTheSheet()
+ {
+ var (plate, vertical, horizontal) = Junction();
+ var right = plate.BoundingBox(includeParts: false).Right;
+
+ CutOffTrim.Joints(plate, vertical, Settings).MoveTo(right + 50);
+
+ Assert.Equal(right - 0.5, horizontal.EndLimit);
+ }
+
+ [Fact]
+ public void Joints_SkipALineThatStopsShort()
+ {
+ var (plate, vertical, horizontal) = Junction();
+ horizontal.EndLimit = 26;
+
+ var joints = CutOffTrim.Joints(plate, vertical, Settings);
+ joints.MoveTo(34);
+
+ Assert.Empty(joints.Lines);
+ Assert.Equal(26, horizontal.EndLimit);
+ }
+
+ [Fact]
+ public void Joints_SkipALineItsOwnPiecesDoNotReach()
+ {
+ var (plate, vertical, horizontal) = Junction();
+ vertical.EndLimit = 10;
+
+ Assert.Empty(CutOffTrim.Joints(plate, vertical, Settings).Lines);
+
+ vertical.EndLimit = null;
+ vertical.Gaps = new[] { new CutOffGap(15, 25) };
+
+ Assert.Empty(CutOffTrim.Joints(plate, vertical, Settings).Lines);
+ }
+
+ [Fact]
+ public void Joints_SkipAParallelLine()
+ {
+ var (plate, vertical, _) = Junction();
+ plate.CutOffs.Add(new CutOff(new Vector(10, 0), CutOffAxis.Vertical) { EndLimit = 30 });
+
+ var joints = CutOffTrim.Joints(plate, vertical, Settings);
+
+ Assert.DoesNotContain(joints.Lines, line => line.Axis == CutOffAxis.Vertical);
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Actions/ActionTrimExtendCutOffTests.cs b/OpenNest.WinForms.Tests/Actions/ActionTrimExtendCutOffTests.cs
index f8679bb5..3660b0c6 100644
--- a/OpenNest.WinForms.Tests/Actions/ActionTrimExtendCutOffTests.cs
+++ b/OpenNest.WinForms.Tests/Actions/ActionTrimExtendCutOffTests.cs
@@ -59,6 +59,29 @@ public class ActionTrimExtendCutOffTests
Assert.IsType(view.Actions.CurrentAction);
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
+ [Fact]
+ public void DraggingACutOffTakesTheLinesThatMeetIt() => StaTestThread.Run(() =>
+ {
+ var plate = new Plate(60, 120);
+ var vertical = new CutOff(new Vector(30, 0), CutOffAxis.Vertical);
+ var horizontal = new CutOff(new Vector(0, 20), CutOffAxis.Horizontal) { EndLimit = 30 };
+ plate.CutOffs.Add(vertical);
+ plate.CutOffs.Add(horizontal);
+ plate.RegenerateCutOffs(new CutOffSettings());
+ using var view = new PlateView { Plate = plate };
+
+ var dragged = view.CutOffs.TryStartDrag(new Vector(30, 40), Tolerance);
+ Assert.Same(vertical, dragged);
+ view.CutOffs.UpdateDrag(new Vector(34, 40), dragged);
+ Assert.Equal(34, horizontal.EndLimit);
+ Assert.Equal(34, horizontal.Drawing.Program.BoundingBox().Right, 6);
+ view.CutOffs.UpdateDrag(new Vector(26, 40), dragged);
+ view.CutOffs.EndDrag();
+
+ Assert.Equal(26, horizontal.EndLimit);
+ Assert.Equal(26, horizontal.Drawing.Program.BoundingBox().Right, 6);
+ }, TimeSpan.FromMinutes(1), "The STA test did not complete.");
+
[Fact]
public void ApplyRegeneratesAndRefusalsChangeNothing() => StaTestThread.Run(() =>
{
diff --git a/OpenNest/Controls/CutOffHandler.cs b/OpenNest/Controls/CutOffHandler.cs
index 434747f4..b962b3c8 100644
--- a/OpenNest/Controls/CutOffHandler.cs
+++ b/OpenNest/Controls/CutOffHandler.cs
@@ -15,6 +15,10 @@ namespace OpenNest.Controls
// Open from the press on a cut-off to the release: a drag undoes as one move.
private ChangeScope drag;
+ // The lines that meet the dragged cut-off and follow it.
+ private CutOffJoints joints;
+ private bool moved;
+
public CutOffHandler(PlateView view)
{
this.view = view;
@@ -33,6 +37,8 @@ namespace OpenNest.Controls
IsDragging = true;
dragPerimeterCache = Plate.BuildPerimeterCache(view.Plate);
+ joints = CutOffTrim.Joints(view.Plate, hitCutOff, view.CutOffSettings);
+ moved = false;
drag = view.BeginChange("Move Cut-Off");
return hitCutOff;
}
@@ -49,6 +55,10 @@ namespace OpenNest.Controls
cutOff.Position = new Vector(cutOff.Position.X, currentPoint.Y);
cutOff.Regenerate(view.Plate, view.CutOffSettings, dragPerimeterCache);
+ moved = true;
+ joints?.MoveTo(cutOff.Axis == CutOffAxis.Vertical ? cutOff.Position.X : cutOff.Position.Y);
+ foreach (var line in joints?.Lines ?? new List())
+ line.Regenerate(view.Plate, view.CutOffSettings, dragPerimeterCache);
view.Invalidate();
}
@@ -60,6 +70,9 @@ namespace OpenNest.Controls
IsDragging = false;
dragPerimeterCache = null;
view.Plate.RegenerateCutOffs(view.CutOffSettings);
+ if (moved && joints != null)
+ PlateSequencing.PlaceCutOffs(view.Plate, joints.Lines);
+ joints = null;
view.Invalidate();
var scope = drag;
diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md
index cb6f6991..3af2d323 100644
--- a/docs/automatic-scrap-cutoffs.md
+++ b/docs/automatic-scrap-cutoffs.md
@@ -110,6 +110,8 @@ Saving `.nest` retains ordinary cutoff positions, axes, limits, head-locked over
- **Extend**: click near an end to grow. An end bordering a gap closes that gap (one click removes the whole gap). The line's own end runs to the next crossing cut-off beyond it, skipping one sitting at the current end (click again to go one more step), or to the sheet edge, which clears the limit so the usual overtravel or head-locked overcut applies again.
- **Swap Through-Cut**: right-click where two cut-offs meet, one running through and the other stopping at it. The stopped one extends past the junction as Extend does, and the through one is trimmed there, losing the side away from the plate's origin (and any gaps on it), since only the origin-side edge is certain. On a 96 x 48 sheet whose verticals stop at a full-length horizontal separator, swapping at the last vertical leaves that vertical splitting the sheet and keeps the area beyond it whole. A junction a gap touches is not offered, and a swap there is refused: close the gap first.
+Dragging a cut-off takes along the lines that meet it (`CutOffTrim.Joints`): a perpendicular line with a limit or gap edge on its center, where its own cut pieces reach, is extended or trimmed in the same drag and undoes with it, and the changed lines' segments are placed as for any edit. A stop never passes the next stop on its line: dragged past one, it holds a **Minimum segment length** short of it, or of the sheet edge, and comes back if the drag does. A line that stopped short before the drag, such as one left behind by a move in an earlier version, is not joined: use Extend or Trim to bring it to the line.
+
A gap is never cut, and nothing treats it as cut: segment generation excludes it like a part; a segment's span of the nominal line (`CutOffSegments`) stops at a gap between segments; the cutting dependencies (`CuttingDependencyGraph.CutOffCrosses`) do not make a segment precede a part wholly inside a gap; the clamp check ignores a clamp wholly inside one; and a gapped line never counts as full span, so it defines no expected remnant, never separates the sheet, and is a limited-line conflict for automatic cut-offs on the same line.
The hover preview dashes the stretches an edit removes in red and those it adds in green. Each edit undoes as one step ("Trim Cut-Off", "Extend Cut-Off", "Swap Through-Cut"), regenerates the plate's cut-offs and places the changed cut-offs' segments with `PlateSequencing.PlaceCutOffs`, so a line that now splits the sheet is cut last and one that no longer does moves before the parts it crosses.