feat(cutoffs): cut sheet-separating cut-offs after every part

This commit is contained in:
aj committed 2026-10-13 21:09:35 -04:00
1 parent b9a387b517
commit ddd1e422be
10 files changed
+273 -26

No files matched your search

+55
View File
@@ -50,6 +50,61 @@ namespace OpenNest
return (segments.Sum(s => s.End - s.Start), segments.Count);
}
/// <summary>
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, cuts
/// one continuous straight line on the definition's axis across the whole sheet, so it
/// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
/// line on the sheet edge, a stale or unrecognised program) does not separate the sheet.
/// </summary>
internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet)
{
if (part?.Program == null || definition == null || sheet == null
|| part.Program.Mode != Mode.Absolute)
return false;
var vertical = definition.Axis == CutOffAxis.Vertical;
var line = vertical ? definition.Position.X : definition.Position.Y;
var acrossMin = vertical ? sheet.Left : sheet.Bottom;
var acrossMax = vertical ? sheet.Right : sheet.Top;
var alongMin = vertical ? sheet.Bottom : sheet.Left;
var alongMax = vertical ? sheet.Top : sheet.Right;
if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon))
return false;
Vector? position = null;
double? low = null, high = null;
var cutting = false;
foreach (var code in part.Program.Codes)
{
switch (code)
{
case RapidMove rapid:
if (cutting)
return false; // A second pierce leaves an uncut gap.
position = rapid.EndPoint + part.Location;
break;
case LinearMove move when position != null:
var end = move.EndPoint + part.Location;
if (!OnLine(position.Value) || !OnLine(end))
return false;
foreach (var value in new[] { Along(position.Value), Along(end) })
{
low = low == null ? value : System.Math.Min(low.Value, value);
high = high == null ? value : System.Math.Max(high.Value, value);
}
position = end;
cutting = true;
break;
case Motion:
return false;
}
}
return cutting && low <= alongMin + Math.Tolerance.Epsilon && high >= alongMax - Math.Tolerance.Epsilon;
bool OnLine(Vector point) =>
System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon;
double Along(Vector point) => vertical ? point.Y : point.X;
}
private string GetName()
{
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
@@ -83,6 +83,16 @@ internal sealed class CuttingDependencyGraph
{
if (!nodes[cut].IsCutOff)
continue;
if (nodes[cut].SeparatesSheet)
{
// Cutting a line that splits the sheet comes after every part and every other
// cutoff, so the head never travels back across the separated edge. Such a line
// is continuous, so it passes through no part and needs no crossing edge.
for (var other = 0; other < nodes.Count; other++)
if (!nodes[other].SeparatesSheet)
edges[cut].Add(other);
continue;
}
var definition = nodes[cut].CutOff;
for (var part = 0; part < nodes.Count; part++)
{
@@ -190,9 +200,10 @@ internal sealed class DependencyNode
private LeadMaterialSnapshot material;
internal DependencyNode(bool isCutOff, CutOffDefinition cutOff, Box placedBounds, Box cleanBounds,
Func<LeadMaterialSnapshot> materialFactory)
Func<LeadMaterialSnapshot> materialFactory, bool separatesSheet = false)
{
IsCutOff = isCutOff;
SeparatesSheet = isCutOff && separatesSheet;
CutOff = cutOff;
PlacedBounds = Copy(placedBounds);
CleanBounds = cleanBounds == null ? null : Copy(cleanBounds);
@@ -201,6 +212,8 @@ internal sealed class DependencyNode
}
internal bool IsCutOff { get; }
/// <summary>A cutoff whose placed program is one continuous cut across the whole sheet.</summary>
internal bool SeparatesSheet { get; }
internal CutOffDefinition CutOff { get; }
internal Box PlacedBounds { get; }
internal Box CleanBounds { get; }
@@ -147,12 +147,15 @@ public sealed class FixedProgramPlacement
}
return new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution,
OwnedProgramCopy.Copy(proposed, token), Prepared, Material, choices)
{ SelectedProgram = selected, IsCutOff = IsCutOff };
{ SelectedProgram = selected, IsCutOff = IsCutOff, SeparatesSheet = SeparatesSheet };
}
/// <summary>A cutoff: always a fixed open-cut program, never material or regenerated.</summary>
public bool IsCutOff { get; internal init; }
/// <summary>A cutoff that splits the whole sheet; it is cut after every other placement.</summary>
public bool SeparatesSheet { get; internal init; }
public Part SourcePart { get; }
public int SourceOrdinal { get; }
public Vector Location { get; }
@@ -78,12 +78,15 @@ public static class CuttingPlanService
var cut = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
if (!cut.HasCuttingContour)
throw new ArgumentException("Placed cutoff program has no nonzero cutting motions.");
nodes.Add(new(true, definitions.TryGetValue(source.BaseDrawing, out var definition)
var defined = definitions.TryGetValue(source.BaseDrawing, out var definition);
var separates = defined && CutOff.SeparatesSheet(source, definition,
request.Plate.BoundingBox(includeParts: false));
nodes.Add(new(true, defined
? new(definition.Axis, definition.Position, definition.StartLimit, definition.EndLimit) : null,
source.BoundingBox, null, null));
source.BoundingBox, null, null, separates));
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
cut, OwnedProgramCopy.Copy(source.Program, token))
{ IsCutOff = true });
{ IsCutOff = true, SeparatesSheet = separates });
continue;
}
var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token);
@@ -382,6 +385,10 @@ public static class CuttingPlanService
{
var part = snapshot.Placements[violation.Part];
var prerequisite = snapshot.Placements[violation.Prerequisite];
if (part.SeparatesSheet)
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
$"{subject} cuts the sheet-separating cutoff (part {part.SourceOrdinal + 1}) before part "
+ $"{prerequisite.SourceOrdinal + 1}; every part and other cutoff must be cut before the sheet is separated.");
var reason = prerequisite.IsCutOff ? "the cutoff crossing it" : "the part nested in its cutout";
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
$"{subject} cuts part {part.SourceOrdinal + 1} before {reason} (part {prerequisite.SourceOrdinal + 1}).");
+18 -6
View File
@@ -34,16 +34,27 @@ namespace OpenNest.Engine.Sequencing
private static List<Part> OrderCutOffsFirst(List<Part> ordered, Plate plate)
{
var cuts = ordered.Select((part, index) => (Part: part, Index: index))
.Where(item => item.Part.BaseDrawing.IsCutOff).ToList();
if (cuts.Count == 0)
return ordered;
var definitions = new Dictionary<Drawing, CutOff>(ReferenceEqualityComparer.Instance);
foreach (var cutOff in plate.CutOffs)
definitions[cutOff.Drawing] = cutOff;
var bounds = plate.BoundingBox(includeParts: false);
// A cutoff that splits the whole sheet goes after every part and other cutoff, so the
// head never travels back across it. It passes through no part.
var separating = ordered.Where(part => part.BaseDrawing.IsCutOff
&& definitions.TryGetValue(part.BaseDrawing, out var definition)
&& CutOff.SeparatesSheet(part, definition, bounds)).ToList();
if (separating.Count > 0)
{
var last = new HashSet<Part>(separating, ReferenceEqualityComparer.Instance);
ordered = ordered.Where(part => !last.Contains(part)).ToList();
}
var cuts = ordered.Select((part, index) => (Part: part, Index: index))
.Where(item => item.Part.BaseDrawing.IsCutOff).ToList();
if (cuts.Count == 0)
return ordered.Concat(separating).ToList();
var emitted = new bool[ordered.Count];
var result = new List<Part>(ordered.Count);
for (var i = 0; i < ordered.Count; i++)
@@ -75,6 +86,7 @@ namespace OpenNest.Engine.Sequencing
}
}
result.AddRange(separating);
return result;
}
}
@@ -0,0 +1,65 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Tests.CutOffs;
/// <summary>Which cut-offs split the whole sheet (and so must be cut after every part).</summary>
public class CutOffSeparationTests
{
[Theory]
[InlineData(1, CutDirection.AwayFromOrigin)]
[InlineData(2, CutDirection.TowardOrigin)]
[InlineData(3, CutDirection.AwayFromOrigin)]
[InlineData(4, CutDirection.TowardOrigin)]
public void ContinuousFullSpanLine_SeparatesTheSheet_InEveryQuadrantAndDirection(int quadrant, CutDirection direction)
{
var plate = new Plate(60, 120) { Quadrant = quadrant };
var bounds = plate.BoundingBox(includeParts: false);
plate.Parts.Add(TestHelpers.MakePartAt(bounds.Left + 10, bounds.Bottom + 10, 10));
var vertical = new CutOff(new Vector(bounds.Left + 40, 0), CutOffAxis.Vertical);
var horizontal = new CutOff(new Vector(0, bounds.Bottom + 40), CutOffAxis.Horizontal);
var crossing = new CutOff(new Vector(bounds.Left + 15, 0), CutOffAxis.Vertical);
plate.CutOffs.AddRange([vertical, horizontal, crossing]);
plate.RegenerateCutOffs(new CutOffSettings { CutDirection = direction, Overtravel = 0.5 });
Assert.True(Separates(plate, vertical));
Assert.True(Separates(plate, horizontal));
Assert.False(Separates(plate, crossing)); // Trimmed around the part: two pieces.
}
[Fact]
public void LimitedEdgeStaleOrBrokenLines_DoNotSeparateTheSheet()
{
var plate = new Plate(60, 120);
plate.Parts.Add(TestHelpers.MakePartAt(10, 10, 10));
var limited = new CutOff(new Vector(40, 0), CutOffAxis.Vertical) { EndLimit = 30 };
var onEdge = new CutOff(new Vector(0, 0), CutOffAxis.Vertical);
var stale = new CutOff(new Vector(50, 0), CutOffAxis.Vertical);
var split = new CutOff(new Vector(70, 0), CutOffAxis.Vertical);
plate.CutOffs.AddRange([limited, onEdge, stale, split]);
plate.RegenerateCutOffs(new CutOffSettings());
Assert.True(Separates(plate, stale));
Assert.True(Separates(plate, split));
stale.Position = new Vector(51, 0); // Moved without regeneration: its program is elsewhere.
var program = new Program();
program.Codes.Add(new RapidMove(70, 0));
program.Codes.Add(new LinearMove(70, 30));
program.Codes.Add(new RapidMove(70, 30.5));
program.Codes.Add(new LinearMove(70, 60));
split.Drawing.Program = program;
var broken = new Part(split.Drawing);
Assert.False(Separates(plate, limited));
Assert.False(Separates(plate, onEdge));
Assert.False(Separates(plate, stale));
Assert.False(CutOff.SeparatesSheet(broken, split, plate.BoundingBox(false))); // Two pierces leave a gap.
Assert.False(CutOff.SeparatesSheet(Part(plate, limited), null, plate.BoundingBox(false)));
}
private static bool Separates(Plate plate, CutOff cutOff) =>
CutOff.SeparatesSheet(Part(plate, cutOff), cutOff, plate.BoundingBox(includeParts: false));
private static Part Part(Plate plate, CutOff cutOff) =>
Assert.Single(plate.Parts, p => ReferenceEquals(p.BaseDrawing, cutOff.Drawing));
}
@@ -39,6 +39,67 @@ public class CuttingDependencyTests
Assert.Contains(q, plate.Parts);
}
[Fact]
public void Plan_SheetSeparatingCutOff_IsCutAfterEveryPartAndOtherCutOff()
{
var parameters = ExplicitContourTests.Parameters();
var (plate, separator, crossing, left, right) = SeparatedPlate();
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters));
var separatorIndex = plate.Parts.IndexOf(separator);
Assert.Equal(Enumerable.Range(0, plate.Parts.Count).Where(i => i != separatorIndex),
snapshot.Dependencies.PrerequisitesOf(separatorIndex));
var result = CuttingPlanService.Plan(snapshot);
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
var order = result.ProposedOrder.Select(o => o.SourcePart).ToList();
Assert.Same(separator, order[^1]);
Assert.True(result.ProposedOrder[^1].SeparatesSheet);
Assert.False(result.ProposedOrder.Single(o => ReferenceEquals(o.SourcePart, crossing)).SeparatesSheet);
Assert.True(order.IndexOf(crossing) < order.IndexOf(left));
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
Assert.Same(separator, plate.Parts[^1]);
Assert.Contains(right, plate.Parts);
}
[Fact]
public void Plan_KeptOrderCuttingTheSeparatorFirst_IsAConstraintConflictThatSaysWhy()
{
var (plate, separator, _, _, _) = SeparatedPlate();
Assert.Same(separator, plate.Parts[0]);
var result = CuttingPlanService.Plan(CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
confirmedParameters: ExplicitContourTests.Parameters(), preservePartOrder: true)));
Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status);
Assert.Contains(result.Findings, f => f.Message.Contains("sheet-separating cutoff")
&& f.Message.Contains("before the sheet is separated"));
}
// 100 x 100 sheet: squares at X 1-11 and 60-70; a cutoff at X 5 is trimmed around the left
// square, while a continuous line at X 40 splits the sheet. Both cutoffs start the sequence.
private static (Plate, Part Separator, Part Crossing, Part Left, Part Right) SeparatedPlate()
{
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Size = new Size(100, 100);
var left = new Part(new Drawing("left", ExplicitContourTests.Square(false)), new Vector(1, 1));
var right = new Part(new Drawing("right", ExplicitContourTests.Square(false)), new Vector(60, 1));
plate.Parts.Add(left);
plate.Parts.Add(right);
var separator = new CutOff(new Vector(40, 0), CutOffAxis.Vertical);
var crossing = new CutOff(new Vector(5, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(separator);
plate.CutOffs.Add(crossing);
plate.RegenerateCutOffs(new CutOffSettings());
var cuts = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToArray();
Assert.Equal(2, cuts.Length);
var separatorPart = cuts.Single(p => ReferenceEquals(p.BaseDrawing, separator.Drawing));
var crossingPart = cuts.Single(p => ReferenceEquals(p.BaseDrawing, crossing.Drawing));
plate.Parts.Reorder([separatorPart, crossingPart, left, right]);
return (plate, separatorPart, crossingPart, left, right);
}
[Fact]
public void Apply_CutOffReclassifiedAfterPlanning_IsStale()
{
@@ -245,20 +245,42 @@ public class PlateSequencingTests
}
[Fact]
public void Apply_NonCrossingTailCutOff_KeepsItsNormalSequencePlace()
public void Apply_SheetSeparatingCutOff_GoesAfterEveryPartAndCrossingCutOff()
{
var part = TestHelpers.MakePartAt(10, 10, 10);
var plate = TestHelpers.MakePlate(60, 120, part);
var tail = new CutOff(new Vector(40, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(tail);
plate.RegenerateCutOffs(new CutOffSettings());
var parameters = new SequenceParameters { Method = SequenceMethod.LeftSide };
var expected = Baseline(plate, parameters);
Assert.Same(part, expected[0]);
var hazards = 0;
foreach (var method in Enum.GetValues<SequenceMethod>())
{
var left = TestHelpers.MakePartAt(10, 10, 10);
var right = TestHelpers.MakePartAt(80, 10, 10);
var plate = TestHelpers.MakePlate(60, 120, left, right);
var separator = new CutOff(new Vector(40, 0), CutOffAxis.Vertical);
var crossing = new CutOff(new Vector(85, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(separator);
plate.CutOffs.Add(crossing);
plate.RegenerateCutOffs(new CutOffSettings());
var parameters = new SequenceParameters { Method = method };
var separatorPart = plate.Parts.Single(p => ReferenceEquals(p.BaseDrawing, separator.Drawing));
List<Part> baseline;
try
{
baseline = Baseline(plate, parameters).ToList();
}
catch (NotSupportedException)
{
continue;
}
if (baseline.IndexOf(separatorPart) < baseline.IndexOf(right)
|| baseline.IndexOf(separatorPart) < baseline.IndexOf(left))
hazards++;
PlateSequencing.Apply(plate, parameters);
PlateSequencing.Apply(plate, parameters);
Assert.Equal(expected, plate.Parts);
Assert.Same(separatorPart, plate.Parts[^1]);
AssertPrecedes(plate, crossing, right);
Assert.Equal(4, plate.Parts.Count);
}
// Without the rule, at least one route cuts the separator before a part.
Assert.True(hazards > 0);
}
[Fact]
+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.
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 tail separator that crosses no parts keeps its normal route position rather than being forced to the front. Cutoff geometry, clearance, part programs, placements, and quantities are unchanged.
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.
+12 -3
View File
@@ -95,6 +95,15 @@ the final replay enforce them:
plate-scoped request; they are always fixed programs, need no lead-in, are not
material for lead validation and, being open cuts, never become rapid obstacles.
Rapids into and out of them are still checked.
- A cutoff that splits the whole sheet is cut after every part and every other
cutoff, so the head never travels back across the separated edge. It qualifies
when its placed program is one continuous straight cut on its own line spanning
the sheet from edge to edge (`CutOff.SeparatesSheet`). A line trimmed around parts,
limited short of an edge, on the sheet edge, stale after a move or in an
unrecognised program form does not qualify and keeps the crossing rule above.
Being continuous, a separating line passes through no part. A kept order that cuts
it earlier is a constraint conflict naming the separating cutoff (user decision,
2026-10-13).
- A part whose perimeter lies strictly inside a cutout of another part precedes
that host. Material bounds (never rapids or scribe marks) only select candidate
pairs; containment is proven on native clean material. Touching or crossing boundaries, or material that cannot be
@@ -427,9 +436,9 @@ panel is wired in the editor. Non-editor callers still use the shared analyzer.
`Cut-off Settings...` opens the Automatic Scrap Cut-offs dialog in settings mode for
the same scope: it previews on the active plate as usual, but `Use These Settings`
only returns the values (it adds nothing), ticks the box and replans. The panel
remembers the last settings per unit system until the application closes. A cut-off
still goes before every part its nominal line passes through; a line that passes
through no part keeps a free position in the order (user decision, 2026-10-13).
remembers the last settings per unit system until the application closes. Cut-off
order follows the dependency rules above: crossing cut-offs before the parts they
pass through, sheet-separating cut-offs last.
- Lead-ins are optional for planning. With `None` (or a style that emits no lead
motion), the emitter places the pierce directly on the selected contour point.
Unlocked contour starts can still move to improve travel; locked programs keep