fix(core): keep cut-offs in their cut sequence through regenerate and save

A cut-off's place in Plate.Parts is its cut sequence number, but
RegenerateCutOffs removed every cut-off part and appended it again, so
any part drag, fill or cut-off move sent the cut-offs to the end. The
nest file didn't store the position either, so reopening did the same.

RegenerateCutOffs now puts each cut-off back at its previous index (new
cut-offs go at the end), and CutOffDto.Sequence saves the index. Older
files without it load the cut-offs at the end, as before.
This commit is contained in:
aj
2026-09-28 18:44:04 -04:00
parent 6803d7519f
commit 4da48eed68
6 changed files with 182 additions and 7 deletions
+1 -1
View File
@@ -154,7 +154,7 @@ Keep vendor programming manuals and full-text extracts outside source control un
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes. - `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
- `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. - `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. - **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 them to `Plate.Parts`. 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). - **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.
- **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. - **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. - **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.
- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` and the saved `SCRIBE` layer (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth). - **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` and the saved `SCRIBE` layer (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth).
+36 -5
View File
@@ -94,18 +94,40 @@ namespace OpenNest
/// <summary> /// <summary>
/// Regenerates all cut-off drawings and materializes them as parts. /// Regenerates all cut-off drawings and materializes them as parts.
/// Existing cut-off parts are removed first, then each cut-off is /// Existing cut-off parts are removed first, then each cut-off is
/// regenerated and added back if it produces any geometry. /// regenerated and put back at the same place in the cut sequence
/// (<see cref="Parts"/> order). New cut-offs are added at the end.
/// </summary> /// </summary>
public void RegenerateCutOffs(CutOffSettings settings) public void RegenerateCutOffs(CutOffSettings settings)
{ {
// Remove existing cut-off parts // Remember each cut-off's place in the cut sequence, so a part drag
// or cut-off move doesn't send it to the end of the sequence.
var sequence = new Dictionary<CutOff, int>();
for (var i = Parts.Count - 1; i >= 0; i--) for (var i = Parts.Count - 1; i >= 0; i--)
{ {
if (Parts[i].BaseDrawing.IsCutOff) if (!Parts[i].BaseDrawing.IsCutOff)
Parts.RemoveAt(i); continue;
var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing));
if (cutoff != null)
sequence[cutoff] = i;
Parts.RemoveAt(i);
} }
RegenerateCutOffs(settings, sequence);
}
/// <summary>
/// Regenerates all cut-off drawings and materializes them as parts, placing
/// each cut-off at its index in <paramref name="sequence"/> (its place in
/// <see cref="Parts"/> order). Cut-offs missing from it are added at the end.
/// Callers must remove existing cut-off parts first.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence)
{
var cache = BuildPerimeterCache(this); var cache = BuildPerimeterCache(this);
var placed = new List<(int Index, Part Part)>();
// Regenerate and materialize each cut-off // Regenerate and materialize each cut-off
foreach (var cutoff in CutOffs) foreach (var cutoff in CutOffs)
@@ -116,8 +138,17 @@ namespace OpenNest
continue; continue;
var part = new Part(cutoff.Drawing); var part = new Part(cutoff.Drawing);
Parts.Add(part);
if (sequence != null && sequence.TryGetValue(cutoff, out var index))
placed.Add((index, part));
else
Parts.Add(part);
} }
// Lowest index first: each insert then lands on its saved index, because
// every part sequenced before it is already in place.
foreach (var (index, part) in placed.OrderBy(p => p.Index))
Parts.Insert(System.Math.Clamp(index, 0, Parts.Count), part);
} }
/// <summary> /// <summary>
+6
View File
@@ -87,6 +87,12 @@ namespace OpenNest.IO
public string Axis { get; init; } = "vertical"; public string Axis { get; init; } = "vertical";
public double? StartLimit { get; init; } public double? StartLimit { get; init; }
public double? EndLimit { get; init; } public double? EndLimit { get; init; }
/// <summary>
/// Zero-based place in the plate's cut sequence (<c>Plate.Parts</c> order,
/// cut-offs included). Null in older files, which load it at the end.
/// </summary>
public int? Sequence { get; init; }
} }
public record SizeDto public record SizeDto
+6 -1
View File
@@ -372,6 +372,8 @@ namespace OpenNest.IO
// Cut-offs // Cut-offs
if (p.CutOffs != null) if (p.CutOffs != null)
{ {
var sequence = new Dictionary<CutOff, int>();
foreach (var cutoffDto in p.CutOffs) foreach (var cutoffDto in p.CutOffs)
{ {
var axis = var axis =
@@ -384,9 +386,12 @@ namespace OpenNest.IO
EndLimit = cutoffDto.EndLimit, EndLimit = cutoffDto.EndLimit,
}; };
plate.CutOffs.Add(cutoff); plate.CutOffs.Add(cutoff);
if (cutoffDto.Sequence is int index)
sequence[cutoff] = index;
} }
plate.RegenerateCutOffs(new CutOffSettings()); plate.RegenerateCutOffs(new CutOffSettings(), sequence);
} }
nest.Plates.Add(plate); nest.Plates.Add(plate);
+11
View File
@@ -223,6 +223,16 @@ namespace OpenNest.IO
var cutoffs = new List<CutOffDto>(); var cutoffs = new List<CutOffDto>();
foreach (var cutoff in plate.CutOffs) foreach (var cutoff in plate.CutOffs)
{ {
var sequence = -1;
for (var j = 0; j < plate.Parts.Count; j++)
{
if (ReferenceEquals(plate.Parts[j].BaseDrawing, cutoff.Drawing))
{
sequence = j;
break;
}
}
cutoffs.Add( cutoffs.Add(
new CutOffDto new CutOffDto
{ {
@@ -231,6 +241,7 @@ namespace OpenNest.IO
Axis = cutoff.Axis == CutOffAxis.Vertical ? "vertical" : "horizontal", Axis = cutoff.Axis == CutOffAxis.Vertical ? "vertical" : "horizontal",
StartLimit = cutoff.StartLimit, StartLimit = cutoff.StartLimit,
EndLimit = cutoff.EndLimit, EndLimit = cutoff.EndLimit,
Sequence = sequence >= 0 ? sequence : null,
} }
); );
} }
@@ -0,0 +1,122 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.CutOffs;
/// <summary>
/// A cut-off's place in <c>Plate.Parts</c> is its cut sequence number, which the
/// user sets and the posts follow. Regenerating cut-offs (after a part drag, fill
/// or cut-off move) and saving must keep it.
/// </summary>
public class CutOffSequenceTests
{
private static Drawing MakeSquare()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Drawing("square", pgm);
}
/// <summary>Three parts along X with vertical cut-offs between them, the
/// cut-offs sequenced as 2 and 4: part, cut, part, cut, part.</summary>
private static Plate MakeSequencedPlate(Drawing drawing)
{
var plate = new Plate(60, 120);
plate.Parts.Add(new Part(drawing, new Vector(1, 2)));
plate.Parts.Add(new Part(drawing, new Vector(30, 2)));
plate.Parts.Add(new Part(drawing, new Vector(60, 2)));
plate.CutOffs.Add(new CutOff(new Vector(20, 0), CutOffAxis.Vertical));
plate.CutOffs.Add(new CutOff(new Vector(50, 0), CutOffAxis.Vertical));
plate.RegenerateCutOffs(new CutOffSettings());
// As ActionSetSequence does: remove, then insert at the chosen number.
SetSequence(plate, plate.CutOffs[0], 1);
SetSequence(plate, plate.CutOffs[1], 3);
return plate;
}
private static void SetSequence(Plate plate, CutOff cutOff, int index)
{
var part = plate.Parts.First(p => ReferenceEquals(p.BaseDrawing, cutOff.Drawing));
plate.Parts.Remove(part);
plate.Parts.Insert(index, part);
}
/// <summary>The cut sequence as names: "part" or the cut-off's X position.</summary>
private static string[] Sequence(Plate plate) =>
plate
.Parts.Select(p =>
p.BaseDrawing.IsCutOff ? $"cut@{p.BoundingBox.X:F0}" : $"part@{p.Location.X:F0}"
)
.ToArray();
private static readonly string[] Expected =
{
"part@1",
"cut@20",
"part@30",
"cut@50",
"part@60",
};
[Fact]
public void RegenerateCutOffs_KeepsEachCutOffInItsSequencePlace()
{
var plate = MakeSequencedPlate(MakeSquare());
Assert.Equal(Expected, Sequence(plate));
plate.RegenerateCutOffs(new CutOffSettings());
Assert.Equal(Expected, Sequence(plate));
}
[Fact]
public void RegenerateCutOffs_AddsNewCutOffAtTheEnd()
{
var plate = MakeSequencedPlate(MakeSquare());
plate.CutOffs.Add(new CutOff(new Vector(0, 40), CutOffAxis.Horizontal));
plate.RegenerateCutOffs(new CutOffSettings());
Assert.Equal(Expected, Sequence(plate).Take(5));
Assert.Equal(6, plate.Parts.Count);
Assert.Same(plate.CutOffs[2].Drawing, plate.Parts[5].BaseDrawing);
}
[Fact]
public void SaveAndReopen_KeepsCutOffSequence()
{
var drawing = MakeSquare();
var nest = new Nest("seq") { DateCreated = DateTime.Now, DateLastModified = DateTime.Now };
nest.Drawings.Add(drawing);
nest.Plates.Add(MakeSequencedPlate(drawing));
using var stream = new MemoryStream();
new NestWriter(nest).Write(stream);
stream.Position = 0;
var loaded = new NestReader(stream).Read();
Assert.Equal(Expected, Sequence(loaded.Plates[0]));
}
[Fact]
public void RegenerateCutOffs_OutOfRangeSequence_AppendsInsteadOfThrowing()
{
// A damaged file can name a sequence past the end of the plate.
var plate = new Plate(60, 120);
plate.Parts.Add(new Part(MakeSquare(), new Vector(1, 2)));
var cutOff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(cutOff);
plate.RegenerateCutOffs(new CutOffSettings(), new Dictionary<CutOff, int> { [cutOff] = 99 });
Assert.Equal(2, plate.Parts.Count);
Assert.Same(cutOff.Drawing, plate.Parts[1].BaseDrawing);
}
}