fix(sequencing): cut scrap cutoffs before crossed parts

This commit is contained in:
aj
2026-09-29 00:05:28 -04:00
parent c3dd346b7a
commit 8720580004
6 changed files with 333 additions and 7 deletions
+1
View File
@@ -156,6 +156,7 @@ Keep vendor programming manuals and full-text extracts outside source control un
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. After its null/empty guards, `DefaultFillComparer` decides unequal counts without scoring; equal counts still use scores, and exact ties retain the current layout. `FillHelpers.FillPattern` computes eager scores only when no custom comparer is supplied; custom comparers remain authoritative and may perform their own scoring.
- **Extents column pitch**: for finite valid geometry, finite pair height, and finite nonnegative spacing, `FillExtents.BuildColumn` uses `pair.Bbox.Width + partSpacing` directly. The old vertical slide calculation clamps to the same pitch, so it need not prepare boundaries or temporary test clones. Negative/nonfinite spacing or nonfinite pair height retains the legacy calculation: public/interactive callers do not all validate spacing. Do not remove `BuildPair` boundary preparation or the adjusted-column overlap fallback, or turn this shortcut into a geometry/validation policy change.
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds each at its previous index in `Plate.Parts` (its cut sequence number; new cut-offs go at the end). Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load; `CutOffDto.Sequence` restores each one's place in the cut sequence). Posts must follow `Plate.Parts` order for cut-offs too, not move them to the end.
- **Plate sequencing**: `PlateSequencing.Apply` in Engine is the shared current/all-plate application boundary. Reverse the sequencer's exit-first route before enforcing cutoff-before-crossed-part dependencies, then commit `Plate.Parts` order. Use nominal cutoff spans and reference-keyed definitions, not trimmed segments or drawing names. The conservative placed-bounds check honors start/end limits, preserves ordinary part order, and does not force noncrossing tail separators to the front. This runs on automatic sequence application, not manual edits or regeneration; see [cutoff sequencing](docs/automatic-scrap-cutoffs.md#part-sequencing).
- **Automatic scrap cutoffs**: `AutomaticCutOffPlanner.Create` in Core proposes detached vertical `CutOff` definitions and preview parts from the real-part envelope, with quadrant-aware pitch and a verified full-width tail separator beyond `max(PartSpacing, PartClearance)` plus tolerance. Planning must not mutate the plate. Apply only an unblocked, current plan by adding its definitions to `Plate.CutOffs` and calling `Plate.RegenerateCutOffs`; never accept preview Parts directly. Existing equivalent full-span lines are suppressed, while same-line limited cuts require manual review. Nominal spacing is not certification of disconnected hopper-sized scrap. Both Plate and Nest menus use the modal `AutomaticCutOffForm`; Nest applies one setting set to every plate through Core's `AutomaticCutOffBatch`, which replans all plates before any changes, refuses the entire batch on blocking diagnostics, and rolls back cutoff programs/sequence on failure. Minimum tail-to-keep defaults to 12 in / 304.8 mm in the dialog; `AutomaticCutOffOptions.MinimumTailLength` is in model units (zero disables the minimum). A shorter tail suppresses only the new final separator, never other grid lines or existing definitions. See [operator workflow and limitations](docs/automatic-scrap-cutoffs.md).
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). MCP, API, and Training projects use `ImportDrawing` for headless conversion. The console uses `Import` followed by `BuildDrawing` so it can report bend-repair outcomes. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
@@ -0,0 +1,92 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Sequencing
{
/// <summary>Applies a sequencer's exit-first route as the plate's cutting order.</summary>
public static class PlateSequencing
{
public static void Apply(Plate plate, SequenceParameters parameters)
{
var sequencer = PartSequencerFactory.Create(parameters);
var ordered = sequencer.Sequence(plate.Parts.ToList(), plate)
.Select(p => p.Part).Reverse().ToList();
// Enforce dependencies AFTER reversing the exit-first route. Checking
// the sequencer's output itself would invert the safety rule on apply.
var cutOrder = OrderCutOffsFirst(ordered, plate);
plate.Parts.Clear();
foreach (var part in cutOrder)
plate.Parts.Add(part);
}
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);
var emitted = new bool[ordered.Count];
var result = new List<Part>(ordered.Count);
for (var i = 0; i < ordered.Count; i++)
{
var part = ordered[i];
if (!part.BaseDrawing.IsCutOff)
{
foreach (var cut in cuts)
{
if (emitted[cut.Index])
continue;
// An orphaned cutoff still must not follow potentially
// crossed parts when its nominal span cannot be recovered.
if (!definitions.TryGetValue(cut.Part.BaseDrawing, out var definition)
|| CrossesBounds(definition, part.BoundingBox, bounds))
{
result.Add(cut.Part);
emitted[cut.Index] = true;
}
}
}
if (!emitted[i])
{
result.Add(part);
emitted[i] = true;
}
}
return result;
}
private static bool CrossesBounds(CutOff cutOff, Box part, Box plate)
{
var vertical = cutOff.Axis == CutOffAxis.Vertical;
var position = vertical ? cutOff.Position.X : cutOff.Position.Y;
var acrossMin = vertical ? part.Left : part.Bottom;
var acrossMax = vertical ? part.Right : part.Top;
var alongMin = vertical ? part.Bottom : part.Left;
var alongMax = vertical ? part.Top : part.Right;
var start = cutOff.StartLimit ?? (vertical ? plate.Bottom : plate.Left);
var end = cutOff.EndLimit ?? (vertical ? plate.Top : plate.Right);
// Use the nominal line, not its trimmed cutting segments (which
// deliberately skip the parts). Bounds conservatively include edge
// contacts and concave recesses; limits prevent unrelated dependencies
// beyond the cutoff's span. Negative coordinates need no special case.
return !(position < acrossMin - Tolerance.Epsilon
|| position > acrossMax + Tolerance.Epsilon
|| System.Math.Max(start, end) < alongMin - Tolerance.Epsilon
|| System.Math.Min(start, end) > alongMax + Tolerance.Epsilon);
}
}
}
@@ -0,0 +1,230 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.Sequencing;
public class PlateSequencingTests
{
[Fact]
public void Apply_WithoutCutOffs_PreservesExistingReversedSequencerOrder()
{
var plate = TestHelpers.MakePlate(60, 120,
TestHelpers.MakePartAt(10, 5), TestHelpers.MakePartAt(30, 20),
TestHelpers.MakePartAt(20, 10));
var parameters = new SequenceParameters { Method = SequenceMethod.LeastCode };
var expected = Baseline(plate, parameters);
PlateSequencing.Apply(plate, parameters);
Assert.Equal(expected, plate.Parts);
}
[Theory]
[InlineData(CutOffAxis.Vertical, CutDirection.AwayFromOrigin)]
[InlineData(CutOffAxis.Vertical, CutDirection.TowardOrigin)]
[InlineData(CutOffAxis.Horizontal, CutDirection.AwayFromOrigin)]
[InlineData(CutOffAxis.Horizontal, CutDirection.TowardOrigin)]
public void Apply_MovesEveryCrossingCutOffBeforeEveryCrossedPart(
CutOffAxis axis, CutDirection direction)
{
var drawing = TestHelpers.MakeSquareDrawing();
var first = new Part(drawing, new Vector(10, 10));
var second = new Part(drawing, axis == CutOffAxis.Vertical
? new Vector(10, 30) : new Vector(30, 10));
var unrelated = TestHelpers.MakePartAt(60, 50);
var plate = TestHelpers.MakePlate(60, 120, first, second, unrelated);
plate.Quantity = 2;
var cutA = new CutOff(new Vector(13, 13), axis);
var cutB = new CutOff(new Vector(17, 17), axis);
plate.CutOffs.Add(cutA);
plate.CutOffs.Add(cutB);
var settings = new CutOffSettings { CutDirection = direction };
plate.RegenerateCutOffs(settings);
var parameters = new SequenceParameters { Method = SequenceMethod.LeastCode };
var baseline = Baseline(plate, parameters);
var programs = plate.Parts.Select(p => (Part: p, p.Program, p.Location, p.Rotation)).ToArray();
var nested = drawing.Quantity.Nested;
PlateSequencing.Apply(plate, parameters);
AssertPrecedes(plate, cutA, first, second);
AssertPrecedes(plate, cutB, first, second);
Assert.Equal(baseline.Where(p => !p.BaseDrawing.IsCutOff),
plate.Parts.Where(p => !p.BaseDrawing.IsCutOff));
Assert.Equal(programs.Length, plate.Parts.Count);
Assert.Equal(nested, drawing.Quantity.Nested);
foreach (var item in programs)
{
Assert.Contains(item.Part, plate.Parts);
Assert.Same(item.Program, item.Part.Program);
Assert.Equal(item.Location, item.Part.Location);
Assert.Equal(item.Rotation, item.Part.Rotation);
}
var sequence = plate.Parts.Select(p => p.BaseDrawing).ToArray();
plate.RegenerateCutOffs(settings);
Assert.Equal(sequence, plate.Parts.Select(p => p.BaseDrawing));
AssertPrecedes(plate, cutA, first, second);
AssertPrecedes(plate, cutB, first, second);
}
[Theory]
[InlineData(SequenceMethod.RightSide)]
[InlineData(SequenceMethod.LeftSide)]
[InlineData(SequenceMethod.BottomSide)]
[InlineData(SequenceMethod.EdgeStart)]
[InlineData(SequenceMethod.LeastCode)]
[InlineData(SequenceMethod.Advanced)]
public void Apply_AllMethodsAndQuadrants_RespectCutOffDependencies(SequenceMethod method)
{
foreach (var quadrant in new[] { 1, 2, 3, 4 })
{
var plate = new Plate(60, 120) { Quadrant = quadrant };
var bounds = plate.BoundingBox(false);
var part = TestHelpers.MakePartAt(bounds.Left + 20, bounds.Bottom + 20, 10);
part.Rotate(System.Math.PI / 4, part.Location);
Assert.True(bounds.Contains(part.BoundingBox));
plate.Parts.Add(part);
var center = part.BoundingBox.Center;
var vertical = new CutOff(center, CutOffAxis.Vertical);
var horizontal = new CutOff(center, CutOffAxis.Horizontal);
plate.CutOffs.Add(vertical);
plate.CutOffs.Add(horizontal);
plate.RegenerateCutOffs(new CutOffSettings());
PlateSequencing.Apply(plate, new SequenceParameters { Method = method });
AssertPrecedes(plate, vertical, part);
AssertPrecedes(plate, horizontal, part);
}
}
[Theory]
[InlineData(CutOffAxis.Vertical, SequenceMethod.LeftSide)]
[InlineData(CutOffAxis.Horizontal, SequenceMethod.BottomSide)]
public void Apply_LimitedSameNamedCutOffs_OnlyMoveBeforePartsWithinTheirSpans(
CutOffAxis axis, SequenceMethod method)
{
var first = TestHelpers.MakePartAt(10, 10, 10);
var second = axis == CutOffAxis.Vertical
? TestHelpers.MakePartAt(10, 30, 10) : TestHelpers.MakePartAt(30, 10, 10);
var plate = TestHelpers.MakePlate(60, 120, first, second);
var lower = new CutOff(new Vector(15, 15), axis) { StartLimit = 5, EndLimit = 25 };
var upper = new CutOff(new Vector(15, 15), axis) { StartLimit = 25, EndLimit = 45 };
plate.CutOffs.Add(lower);
plate.CutOffs.Add(upper);
plate.RegenerateCutOffs(new CutOffSettings());
Assert.Equal(lower.Drawing.Name, upper.Drawing.Name);
var lowerPart = plate.Parts.Single(p => ReferenceEquals(p.BaseDrawing, lower.Drawing));
var upperPart = plate.Parts.Single(p => ReferenceEquals(p.BaseDrawing, upper.Drawing));
PlateSequencing.Apply(plate, new SequenceParameters { Method = method });
Assert.Equal(new[] { upperPart, second, lowerPart, first }, plate.Parts);
Assert.Equal(new[] { lower, upper }, plate.CutOffs);
}
[Fact]
public void Apply_NonCrossingTailCutOff_KeepsItsNormalSequencePlace()
{
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]);
PlateSequencing.Apply(plate, parameters);
Assert.Equal(expected, plate.Parts);
}
[Fact]
public void Apply_OrphanedCutOff_ConservativelyPrecedesAllParts()
{
var part = TestHelpers.MakePartAt(10, 10, 10);
var orphan = TestHelpers.MakePartAt(0, 0);
orphan.BaseDrawing.IsCutOff = true;
var plate = TestHelpers.MakePlate(60, 120, part, orphan);
PlateSequencing.Apply(plate,
new SequenceParameters { Method = SequenceMethod.LeftSide });
Assert.Equal(new[] { orphan, part }, plate.Parts);
}
[Fact]
public void Apply_EmptyAndCutOffOnlyPlates_PreserveAllEntries()
{
var plate = new Plate(60, 120);
var parameters = new SequenceParameters { Method = SequenceMethod.LeastCode };
PlateSequencing.Apply(plate, parameters);
Assert.Empty(plate.Parts);
plate.CutOffs.Add(new CutOff(new Vector(10, 0), CutOffAxis.Vertical));
plate.CutOffs.Add(new CutOff(new Vector(20, 0), CutOffAxis.Vertical));
plate.RegenerateCutOffs(new CutOffSettings());
var expected = Baseline(plate, parameters);
PlateSequencing.Apply(plate, parameters);
Assert.Equal(expected, plate.Parts);
}
[Fact]
public void Apply_SaveAndReload_PreservesCorrectedMixedSequence()
{
var nest = new Nest("sequenced-cutoffs");
var drawing = TestHelpers.MakeSquareDrawing();
nest.Drawings.Add(drawing);
var part = new Part(drawing, new Vector(10, 10));
var plate = TestHelpers.MakePlate(60, 120, part);
nest.Plates.Add(plate);
var crossing = new CutOff(new Vector(15, 0), CutOffAxis.Vertical);
var tail = new CutOff(new Vector(40, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(crossing);
plate.CutOffs.Add(tail);
plate.RegenerateCutOffs(new CutOffSettings());
PlateSequencing.Apply(plate,
new SequenceParameters { Method = SequenceMethod.LeftSide });
AssertPrecedes(plate, crossing, part);
Assert.False(plate.Parts[1].BaseDrawing.IsCutOff);
var expected = plate.Parts.Select(p => p.BaseDrawing.Name).ToArray();
using var stream = new MemoryStream();
new NestWriter(nest).Write(stream);
stream.Position = 0;
var loaded = new NestReader(stream).Read();
Assert.Equal(expected, loaded.Plates[0].Parts.Select(p => p.BaseDrawing.Name));
}
[Fact]
public void Apply_InvalidSequenceMethod_LeavesPlateUntouched()
{
var plate = TestHelpers.MakePlate(60, 120, TestHelpers.MakePartAt(10, 5));
var before = plate.Parts.ToArray();
Assert.Throws<NotSupportedException>(() => PlateSequencing.Apply(plate,
new SequenceParameters { Method = (SequenceMethod)999 }));
Assert.Equal(before, plate.Parts);
}
private static Part[] Baseline(Plate plate, SequenceParameters parameters) =>
PartSequencerFactory.Create(parameters).Sequence(plate.Parts.ToList(), plate)
.Select(p => p.Part).Reverse().ToArray();
private static void AssertPrecedes(Plate plate, CutOff cutOff, params Part[] crossed)
{
var cutPart = Assert.Single(plate.Parts,
p => ReferenceEquals(p.BaseDrawing, cutOff.Drawing));
foreach (var part in crossed)
Assert.True(plate.Parts.IndexOf(cutPart) < plate.Parts.IndexOf(part),
$"{cutOff.Drawing.Name} must precede the part at {part.Location}.");
}
}
+1 -6
View File
@@ -577,12 +577,7 @@ namespace OpenNest.Forms
private static void SequencePlate(Plate plate)
{
var parameters = new SequenceParameters { Method = SequenceMethod.LeastCode };
var sequencer = PartSequencerFactory.Create(parameters);
var ordered = sequencer.Sequence(plate.Parts.ToList(), plate);
plate.Parts.Clear();
for (var i = ordered.Count - 1; i >= 0; i--)
plate.Parts.Add(ordered[i].Part);
PlateSequencing.Apply(plate, parameters);
}
public void CalculateCurrentPlateCutTime()
+1 -1
View File
@@ -43,7 +43,7 @@ Shared coding-agent guidance lives in [AGENTS.md](AGENTS.md). [CLAUDE.md](CLAUDE
2. Import DXFs via the CAD Converter (layer/color filtering, bend detection, G-code preview) or create built-in shapes
3. Define plate size, material, quadrant, spacing
4. Fill — the engine arranges parts
5. Optionally add cut-off lines, then save `.nest`, export DXF, or post-process to G-code
5. Optionally add cut-off lines, apply Part Sequencing to order crossing cut-offs before their parts, then save `.nest`, export DXF, or post-process to G-code
Review part spacing before cutting, especially for interlocking pairs. See [pair-spacing checks and current limitations](docs/geometry/pair-spacing.md).
+8
View File
@@ -34,6 +34,14 @@ For a 120-by-81-inch sheet whose parts extend through 80 inches, the nominal ske
- Internal-hole scrap is not processed. This command reuses the outside-skeleton behavior of manual cutoffs; it does not force cuts through parts or remove clearance to guarantee separation.
- Review the preview and posted NC using the normal machine-review process before production cutting.
## Part sequencing
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.
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.
This rule runs when applying automatic sequencing, after reversing the exit-first sequencer route into cutting order. Manual sequence edits are still manual; adding or moving cutoffs does not automatically reapply this rule. Reapply Part Sequencing after layout changes and review the resulting rapids before posting. Regeneration and saving/reloading retain the applied mixed sequence.
## Rerunning, editing, and saving
The command is one-shot; spacing and minimum-tail inputs start at their defaults each time the dialog opens. Generated lines are ordinary cutoffs and can subsequently be dragged, deleted, or sequenced with the existing tools. Moving parts regenerates their trimmed segments but does not reposition the cutoff grid or recalculate the retained tail automatically.