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() {