From 8f568482bcdff4462e3028f0fe69e3504c9979b1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 22 Oct 2026 20:06:15 -0400 Subject: [PATCH] fix(planning): keep a blank's outline on its applied program An applied cutting program for a display-perimeter blank held only its cutouts, so the part shrank to them: a 30 x 20 door reported an 11.5 x 20 extent, overlap and the plate view lost the blank, and running automatic cut-offs afterwards added lines straight through it. A complete emission now ends with a display tail: a marker comment, a rapid to the outline and the outline on the display perimeter layer. The part keeps the blank's bounds and material, so later cut-offs still have nothing to cut and the door plans again as before. Nothing executes the tail: executed reads stop at the marker, so search, replay and the pre-post review see only the cuts, and cut time, the rapid display and the cut simulation leave it out. DisplayPerimeterTail.ForPosting gives posts the program without it and refuses an unplanned display perimeter. --- OpenNest.Core/CNC/CutSimulation.cs | 2 +- .../CuttingPlanning/ExecutionMotionReader.cs | 3 + .../CNC/CuttingPlanning/PreparedContours.cs | 33 +++++--- .../CuttingStrategy/ContourCuttingStrategy.cs | 8 +- OpenNest.Core/CNC/DisplayPerimeterTail.cs | 84 +++++++++++++++++++ OpenNest.Core/CNC/Program.cs | 15 ++++ OpenNest.Core/CNC/RapidEnumerator.cs | 2 + OpenNest.Core/Timing.cs | 4 +- .../DisplayPerimeterPersistenceTests.cs | 57 +++++++++++++ .../CuttingPlanning/DisplayPerimeterTests.cs | 80 ++++++++++++++++++ 10 files changed, 272 insertions(+), 16 deletions(-) create mode 100644 OpenNest.Core/CNC/DisplayPerimeterTail.cs diff --git a/OpenNest.Core/CNC/CutSimulation.cs b/OpenNest.Core/CNC/CutSimulation.cs index 21cd4c2d..9db360e4 100644 --- a/OpenNest.Core/CNC/CutSimulation.cs +++ b/OpenNest.Core/CNC/CutSimulation.cs @@ -238,7 +238,7 @@ namespace OpenNest.CNC builder.PartIndex = i; var mode = Mode.Absolute; var pos = part.Location; - builder.AddProgram(part.Program, part.Location, ref mode, ref pos); + builder.AddProgram(DisplayPerimeterTail.WithoutTail(part.Program), part.Location, ref mode, ref pos); } var steps = ContourTiming.Finish(builder.Steps, plate, parameters); diff --git a/OpenNest.Core/CNC/CuttingPlanning/ExecutionMotionReader.cs b/OpenNest.Core/CNC/CuttingPlanning/ExecutionMotionReader.cs index b26352fa..01a202e6 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ExecutionMotionReader.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ExecutionMotionReader.cs @@ -48,6 +48,9 @@ public static class ExecutionMotionReader throw new ArgumentException("Program expansion exceeds the verification limit."); if (code == null) throw new ArgumentException("Program contains a missing instruction."); + // A display perimeter's tail is never executed: an applied program ends at its marker. + if (code is Comment { Value: DisplayPerimeterTail.Marker }) + break; if (requireSupportedTypes) { var type = code.GetType(); diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index a542cb71..ed2aafde 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -46,20 +46,22 @@ public sealed class PreparedContours // automatic entries keep to points where the tab steadies the part. World = local + origin. private readonly PartSupport support; private readonly Vector supportOrigin; - // False when the outside is a display perimeter: shown and material, never cut. It is not one - // of the contours, so every contour is a hole and no emission can reach the outline. - private readonly bool hasCutPerimeter; + // The outside when it is a display perimeter: shown and material, never cut. It is not one of + // the contours, so every contour is a hole; a complete emission only appends it as the + // never-executed DisplayPerimeterTail. Null for an ordinary cut perimeter. + private readonly Shape displayOutline; + private bool hasCutPerimeter => displayOutline == null; private PreparedContours(Shape[] shapes, List scribes, List breakCuts, List tabbedCutouts, CuttingParameters parameters) - : this(shapes, scribes, breakCuts, tabbedCutouts, parameters, hasCutPerimeter: true) + : this(shapes, scribes, breakCuts, tabbedCutouts, parameters, displayOutline: null) { } private PreparedContours(Shape[] shapes, List scribes, List breakCuts, List tabbedCutouts, - CuttingParameters parameters, bool hasCutPerimeter) + CuttingParameters parameters, Shape displayOutline) { - this.hasCutPerimeter = hasCutPerimeter; + this.displayOutline = displayOutline; this.shapes = shapes; this.scribes = scribes; this.breakCuts = breakCuts; @@ -71,7 +73,7 @@ public sealed class PreparedContours private PreparedContours(PreparedContours unpinned, IReadOnlyList pins, CancellationToken token) : this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters, - unpinned.hasCutPerimeter) + unpinned.displayOutline) { contourParameters = new CuttingParameters[shapes.Length]; var rejected = new List(); @@ -107,7 +109,7 @@ public sealed class PreparedContours // a pinned perimeter keeps its pin's settings: the analysis is only kept for review. private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab) : this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters, - source.hasCutPerimeter) + source.displayOutline) { for (var contour = 0; contour < Count; contour++) { @@ -306,7 +308,7 @@ public sealed class PreparedContours foreach (var cut in breakCuts) cut.UpdateBounds(); // Never a contour: nothing can choose, emit or lead it. Every contour is a hole. - return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, hasCutPerimeter: false); + return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, displayPerimeter); } if (current.Entities.Count != 0 || contours.Count == 0) throw new ArgumentException("Preparation requires nonempty closed contours."); @@ -761,7 +763,7 @@ public sealed class PreparedContours public Program Emit(IReadOnlyList choices) { Validate(choices, true); - return EmitPrefix(choices); + return EmitOwned(choices, complete: true); } /// @@ -788,11 +790,16 @@ public sealed class PreparedContours internal Program EmitPrefix(IReadOnlyList choices) { Validate(choices, false); - return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared( + return EmitOwned(choices, complete: false); + } + + // Only a complete emission carries a display perimeter's tail: prefixes are search probes. + private Program EmitOwned(IReadOnlyList choices, bool complete) => + new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared( shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices, contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(), - tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal); - } + tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal, + complete ? (Shape)displayOutline?.Clone() : null); /// Open break cuts this preparation cuts before its contours. public int BreakCutCount => breakCuts.Count; diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index 0cf123a7..ca952595 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -251,7 +251,7 @@ namespace OpenNest.CNC.CuttingStrategy internal Program EmitPrepared(Shape[] shapes, List scribes, IReadOnlyList choices, IReadOnlyList contourParameters = null, IReadOnlyList breakCuts = null, - IReadOnlyList tabbedCutouts = null, int? perimeterOrdinal = null) + IReadOnlyList tabbedCutouts = null, int? perimeterOrdinal = null, Shape displayTail = null) { var result = new Program(Mode.Absolute); EmitScribeContours(result, scribes); @@ -259,6 +259,12 @@ namespace OpenNest.CNC.CuttingStrategy foreach (var cutout in tabbedCutouts ?? Array.Empty()) EmitTabbedCutout(result, cutout); EmitChosenContours(result, shapes, choices, contourParameters, perimeterOrdinal ?? shapes.Length - 1); + if (displayTail != null) + { + // Display only, after every cut: executed reads and posts stop at its marker. + var start = GetShapeStartPoint(displayTail); + DisplayPerimeterTail.Append(result, ConvertShapeToMoves(displayTail, start, LayerType.DisplayPerimeter), start); + } result.Mode = Mode.Incremental; return result; } diff --git a/OpenNest.Core/CNC/DisplayPerimeterTail.cs b/OpenNest.Core/CNC/DisplayPerimeterTail.cs new file mode 100644 index 00000000..68c2cb0e --- /dev/null +++ b/OpenNest.Core/CNC/DisplayPerimeterTail.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.CNC +{ + /// + /// The never-cut outline of a supplied blank, kept at the end of an applied cutting program. + /// An applied program for a blank with a outline cuts + /// only its cutouts; the outline follows them after so the part keeps the + /// blank's extent for bounds, overlap and cut-offs. Nothing that executes or posts a program may + /// run the tail: executed reads stop at the marker and posts take . + /// + public static class DisplayPerimeterTail + { + /// The comment that starts the tail; every code after it is display only. + public const string Marker = "DISPLAY PERIMETER NEVER CUT"; + + /// The index of the tail's marker in 's codes, or -1. + public static int IndexOf(Program program) + { + ArgumentNullException.ThrowIfNull(program); + return program.Codes.FindIndex(code => code is Comment { Value: Marker }); + } + + public static bool HasTail(Program program) => IndexOf(program) >= 0; + + /// + /// Appends the outline after the marker: a rapid to its start, then its entities on the + /// display perimeter layer. must still be in absolute mode. + /// + internal static void Append(Program program, IReadOnlyList outline, Vector start) + { + if (program.Mode != Mode.Absolute) + throw new InvalidOperationException("Append the display tail before switching to incremental mode."); + program.Codes.Add(new Comment(Marker)); + program.Codes.Add(new RapidMove(start)); + program.Codes.AddRange(outline); + } + + /// + /// The executed program: itself when it has no tail, otherwise a + /// shallow copy of the codes before the marker (shared instructions and sub-programs). + /// + public static Program WithoutTail(Program program) + { + var marker = IndexOf(program); + return marker < 0 ? program : program.HeadCopy(marker); + } + + /// Whether or any sub-program it calls moves on the display perimeter layer. + public static bool HasDisplayPerimeterMotion(Program program) => + HasDisplayPerimeterMotion(program, new HashSet(ReferenceEqualityComparer.Instance)); + + private static bool HasDisplayPerimeterMotion(Program program, HashSet visited) + { + if (program?.Codes == null || !visited.Add(program)) + return false; + foreach (var code in program.Codes) + { + if (code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter }) + return true; + if (code is SubProgramCall call && HasDisplayPerimeterMotion(call.Program, visited)) + return true; + } + return false; + } + + /// + /// The program a post may write for a part: its tail removed. A display perimeter anywhere + /// else means the part was never planned, and posting it would cut the blank's outline. + /// + public static Program ForPosting(Program program, string partName) + { + var executed = WithoutTail(program); + if (HasDisplayPerimeterMotion(executed)) + throw new InvalidOperationException( + $"{partName} has a display perimeter (a supplied blank's outline that is never cut) but no " + + "cutting plan. Run Plan Cutting before posting so only its cutouts are cut."); + return executed; + } + } +} diff --git a/OpenNest.Core/CNC/Program.cs b/OpenNest.Core/CNC/Program.cs index 4fd490b5..7b02eac4 100644 --- a/OpenNest.Core/CNC/Program.cs +++ b/OpenNest.Core/CNC/Program.cs @@ -508,6 +508,21 @@ namespace OpenNest.CNC return true; } + /// + /// A shallow copy holding only the first codes: the same + /// instruction, variable and sub-program instances, mode and rotation. + /// + internal Program HeadCopy(int count) + { + var head = new Program() { mode = this.mode, Rotation = this.Rotation }; + head.Codes.AddRange(Codes.GetRange(0, count)); + foreach (var kvp in Variables) + head.Variables[kvp.Key] = kvp.Value; + foreach (var kvp in SubPrograms) + head.SubPrograms[kvp.Key] = kvp.Value; + return head; + } + public object Clone() { var pgm = new Program() { mode = this.mode, Rotation = this.Rotation }; diff --git a/OpenNest.Core/CNC/RapidEnumerator.cs b/OpenNest.Core/CNC/RapidEnumerator.cs index 51154f65..e9ed9db6 100644 --- a/OpenNest.Core/CNC/RapidEnumerator.cs +++ b/OpenNest.Core/CNC/RapidEnumerator.cs @@ -31,6 +31,8 @@ namespace OpenNest.CNC private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List results) { + // The head never travels to a display perimeter's tail. + pgm = DisplayPerimeterTail.WithoutTail(pgm); // Draw the approach to the first rapid destination, or to the program // origin when it starts cutting without a rapid. Only an initial rapid // (or call arrival) is already represented by this approach; a later diff --git a/OpenNest.Core/Timing.cs b/OpenNest.Core/Timing.cs index 4aa5481b..b438df6d 100644 --- a/OpenNest.Core/Timing.cs +++ b/OpenNest.Core/Timing.cs @@ -10,6 +10,8 @@ namespace OpenNest { public static TimingInfo GetTimingInfo(Program pgm) { + // A display perimeter's tail is never cut, so it takes no time. + pgm = DisplayPerimeterTail.WithoutTail(pgm); var entities = ConvertProgram.ToGeometry(pgm); var shapes = ShapeBuilder.GetShapes( entities.Where(entity => entity.Layer != SpecialLayers.Rapid) @@ -36,7 +38,7 @@ namespace OpenNest foreach (var part in plate.Parts) { - var endpt = part.Program.EndPoint() + part.Location; + var endpt = DisplayPerimeterTail.WithoutTail(part.Program).EndPoint() + part.Location; info += GetTimingInfo(part.Program); info.TravelDistance += pos.DistanceTo(endpt); pos = endpt; diff --git a/OpenNest.IO.Tests/DisplayPerimeterPersistenceTests.cs b/OpenNest.IO.Tests/DisplayPerimeterPersistenceTests.cs index 8c3f6680..02948b77 100644 --- a/OpenNest.IO.Tests/DisplayPerimeterPersistenceTests.cs +++ b/OpenNest.IO.Tests/DisplayPerimeterPersistenceTests.cs @@ -27,6 +27,63 @@ public class DisplayPerimeterPersistenceTests private static int DisplayPerimeterMoves(Program program) => program.Codes.Count(code => code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter }); + [Fact] + public void RoundDisplayPerimeter_SurvivesSaveAndReloadAsAnArc() + { + var outline = new Circle(new Vector(10, 10), 10) { Layer = SpecialLayers.DisplayPerimeter }; + var entities = new List + { + outline, new Line(8, 8, 12, 8), new Line(12, 8, 12, 12), new Line(12, 12, 8, 12), new Line(8, 12, 8, 8), + }; + var drawing = CadImporter.BuildDrawing(new CadImportResult { Entities = entities, Name = "round" }, + entities, new List(), 1, null, null, null, new List()); + Assert.Contains(drawing.Program.Codes, c => c is ArcMove { Layer: LayerType.DisplayPerimeter }); + var nest = new Nest("round") { Units = Units.Inches }; + nest.Drawings.Add(drawing); + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + + var reread = Assert.Single(new NestReader(new MemoryStream(stream.ToArray())).Read().Drawings); + + Assert.Contains(reread.Program.Codes, c => c is ArcMove { Layer: LayerType.DisplayPerimeter }); + Assert.DoesNotContain(reread.Program.Codes, c => c is ArcMove { Layer: LayerType.Cut }); + } + + [Fact] + public void AppliedProgramTail_SurvivesSaveAndReload() + { + var drawing = Door(); + var nest = new Nest("door") { Units = Units.Inches }; + nest.Drawings.Add(drawing); + var plate = new Plate(new Size(20, 30)); + var part = new Part(drawing, Vector.Zero); + plate.Parts.Add(part); + nest.Plates.Add(plate); + // An applied program as the planner installs it: the cut hole, then the never-cut tail. + var applied = new Program(); + applied.Codes.Add(new RapidMove(5, 5)); + foreach (var (x, y) in new[] { (5.0, 10.0), (10.0, 10.0), (10.0, 5.0), (5.0, 5.0) }) + applied.Codes.Add(new LinearMove(x, y)); + applied.Codes.Add(new Comment(DisplayPerimeterTail.Marker)); + applied.Codes.Add(new RapidMove(0, 0)); + foreach (var (x, arcY) in new[] { (30.0, 0.0), (30.0, 20.0), (0.0, 20.0), (0.0, 0.0) }) + applied.Codes.Add(new LinearMove(x, arcY) { Layer = LayerType.DisplayPerimeter }); + applied.Mode = Mode.Incremental; + Assert.True(part.RestoreLeadInProgram(applied, locked: true)); + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + + var reader = new NestReader(new MemoryStream(stream.ToArray())); + var restored = Assert.Single(reader.Read().Plates.SelectMany(p => p.Parts)); + + Assert.Empty(reader.Warnings); + Assert.True(DisplayPerimeterTail.HasTail(restored.Program)); + Assert.Equal(4, DisplayPerimeterMoves(restored.Program)); + Assert.Equal(30, restored.BoundingBox.Length, 6); + Assert.False(DisplayPerimeterTail.HasDisplayPerimeterMotion( + DisplayPerimeterTail.ForPosting(restored.Program, "Part 1"))); + } + [Fact] public void DisplayPerimeter_SurvivesSaveAndReloadOnTheDrawingAndItsPlacements() { diff --git a/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs b/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs index 38946f0e..9db3cad2 100644 --- a/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs +++ b/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs @@ -75,6 +75,86 @@ public class DisplayPerimeterTests Assert.All(motions.Where(m => !m.Rapid), m => Assert.True(InsideACutout(m.End), $"Cut to ({m.End.X}, {m.End.Y}) leaves the cutouts.")); } + private static (Plate Plate, Part Part) PlannedDoor() + { + var plate = DoorPlate(Door()); + var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan(); + Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in"))); + Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: false).Status); + return (plate, plate.Parts[0]); + } + + [Fact] + public void AfterApply_TheDoorKeepsItsOutlineAsANeverCutTail() + { + var (_, part) = PlannedDoor(); + + Assert.Equal(30, part.BoundingBox.Length, 6); + Assert.Equal(20, part.BoundingBox.Width, 6); + Assert.True(DisplayPerimeterTail.HasTail(part.Program)); + var executed = DisplayPerimeterTail.ForPosting(part.Program, "Part 1"); + Assert.False(DisplayPerimeterTail.HasDisplayPerimeterMotion(executed)); + // Posting and executed reads agree: the tail is the only thing either leaves out. + var full = ExecutionMotionReader.Read(part.Program, part.Location, null, default).Motions; + var posted = ExecutionMotionReader.Read(executed, part.Location, null, default).Motions; + Assert.Equal(full.Count, posted.Count); + Assert.True(executed.Codes.Count < part.Program.Codes.Count); + } + + [Fact] + public void AfterApply_AutomaticCutOffsStillAddNoLineAcrossTheBlank() + { + var (plate, _) = PlannedDoor(); + + var staging = AutomaticCutOffBatch.Stage([plate], + new AutomaticCutOffOptions { Spacing = 10, MinimumStripLength = 5 }, new CutOffSettings()); + + Assert.Empty(Assert.Single(staging.Plans).Definitions); + } + + [Fact] + public void AfterApply_TheDoorPlansAgainWithTheSameHoles() + { + var (plate, _) = PlannedDoor(); + + var again = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan(); + + Assert.True(again.CanApply, string.Join("\n", again.Describe("in"))); + } + + [Fact] + public void AfterApply_TimingRapidsAndSimulationIgnoreTheTail() + { + var (plate, part) = PlannedDoor(); + + var executed = DisplayPerimeterTail.WithoutTail(part.Program); + Assert.Equal(Timing.GetTimingInfo(executed).PierceCount, Timing.GetTimingInfo(part.Program).PierceCount); + Assert.Equal(Timing.GetTimingInfo(executed).CutDistance, Timing.GetTimingInfo(part.Program).CutDistance, 9); + // The tail starts at the outline's first corner (0, 0); no rapid may travel there. + var rapids = RapidEnumerator.Enumerate(plate.Parts); + Assert.DoesNotContain(rapids, r => r.To.DistanceTo(Vector.Zero) < 1e-9); + var simulation = CutSimulation.Build(plate, new CutParameters + { + Feedrate = 100, + RapidTravelRate = 600, + PierceTime = TimeSpan.FromSeconds(1), + Units = Units.Inches, + }); + Assert.Equal(3, simulation.Steps.Count(step => step.Kind == CutSimulationStepKind.Pierce)); + Assert.DoesNotContain(simulation.Steps, step => step.Layer == LayerType.DisplayPerimeter); + } + + [Fact] + public void Posting_RefusesAnUnplannedDisplayPerimeter() + { + var error = Assert.Throws(() => DisplayPerimeterTail.ForPosting(Door(), "Part 4")); + + Assert.Contains("Part 4", error.Message); + Assert.Contains("Plan Cutting", error.Message); + var holesOnly = Door(LayerType.Cut); + Assert.Same(holesOnly, DisplayPerimeterTail.ForPosting(holesOnly, "Part 1")); + } + [Fact] public void Door_AutomaticCutOffsAddNoLineAcrossTheBlank() {