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.
This commit is contained in:
aj committed 2026-10-22 20:06:15 -04:00
1 parent 73e54fbd67
commit 8f568482bc
10 files changed
+272 -16

No files matched your search

+1 -1
View File
@@ -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);
@@ -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();
@@ -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<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
CuttingParameters parameters)
: this(shapes, scribes, breakCuts, tabbedCutouts, parameters, hasCutPerimeter: true)
: this(shapes, scribes, breakCuts, tabbedCutouts, parameters, displayOutline: null)
{
}
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> 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<CutPinResolution> 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<CutPinResolution>();
@@ -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<ContourChoice> choices)
{
Validate(choices, true);
return EmitPrefix(choices);
return EmitOwned(choices, complete: true);
}
/// <summary>
@@ -788,11 +790,16 @@ public sealed class PreparedContours
internal Program EmitPrefix(IReadOnlyList<ContourChoice> 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<ContourChoice> 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);
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
public int BreakCutCount => breakCuts.Count;
@@ -251,7 +251,7 @@ namespace OpenNest.CNC.CuttingStrategy
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
IReadOnlyList<Shape> tabbedCutouts = null, int? perimeterOrdinal = null)
IReadOnlyList<Shape> 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<Shape>())
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;
}
+84
View File
@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
/// <summary>
/// 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 <see cref="LayerType.DisplayPerimeter"/> outline cuts
/// only its cutouts; the outline follows them after <see cref="Marker"/> 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 <see cref="ForPosting"/>.
/// </summary>
public static class DisplayPerimeterTail
{
/// <summary>The comment that starts the tail; every code after it is display only.</summary>
public const string Marker = "DISPLAY PERIMETER NEVER CUT";
/// <summary>The index of the tail's marker in <paramref name="program"/>'s codes, or -1.</summary>
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;
/// <summary>
/// Appends the outline after the marker: a rapid to its start, then its entities on the
/// display perimeter layer. <paramref name="program"/> must still be in absolute mode.
/// </summary>
internal static void Append(Program program, IReadOnlyList<ICode> 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);
}
/// <summary>
/// The executed program: <paramref name="program"/> itself when it has no tail, otherwise a
/// shallow copy of the codes before the marker (shared instructions and sub-programs).
/// </summary>
public static Program WithoutTail(Program program)
{
var marker = IndexOf(program);
return marker < 0 ? program : program.HeadCopy(marker);
}
/// <summary>Whether <paramref name="program"/> or any sub-program it calls moves on the display perimeter layer.</summary>
public static bool HasDisplayPerimeterMotion(Program program) =>
HasDisplayPerimeterMotion(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
private static bool HasDisplayPerimeterMotion(Program program, HashSet<Program> 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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
+15
View File
@@ -508,6 +508,21 @@ namespace OpenNest.CNC
return true;
}
/// <summary>
/// A shallow copy holding only the first <paramref name="count"/> codes: the same
/// instruction, variable and sub-program instances, mode and rotation.
/// </summary>
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 };
+2
View File
@@ -31,6 +31,8 @@ namespace OpenNest.CNC
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> 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
+3 -1
View File
@@ -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;
@@ -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<Entity>
{
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<Bend>(), 1, null, null, null, new List<TabbedCutout>());
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()
{
@@ -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<InvalidOperationException>(() => 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()
{