From 4da48eed6831450155834772cb7f538b274df825 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 28 Sep 2026 18:44:04 -0400 Subject: [PATCH] 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. --- AGENTS.md | 2 +- OpenNest.Core/Plate.cs | 41 +++++- OpenNest.IO/NestFormat.cs | 6 + OpenNest.IO/NestReader.cs | 7 +- OpenNest.IO/NestWriter.cs | 11 ++ OpenNest.Tests/CutOffs/CutOffSequenceTests.cs | 122 ++++++++++++++++++ 6 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 OpenNest.Tests/CutOffs/CutOffSequenceTests.cs diff --git a/AGENTS.md b/AGENTS.md index 9d3118d..4d31a8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - `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 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. - **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, …)` 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). diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 52c0513..abc0ad1 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -94,18 +94,40 @@ namespace OpenNest /// /// Regenerates all cut-off drawings and materializes them as parts. /// 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 + /// ( order). New cut-offs are added at the end. /// 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(); + for (var i = Parts.Count - 1; i >= 0; i--) { - if (Parts[i].BaseDrawing.IsCutOff) - Parts.RemoveAt(i); + if (!Parts[i].BaseDrawing.IsCutOff) + continue; + + var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing)); + if (cutoff != null) + sequence[cutoff] = i; + + Parts.RemoveAt(i); } + RegenerateCutOffs(settings, sequence); + } + + /// + /// Regenerates all cut-off drawings and materializes them as parts, placing + /// each cut-off at its index in (its place in + /// order). Cut-offs missing from it are added at the end. + /// Callers must remove existing cut-off parts first. + /// + public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary sequence) + { var cache = BuildPerimeterCache(this); + var placed = new List<(int Index, Part Part)>(); // Regenerate and materialize each cut-off foreach (var cutoff in CutOffs) @@ -116,8 +138,17 @@ namespace OpenNest continue; 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); } /// diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index c150f6c..fd4a30e 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -87,6 +87,12 @@ namespace OpenNest.IO public string Axis { get; init; } = "vertical"; public double? StartLimit { get; init; } public double? EndLimit { get; init; } + + /// + /// Zero-based place in the plate's cut sequence (Plate.Parts order, + /// cut-offs included). Null in older files, which load it at the end. + /// + public int? Sequence { get; init; } } public record SizeDto diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index 3eb5834..b5d4202 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -372,6 +372,8 @@ namespace OpenNest.IO // Cut-offs if (p.CutOffs != null) { + var sequence = new Dictionary(); + foreach (var cutoffDto in p.CutOffs) { var axis = @@ -384,9 +386,12 @@ namespace OpenNest.IO EndLimit = cutoffDto.EndLimit, }; 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); diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index fd646e1..eac2d73 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -223,6 +223,16 @@ namespace OpenNest.IO var cutoffs = new List(); 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( new CutOffDto { @@ -231,6 +241,7 @@ namespace OpenNest.IO Axis = cutoff.Axis == CutOffAxis.Vertical ? "vertical" : "horizontal", StartLimit = cutoff.StartLimit, EndLimit = cutoff.EndLimit, + Sequence = sequence >= 0 ? sequence : null, } ); } diff --git a/OpenNest.Tests/CutOffs/CutOffSequenceTests.cs b/OpenNest.Tests/CutOffs/CutOffSequenceTests.cs new file mode 100644 index 0000000..4ec3964 --- /dev/null +++ b/OpenNest.Tests/CutOffs/CutOffSequenceTests.cs @@ -0,0 +1,122 @@ +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Tests.CutOffs; + +/// +/// A cut-off's place in Plate.Parts 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. +/// +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); + } + + /// Three parts along X with vertical cut-offs between them, the + /// cut-offs sequenced as 2 and 4: part, cut, part, cut, part. + 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); + } + + /// The cut sequence as names: "part" or the cut-off's X position. + 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] = 99 }); + + Assert.Equal(2, plate.Parts.Count); + Assert.Same(cutOff.Drawing, plate.Parts[1].BaseDrawing); + } +}