diff --git a/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs b/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs
index 8475cf90..e003f8ae 100644
--- a/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs
+++ b/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs
@@ -14,7 +14,6 @@ public class CincinnatiFeatureWriterTests
UseAntiDive = true,
KerfCompensation = KerfMode.ControllerSide,
DefaultKerfSide = KerfSide.Left,
- ProcessParameterMode = G89Mode.LibraryFile,
InteriorM47 = M47Mode.Always,
ExteriorM47 = M47Mode.Always,
UseSpeedGas = false,
@@ -303,7 +302,6 @@ public class CincinnatiFeatureWriterTests
public void G89_EmittedWithLibraryFile()
{
var config = DefaultConfig();
- config.ProcessParameterMode = G89Mode.LibraryFile;
var ctx = SimpleContext();
ctx.LibraryFile = "MILD10";
ctx.CutDistance = 18.0;
@@ -317,7 +315,6 @@ public class CincinnatiFeatureWriterTests
public void G89_WarningEmittedWhenNoLibrary()
{
var config = DefaultConfig();
- config.ProcessParameterMode = G89Mode.LibraryFile;
var ctx = SimpleContext();
ctx.LibraryFile = "";
var output = WriteFeature(config, ctx);
diff --git a/OpenNest.Tests/Cincinnati/CincinnatiPostRegressionTests.cs b/OpenNest.Tests/Cincinnati/CincinnatiPostRegressionTests.cs
new file mode 100644
index 00000000..bfa9964f
--- /dev/null
+++ b/OpenNest.Tests/Cincinnati/CincinnatiPostRegressionTests.cs
@@ -0,0 +1,335 @@
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Geometry;
+using OpenNest.Posts.Cincinnati;
+using static OpenNest.Tests.Cincinnati.CincinnatiReplay;
+
+namespace OpenNest.Tests.Cincinnati;
+
+///
+/// Posts off-origin parts of several drawings and replays the G-code to check what the
+/// machine actually does: where it cuts, what it leaves uncut and where it raises the head.
+///
+public class CincinnatiPostRegressionTests
+{
+ [Fact]
+ public void PartSubprograms_CutEveryPartWhereInlinePostingDoes()
+ {
+ // Outer square first, so each part's toolpath ends away from its corner.
+ var nest = SquaresNest(
+ OuterFirst,
+ ("A", new Vector(5, 5)),
+ ("B", new Vector(30, 20)),
+ ("A", new Vector(50, 60))
+ );
+
+ var inline = Run(Post(nest, partSubprograms: false));
+ var grouped = Run(Post(nest, partSubprograms: true));
+
+ var expected = nest.Plates[0].Parts
+ .SelectMany(p => new[] { p.Location, p.Location + new Vector(2, 2) })
+ .ToList();
+ AssertPoints(expected, inline.PierceLog.Select(p => p.Point).ToList());
+ AssertSamePath(inline, grouped);
+ }
+
+ [Fact]
+ public void PartSubprograms_PartsWithOwnLeadIns_PostTheirOwnToolpath()
+ {
+ var nest = new Nest("N");
+ var plate = new Plate(48, 96);
+ nest.Plates.Add(plate);
+ var drawing = new Drawing("A") { Program = HoledSquare() };
+ AddLeadInPart(plate, drawing, 0.25, new Vector(5, 5));
+ AddLeadInPart(plate, drawing, 0.75, new Vector(20, 5));
+ AddLeadInPart(plate, new Drawing("B") { Program = HoledSquare() }, 0.25, new Vector(5, 30));
+ AddLeadInPart(plate, drawing, 0.25, new Vector(20, 30));
+
+ var output = Post(nest, partSubprograms: true);
+
+ // Lead-ins differ between the two (drawing, rotation) twins; the identical one shares.
+ Assert.Equal(2, Count(output, "M99 (END OF A)"));
+ Assert.Equal(1, Count(output, "M99 (END OF B)"));
+ AssertSamePath(Run(Post(nest, partSubprograms: false)), Run(output));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void SuppressedMoves_AreLeftUncut(bool partSubprograms)
+ {
+ var suppressed = SquaresProgram();
+ ((Motion)suppressed.Codes[7]).Suppressed = true; // outer (10,0) -> (10,10)
+ var nest = new Nest("N");
+ var plate = new Plate(48, 96);
+ nest.Plates.Add(plate);
+ var a = AddPart(plate, "A", suppressed, new Vector(5, 5));
+ AddPart(plate, "B", SquaresProgram(), new Vector(30, 20));
+
+ var run = Run(Post(nest, partSubprograms));
+
+ Assert.DoesNotContain(run.Cuts, c => Near(c.To, a.Location + new Vector(10, 10)) && Near(c.From, a.Location + new Vector(10, 0)));
+ Assert.DoesNotContain(run.Cuts, c => Near(c.From, a.Location + new Vector(10, 0)) && !Near(c.To, a.Location + new Vector(10, 0)));
+ Assert.Contains(run.PierceLog, p => Near(p.Point, a.Location + new Vector(10, 10)));
+ Assert.Equal(15, run.Cuts.Count);
+ Assert.Equal(0, run.EmptyPierces);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void SafetyHeadraise_IsTheOneRaiseBeforeTheRapidToADifferentPart(bool partSubprograms)
+ {
+ var nest = SquaresNest(("A", new Vector(5, 5)), ("A", new Vector(20, 5)), ("B", new Vector(40, 30)));
+ var b = nest.Plates[0].Parts[2];
+
+ var run = Run(Post(nest, partSubprograms));
+
+ Assert.Equal(0, run.DoubleRaises);
+ Assert.Equal(0, run.HeadDownRapids);
+ var safety = Assert.Single(run.PierceLog, p => p.Raise == "M47 P2000");
+ Assert.True(Near(safety.Point, b.Location + new Vector(2, 2)), $"Safety raise before {safety.Point}");
+ Assert.All(run.PierceLog.Where(p => p.Raise != "M47 P2000"), p => Assert.Equal("M47", p.Raise));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void SheetGoto_TargetsThePostedFirstFeatureNumber(bool partSubprograms)
+ {
+ var nest = SquaresNest(("A", new Vector(5, 5)), ("B", new Vector(30, 20)));
+ var config = Config(partSubprograms);
+ config.FeatureLineNumberStart = 5;
+
+ var numbered = Sheet(Post(nest, config));
+ config.UseLineNumbers = false;
+ var unnumbered = Sheet(Post(nest, config));
+
+ Assert.Contains("GOTO5( Goto Feature )", numbered);
+ Assert.Contains(numbered, l => l.StartsWith("N5 ", System.StringComparison.Ordinal));
+ Assert.DoesNotContain(unnumbered, l => l.StartsWith("GOTO", System.StringComparison.Ordinal));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void AutoM47_RaisesFullyOnlyBeforeLongRapids(bool partSubprograms)
+ {
+ var nest = SquaresNest(("A", new Vector(5, 5)), ("B", new Vector(40, 30)));
+ var config = Config(partSubprograms);
+ config.InteriorM47 = M47Mode.Auto;
+ config.ExteriorM47 = M47Mode.Auto;
+ config.SafetyHeadraiseDistance = null;
+ config.M47OverrideDistanceThreshold = 5;
+
+ var run = Run(Post(nest, config));
+ config.M47OverrideDistanceThreshold = null;
+ var unlimited = Run(Post(nest, config));
+
+ // Pierces: header raise, A outer (short hop), B inner (long rapid), B outer (short hop).
+ Assert.Equal(new[] { "M47", "/M47", "M47", "/M47" }, run.PierceLog.Select(p => p.Raise));
+ Assert.All(unlimited.PierceLog, p => Assert.Equal("M47", p.Raise));
+ Assert.Equal(0, run.HeadDownRapids);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void ExteriorM47_AppliesAfterThePartExterior(bool partSubprograms)
+ {
+ var nest = SquaresNest(("A", new Vector(5, 5)), ("B", new Vector(40, 30)));
+ var config = Config(partSubprograms);
+ config.InteriorM47 = M47Mode.BlockDelete;
+ config.ExteriorM47 = M47Mode.Always;
+ config.SafetyHeadraiseDistance = null;
+
+ var run = Run(Post(nest, config));
+
+ Assert.Equal(new[] { "M47", "/M47", "M47", "/M47" }, run.PierceLog.Select(p => p.Raise));
+ }
+
+ [Theory]
+ [InlineData("N2", 0.25, "MS250N2")]
+ [InlineData("O2", 0.25, "MS250O2")]
+ [InlineData("N2", 0.30, "")]
+ [InlineData("O2", 0.188, "")]
+ public void PrepareForNest_SelectsOnlyTheGasAndThicknessMatch(string gas, double thickness, string expected)
+ {
+ var nest = SquaresNest(("A", new Vector(5, 5)), ("B", new Vector(30, 20)));
+ nest.Material = new Material("Carbon Steel");
+ nest.Thickness = thickness;
+ nest.AssistGas = gas;
+ var config = Config(partSubprograms: false);
+ config.MaterialLibraries = new()
+ {
+ new() { Material = "Carbon Steel", Thickness = 0.25, Gas = "O2", Library = "MS250O2" },
+ new() { Material = "Carbon Steel", Thickness = 0.25, Gas = "N2", Library = "MS250N2" },
+ new() { Material = "Carbon Steel", Thickness = 0.188, Gas = "N2", Library = "MS188N2" },
+ };
+ var post = new CincinnatiPostProcessor(config);
+
+ post.PrepareForNest(nest);
+
+ Assert.Equal(expected, config.SelectedLibrary);
+ var libraries = Sheet(Post(nest, config))
+ .Where(l => l.StartsWith("G89 P", System.StringComparison.Ordinal))
+ .Select(l => l.Split(' ')[1])
+ .Distinct()
+ .ToList();
+ if (expected == "")
+ Assert.Empty(libraries);
+ else
+ Assert.Equal(new[] { $"P{expected}.lib" }, libraries);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void IncrementalPartPrograms_PostWithoutChangingMode(bool partSubprograms)
+ {
+ var nest = new Nest("N");
+ var plate = new Plate(48, 96);
+ nest.Plates.Add(plate);
+ var large = HolePlacementRegressionTests.AddPart(plate, "Large", 0.5, new Vector(10, 20));
+ var small = HolePlacementRegressionTests.AddPart(plate, "Small", 0.25, new Vector(30, 40));
+ large.Program.Mode = Mode.Incremental;
+ small.Program.Mode = Mode.Incremental;
+
+ var run = Run(Post(nest, partSubprograms));
+
+ Assert.Equal(Mode.Incremental, large.Program.Mode);
+ Assert.Equal(Mode.Incremental, small.Program.Mode);
+ HolePlacementRegressionTests.AssertHole(run, large, 0.5);
+ HolePlacementRegressionTests.AssertHole(run, small, 0.25);
+ }
+
+ [Fact]
+ public void Config_WithRemovedSettings_StillLoads()
+ {
+ const string json = """
+ {
+ "UseSheetSubprograms": true,
+ "CoordModeBetweenParts": "G92",
+ "ProcessParameterMode": "Explicit",
+ "InteriorM47": "Auto",
+ "M47OverrideDistanceThreshold": 2.5
+ }
+ """;
+ var options = new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } };
+
+ var config = JsonSerializer.Deserialize(json, options)!;
+
+ Assert.Equal(M47Mode.Auto, config.InteriorM47);
+ Assert.Equal(2.5, config.M47OverrideDistanceThreshold);
+ }
+
+ // Inner square cut first, then the outer square: pierces at (2,2) and (0,0).
+ private static Program SquaresProgram()
+ {
+ var pgm = new Program(Mode.Absolute);
+ pgm.Codes.Add(new RapidMove(2, 2));
+ pgm.Codes.Add(new LinearMove(4, 2));
+ pgm.Codes.Add(new LinearMove(4, 4));
+ pgm.Codes.Add(new LinearMove(2, 4));
+ pgm.Codes.Add(new LinearMove(2, 2));
+ pgm.Codes.Add(new RapidMove(0, 0));
+ pgm.Codes.Add(new LinearMove(10, 0));
+ pgm.Codes.Add(new LinearMove(10, 10));
+ pgm.Codes.Add(new LinearMove(0, 10));
+ pgm.Codes.Add(new LinearMove(0, 0));
+ return pgm;
+ }
+
+ private static Program OuterFirst()
+ {
+ var pgm = SquaresProgram();
+ var inner = pgm.Codes.GetRange(0, 5);
+ pgm.Codes.RemoveRange(0, 5);
+ pgm.Codes.AddRange(inner);
+ return pgm;
+ }
+
+ private static Program HoledSquare()
+ {
+ var pgm = new Program(Mode.Absolute);
+ pgm.Codes.Add(new RapidMove(0, 0));
+ pgm.Codes.Add(new LinearMove(10, 0));
+ pgm.Codes.Add(new LinearMove(10, 10));
+ pgm.Codes.Add(new LinearMove(0, 10));
+ pgm.Codes.Add(new LinearMove(0, 0));
+ pgm.Codes.Add(new RapidMove(3.5, 3));
+ pgm.Codes.Add(new ArcMove(new Vector(3.5, 3), new Vector(3, 3), RotationType.CW));
+ return pgm;
+ }
+
+ private static Nest SquaresNest(params (string Name, Vector Location)[] parts) =>
+ SquaresNest(SquaresProgram, parts);
+
+ private static Nest SquaresNest(Func program, params (string Name, Vector Location)[] parts)
+ {
+ var nest = new Nest("N");
+ var plate = new Plate(48, 96);
+ nest.Plates.Add(plate);
+ var drawings = new Dictionary();
+ foreach (var (name, location) in parts)
+ {
+ if (!drawings.TryGetValue(name, out var drawing))
+ drawings[name] = drawing = new Drawing(name) { Program = program() };
+ plate.Parts.Add(new Part(drawing) { Location = location });
+ }
+ return nest;
+ }
+
+ private static Part AddPart(Plate plate, string name, Program program, Vector location)
+ {
+ var part = new Part(new Drawing(name) { Program = program }) { Location = location };
+ plate.Parts.Add(part);
+ return part;
+ }
+
+ private static void AddLeadInPart(Plate plate, Drawing drawing, double leadIn, Vector location)
+ {
+ var part = new Part(drawing);
+ part.ApplyLeadIns(
+ new CuttingParameters
+ {
+ ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 },
+ ArcCircleLeadOut = new NoLeadOut(),
+ ExternalLeadIn = new LineLeadIn { Length = leadIn, ApproachAngle = 90 },
+ ExternalLeadOut = new NoLeadOut(),
+ },
+ new Vector(10, 10)
+ );
+ part.Location = location;
+ plate.Parts.Add(part);
+ }
+
+ private static List Sheet(string output)
+ {
+ var lines = output.Split('\n').Select(l => l.Trim()).ToList();
+ var start = lines.IndexOf(":101");
+ var end = lines.FindIndex(start, l => l.StartsWith("M99 (END OF", System.StringComparison.Ordinal));
+ return lines.GetRange(start, end - start + 1);
+ }
+
+ private static int Count(string output, string line) =>
+ output.Split('\n').Count(l => l.Trim() == line);
+
+ private static void AssertPoints(IReadOnlyList expected, IReadOnlyList actual)
+ {
+ Assert.Equal(expected.Count, actual.Count);
+ for (var i = 0; i < expected.Count; i++)
+ Assert.True(Near(expected[i], actual[i]), $"Point {i}: expected {expected[i]}, got {actual[i]}");
+ }
+
+ private static void AssertSamePath(Result expected, Result actual)
+ {
+ AssertPoints(expected.PierceLog.Select(p => p.Point).ToList(), actual.PierceLog.Select(p => p.Point).ToList());
+ AssertPoints(expected.Cuts.Select(c => c.From).ToList(), actual.Cuts.Select(c => c.From).ToList());
+ AssertPoints(expected.Cuts.Select(c => c.To).ToList(), actual.Cuts.Select(c => c.To).ToList());
+ Assert.Equal(expected.FullCircles.Count, actual.FullCircles.Count);
+ }
+}
diff --git a/OpenNest.Tests/Cincinnati/CincinnatiReplay.cs b/OpenNest.Tests/Cincinnati/CincinnatiReplay.cs
new file mode 100644
index 00000000..4cb4c062
--- /dev/null
+++ b/OpenNest.Tests/Cincinnati/CincinnatiReplay.cs
@@ -0,0 +1,207 @@
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using OpenNest.Geometry;
+using OpenNest.Posts.Cincinnati;
+
+namespace OpenNest.Tests.Cincinnati;
+
+///
+/// Posts nests with the Cincinnati CL post and replays the G-code to see where the
+/// machine actually cuts.
+///
+internal static class CincinnatiReplay
+{
+ /// A config whose output is easy to replay: no anti-dive, no pallet exchange.
+ internal static CincinnatiPostConfig Config(bool partSubprograms) => new()
+ {
+ PostedAccuracy = 4,
+ UsePartSubprograms = partSubprograms,
+ UseAntiDive = false,
+ PalletExchange = PalletMode.None,
+ };
+
+ internal static string Post(Nest nest, bool partSubprograms) => Post(nest, Config(partSubprograms));
+
+ internal static string Post(Nest nest, CincinnatiPostConfig config)
+ {
+ using var ms = new MemoryStream();
+ new CincinnatiPostProcessor(config).Post(nest, ms);
+ return Encoding.UTF8.GetString(ms.ToArray()).Replace("\r", "");
+ }
+
+ internal sealed class Result
+ {
+ public List<(Vector Center, double Radius)> FullCircles { get; } = new();
+ public int Pierces { get; set; }
+ public int EmptyPierces { get; set; }
+
+ /// Every pierce with the head raise posted before it (null for none).
+ public List<(Vector Point, string? Raise)> PierceLog { get; } = new();
+
+ /// Every cutting move, start to end, in machine coordinates.
+ public List<(Vector From, Vector To)> Cuts { get; } = new();
+
+ /// Head raises posted while the head was already raised.
+ public int DoubleRaises { get; set; }
+
+ /// Rapids taken after a cut with no head raise since.
+ public int HeadDownRapids { get; set; }
+ }
+
+ ///
+ /// Minimal replay of the post's own dialect: M98/M99 calls, G52/G92 frames,
+ /// G0-G3 under G90 with incremental I/J, and M47 head raises (a block-deleted
+ /// /M47 counts as a raise). Every arc must end on its own radius.
+ ///
+ internal static Result Run(string output)
+ {
+ var lines = output.Split('\n').Select(Clean).ToList();
+ var labels = new Dictionary();
+ for (var i = 0; i < lines.Count; i++)
+ {
+ if (lines[i].StartsWith(':'))
+ labels[int.Parse(lines[i][1..], CultureInfo.InvariantCulture)] = i;
+ }
+
+ var run = new Result();
+ var position = Vector.Zero; // machine position
+ var g92 = Vector.Zero; // work origin in machine frame
+ var g52 = Vector.Zero; // local shift in the work frame
+ var returns = new Stack();
+ var pierced = false;
+ var cutSincePierce = false;
+ string? raise = null; // raise posted since the last pierce
+ var headDown = false;
+ var pc = 0;
+
+ while (pc < lines.Count)
+ {
+ var line = lines[pc++];
+ if (line.Length == 0 || line.StartsWith(':') || line.StartsWith("GOTO", System.StringComparison.Ordinal))
+ continue;
+ if (line.StartsWith("#", System.StringComparison.Ordinal))
+ continue;
+
+ var blockDelete = line.StartsWith('/');
+ if (blockDelete)
+ line = line[1..];
+
+ var words = Words(line);
+ if (words.TryGetValue("M", out var m))
+ {
+ if (m == "30")
+ break;
+ if (m == "98")
+ {
+ returns.Push(pc);
+ pc = labels[int.Parse(words["P"], CultureInfo.InvariantCulture)] + 1;
+ continue;
+ }
+ if (m == "99")
+ {
+ pc = returns.Pop();
+ continue;
+ }
+ if (m is "35" or "135")
+ {
+ if (pierced && !cutSincePierce)
+ run.EmptyPierces++;
+ pierced = false;
+ }
+ if (m == "47")
+ {
+ if (raise != null)
+ run.DoubleRaises++;
+ raise = blockDelete ? "/" + line : line;
+ headDown = false;
+ }
+ if (m == "42")
+ headDown = false;
+ continue;
+ }
+
+ if (!words.TryGetValue("G", out var g))
+ continue;
+
+ switch (g)
+ {
+ case "84":
+ case "85":
+ run.Pierces++;
+ run.PierceLog.Add((position, raise));
+ raise = null;
+ pierced = true;
+ headDown = true;
+ cutSincePierce = false;
+ break;
+ case "52":
+ g52 = new Vector(Num(words["X"]), Num(words["Y"]));
+ break;
+ case "92":
+ g92 = words["X"].StartsWith('#')
+ ? Vector.Zero
+ : position - g52 - new Vector(Num(words["X"]), Num(words["Y"]));
+ break;
+ case "0":
+ case "1":
+ case "2":
+ case "3":
+ {
+ var target = g92 + g52 + new Vector(Num(words["X"]), Num(words["Y"]));
+ if (g is "2" or "3")
+ {
+ var center = position + new Vector(Num(words["I"]), Num(words["J"]));
+ var radius = position.DistanceTo(center);
+ Assert.True(
+ System.Math.Abs(target.DistanceTo(center) - radius) < 1e-3,
+ $"Arc ends off its radius: {line}"
+ );
+ if (target.DistanceTo(position) < 1e-6)
+ run.FullCircles.Add((center, radius));
+ }
+ if (g == "0" && headDown)
+ run.HeadDownRapids++;
+ if (g != "0")
+ {
+ cutSincePierce = true;
+ if (pierced)
+ run.Cuts.Add((position, target));
+ }
+ position = target;
+ break;
+ }
+ }
+ }
+
+ return run;
+ }
+
+ internal static bool Near(Vector a, Vector b) => a.DistanceTo(b) < 1e-3;
+
+ private static string Clean(string line)
+ {
+ var text = line.Trim();
+ var comment = text.IndexOf('(');
+ if (comment >= 0)
+ text = text[..comment].Trim();
+ if (text.StartsWith('N'))
+ {
+ var space = text.IndexOf(' ');
+ text = space < 0 ? "" : text[(space + 1)..];
+ }
+ return text;
+ }
+
+ // Words in a block, keyed by letter; a leading G41/G42 gives way to the motion code after it.
+ private static Dictionary Words(string line)
+ {
+ var words = new Dictionary();
+ foreach (var word in line.Split(' ', System.StringSplitOptions.RemoveEmptyEntries))
+ words[word[..1]] = word[1..];
+ return words;
+ }
+
+ private static double Num(string value) => double.Parse(value, CultureInfo.InvariantCulture);
+}
diff --git a/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs b/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs
index 9eaba266..006d035e 100644
--- a/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs
+++ b/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs
@@ -24,7 +24,7 @@ public class HoleCallOutputCharacterizationTests
var body = FirstHole(route, lineNumbers ? "N73 G52 X-12.35 Y6.79" : "G52 X-12.35 Y6.79")
+ Lines("M98 P7", "G52 X0 Y0");
- Assert.Equal(WrapOutput(route, body), output.ToString());
+ Assert.Equal(WrapOutput(route, body, lineNumbers ? 73 : null), output.ToString());
}
[Theory]
@@ -61,7 +61,7 @@ public class HoleCallOutputCharacterizationTests
"M98 P0",
"G52 X0 Y0"
);
- Assert.Equal(WrapOutput(route, body), output.ToString());
+ Assert.Equal(WrapOutput(route, body, lineNumbers ? 73 : null), output.ToString());
}
[Theory]
@@ -92,6 +92,7 @@ public class HoleCallOutputCharacterizationTests
if (route != "part")
body += Lines("( PART: Sample )");
body += Lines(
+ "(WARNING: No library found)",
"G84",
"G1 X2 Y1 F#148",
"G1 X1 Y1",
@@ -102,12 +103,13 @@ public class HoleCallOutputCharacterizationTests
"G52 X0 Y0",
"M47",
lineNumbers ? "N1003 G0 X3 Y3" : "G0 X3 Y3",
+ "(WARNING: No library found)",
"G84",
"G1 X4 Y3 F#148",
"G1 X3 Y3",
"M35"
);
- Assert.Equal(WrapOutput(route, body), output.ToString());
+ Assert.Equal(WrapOutput(route, body, lineNumbers ? 73 : null), output.ToString());
}
[Theory]
@@ -127,14 +129,14 @@ public class HoleCallOutputCharacterizationTests
write(first, program);
Assert.Equal(
- WrapOutput(route, FirstHole(route, "N87 G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0")),
+ WrapOutput(route, FirstHole(route, "N87 G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0"), 87),
first.ToString()
);
config.UseLineNumbers = false;
using var second = new StringWriter { NewLine = "\n" };
write(second, program);
Assert.Equal(
- WrapOutput(route, FirstHole(route, "G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0")),
+ WrapOutput(route, FirstHole(route, "G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0"), null),
second.ToString()
);
}
@@ -152,7 +154,7 @@ public class HoleCallOutputCharacterizationTests
var error = Assert.Throws(() => write(output, program));
Assert.Same(output.Failure, error);
- Assert.Equal(Header(route) + FirstHole(route, "N73 G52 X-12.35 Y6.79"), output.ToString());
+ Assert.Equal(Header(route, 73) + FirstHole(route, "N73 G52 X-12.35 Y6.79"), output.ToString());
}
private static CincinnatiPostConfig CreateConfig(bool lineNumbers) => new()
@@ -161,7 +163,6 @@ public class HoleCallOutputCharacterizationTests
FeatureLineNumberStart = 73,
UseLineNumbers = lineNumbers,
PalletExchange = PalletMode.None,
- ProcessParameterMode = G89Mode.Explicit,
KerfCompensation = KerfMode.PreApplied,
UseAntiDive = false,
};
@@ -204,7 +205,7 @@ public class HoleCallOutputCharacterizationTests
}
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager(), byProgram);
- Dictionary<(int, long), int>? partMapping = route == "grouped-sheet" ? new() : null;
+ Dictionary? partMapping = route == "grouped-sheet" ? new() : null;
return (output, program) =>
{
var plate = new Plate(48, 96);
@@ -234,15 +235,17 @@ public class HoleCallOutputCharacterizationTests
}
// Literal snapshots deliberately do not call the production emitter/formatter.
- private static string WrapOutput(string route, string body) => Header(route) + body +
+ private static string WrapOutput(string route, string body, int? gotoTarget) => Header(route, gotoTarget) + body +
(route == "part" ? Lines("M99 (END OF Sample)") : Lines("M42", "M99 (END OF Nest.001)"));
- private static string Header(string route)
+ // The sheet's GOTO names the first feature's line number, posted only with line numbers.
+ private static string Header(string route, int? gotoTarget)
{
if (route == "part")
return Lines("(*****************************************************)", ":201", "( PART: Sample )");
- return Lines(
+ var lines = new List
+ {
"(*****************************************************)",
"( START OF Nest.001 )",
":101",
@@ -257,8 +260,10 @@ public class HoleCallOutputCharacterizationTests
"M98 P100 (Variable Declaration)",
"G90",
"M47",
- "GOTO1( Goto Feature )"
- );
+ };
+ if (gotoTarget != null)
+ lines.Add($"GOTO{gotoTarget}( Goto Feature )");
+ return Lines(lines.ToArray());
}
// A sheet part that opens with a hole call names the part after the call's G52 line.
diff --git a/OpenNest.Tests/Cincinnati/HolePlacementRegressionTests.cs b/OpenNest.Tests/Cincinnati/HolePlacementRegressionTests.cs
index bf6d841d..ef7ac8bc 100644
--- a/OpenNest.Tests/Cincinnati/HolePlacementRegressionTests.cs
+++ b/OpenNest.Tests/Cincinnati/HolePlacementRegressionTests.cs
@@ -1,7 +1,4 @@
-using System.Globalization;
-using System.IO;
using System.Linq;
-using System.Text;
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
@@ -27,7 +24,7 @@ public class HolePlacementRegressionTests
var small = AddPart(plate, "Small", 0.25, new Vector(30, 20));
nest.Plates.Add(plate);
- var run = Replay(Post(nest, partSubprograms: false));
+ var run = CincinnatiReplay.Run(CincinnatiReplay.Post(nest, partSubprograms: false));
AssertHole(run, large, 0.5);
AssertHole(run, small, 0.25);
@@ -46,7 +43,7 @@ public class HolePlacementRegressionTests
nest.Plates.Add(first);
nest.Plates.Add(second);
- var run = Replay(Post(nest, partSubprograms: true));
+ var run = CincinnatiReplay.Run(CincinnatiReplay.Post(nest, partSubprograms: true));
AssertHole(run, large, 0.5);
AssertHole(run, small, 0.25);
@@ -67,20 +64,17 @@ public class HolePlacementRegressionTests
HoleCall(large).Id = 1;
HoleCall(small).Id = 1;
- var output = Post(nest, partSubprograms);
+ var output = CincinnatiReplay.Post(nest, partSubprograms);
var holeSubs = output.Split('\n').Count(l => l.Trim() == "( PART: HOLE )");
Assert.Equal(2, holeSubs);
- var run = Replay(output);
+ var run = CincinnatiReplay.Run(output);
AssertHole(run, large, 0.5);
- // Part sub-program calls restore the sheet frame with G92 at the part corner while
- // the tool is elsewhere, shifting later parts on the sheet (a separate known defect).
- if (!partSubprograms)
- AssertHole(run, small, 0.25);
+ AssertHole(run, small, 0.25);
}
[Fact]
- public void InlineSheet_PartStartingWithHole_KeepsPartCommentAndSafetyHeadraise()
+ public void InlineSheet_PartStartingWithHole_KeepsPartCommentAndSafetyHeadraiseBeforeItsRapid()
{
var nest = new Nest("N");
var plate = new Plate(48, 96);
@@ -88,13 +82,15 @@ public class HolePlacementRegressionTests
AddPart(plate, "Second", 0.5, new Vector(20, 0));
nest.Plates.Add(plate);
- var lines = Post(nest, partSubprograms: false).Split('\n').Select(l => l.Trim()).ToList();
+ var lines = CincinnatiReplay.Post(nest, partSubprograms: false).Split('\n').Select(l => l.Trim()).ToList();
var second = lines.IndexOf("( PART: Second )");
Assert.True(second > 0);
Assert.EndsWith("G52 X23 Y3", lines[second - 1]);
Assert.Single(lines, l => l == "( PART: First )");
- Assert.Equal("M47 P2000 (Safety Headraise)", lines[second + 3]);
+ // The safety raise ends the previous part, before the rapid to this one.
+ Assert.Equal("M47 P2000 (Safety Headraise)", lines[second - 2]);
+ Assert.Equal("M47", lines[second + 3]);
}
[Theory]
@@ -108,7 +104,7 @@ public class HolePlacementRegressionTests
nest.Plates.Add(plate);
Assert.IsType(part.Program.Codes[0]);
- var run = Replay(Post(nest, partSubprograms));
+ var run = CincinnatiReplay.Run(CincinnatiReplay.Post(nest, partSubprograms));
Assert.Equal(0, run.EmptyPierces);
Assert.Equal(2, run.Pierces);
@@ -126,7 +122,7 @@ public class HolePlacementRegressionTests
var hole = HoleCall(part).Program;
var before = hole.Codes.Count;
- Post(nest, partSubprograms);
+ CincinnatiReplay.Post(nest, partSubprograms);
Assert.Equal(Mode.Incremental, hole.Mode);
Assert.Equal(before, hole.Codes.Count);
@@ -145,7 +141,7 @@ public class HolePlacementRegressionTests
AddPart(plate, "Second", 0.5, new Vector(20, 0));
nest.Plates.Add(plate);
- var output = Post(nest, partSubprograms);
+ var output = CincinnatiReplay.Post(nest, partSubprograms);
var sheet = output[output.IndexOf(":101", System.StringComparison.Ordinal)..];
var order = sheet
@@ -157,7 +153,7 @@ public class HolePlacementRegressionTests
Assert.Equal(new[] { "( PART: Third )", "( PART: First )", "( PART: Second )" }, order);
}
- private static Part AddPart(Plate plate, string name, double holeRadius, Vector location)
+ internal static Part AddPart(Plate plate, string name, double holeRadius, Vector location)
{
var pgm = new Program(Mode.Absolute);
pgm.Codes.Add(new RapidMove(0, 0));
@@ -186,10 +182,10 @@ public class HolePlacementRegressionTests
return part;
}
- private static SubProgramCall HoleCall(Part part) =>
+ internal static SubProgramCall HoleCall(Part part) =>
part.Program.Codes.OfType().Single();
- private static void AssertHole(Replayed run, Part part, double radius)
+ internal static void AssertHole(CincinnatiReplay.Result run, Part part, double radius)
{
var expected = part.Location + HoleCenter;
Assert.Contains(
@@ -197,154 +193,4 @@ public class HolePlacementRegressionTests
c => c.Center.DistanceTo(expected) < 1e-3 && System.Math.Abs(c.Radius - radius) < 1e-3
);
}
-
- private static string Post(Nest nest, bool partSubprograms)
- {
- var config = new CincinnatiPostConfig
- {
- PostedAccuracy = 4,
- UsePartSubprograms = partSubprograms,
- ProcessParameterMode = G89Mode.Explicit,
- UseAntiDive = false,
- PalletExchange = PalletMode.None,
- };
- using var ms = new MemoryStream();
- new CincinnatiPostProcessor(config).Post(nest, ms);
- return Encoding.UTF8.GetString(ms.ToArray()).Replace("\r", "");
- }
-
- private sealed class Replayed
- {
- public List<(Vector Center, double Radius)> FullCircles { get; } = new();
- public int Pierces { get; set; }
- public int EmptyPierces { get; set; }
- }
-
- ///
- /// Minimal replay of the post's own dialect: M98/M99 calls, G52/G92 frames,
- /// G0-G3 under G90 with incremental I/J. Every arc must end on its own radius.
- ///
- private static Replayed Replay(string output)
- {
- var lines = output.Split('\n').Select(Clean).ToList();
- var labels = new Dictionary();
- for (var i = 0; i < lines.Count; i++)
- {
- if (lines[i].StartsWith(':'))
- labels[int.Parse(lines[i][1..], CultureInfo.InvariantCulture)] = i;
- }
-
- var run = new Replayed();
- var position = Vector.Zero; // machine position
- var g92 = Vector.Zero; // work origin in machine frame
- var g52 = Vector.Zero; // local shift in the work frame
- var returns = new Stack();
- var pierced = false;
- var cutSincePierce = false;
- var pc = 0;
-
- while (pc < lines.Count)
- {
- var line = lines[pc++];
- if (line.Length == 0 || line.StartsWith(':') || line.StartsWith("GOTO", System.StringComparison.Ordinal))
- continue;
- if (line.StartsWith("#", System.StringComparison.Ordinal) || line.StartsWith("/", System.StringComparison.Ordinal))
- continue;
-
- var words = Words(line);
- if (words.TryGetValue("M", out var m))
- {
- if (m == "30")
- break;
- if (m == "98")
- {
- returns.Push(pc);
- pc = labels[int.Parse(words["P"], CultureInfo.InvariantCulture)] + 1;
- continue;
- }
- if (m == "99")
- {
- pc = returns.Pop();
- continue;
- }
- if (m is "35" or "135")
- {
- if (pierced && !cutSincePierce)
- run.EmptyPierces++;
- pierced = false;
- }
- continue;
- }
-
- if (!words.TryGetValue("G", out var g))
- continue;
-
- switch (g)
- {
- case "84":
- case "85":
- run.Pierces++;
- pierced = true;
- cutSincePierce = false;
- break;
- case "52":
- g52 = new Vector(Num(words["X"]), Num(words["Y"]));
- break;
- case "92":
- g92 = words["X"].StartsWith('#')
- ? Vector.Zero
- : position - g52 - new Vector(Num(words["X"]), Num(words["Y"]));
- break;
- case "0":
- case "1":
- case "2":
- case "3":
- {
- var target = g92 + g52 + new Vector(Num(words["X"]), Num(words["Y"]));
- if (g is "2" or "3")
- {
- var center = position + new Vector(Num(words["I"]), Num(words["J"]));
- var radius = position.DistanceTo(center);
- Assert.True(
- System.Math.Abs(target.DistanceTo(center) - radius) < 1e-3,
- $"Arc ends off its radius: {line}"
- );
- if (target.DistanceTo(position) < 1e-6)
- run.FullCircles.Add((center, radius));
- }
- if (g != "0")
- cutSincePierce = true;
- position = target;
- break;
- }
- }
- }
-
- return run;
- }
-
- private static string Clean(string line)
- {
- var text = line.Trim();
- var comment = text.IndexOf('(');
- if (comment >= 0)
- text = text[..comment].Trim();
- if (text.StartsWith('N'))
- {
- var space = text.IndexOf(' ');
- text = space < 0 ? "" : text[(space + 1)..];
- }
- return text;
- }
-
- // Words in a block, keyed by letter; a leading G41/G42 gives way to the motion code after it.
- private static Dictionary Words(string line)
- {
- var words = new Dictionary();
- foreach (var word in line.Split(' ', System.StringSplitOptions.RemoveEmptyEntries))
- words[word[..1]] = word[1..];
- return words;
- }
-
- private static double Num(string value) => double.Parse(value, CultureInfo.InvariantCulture);
}
diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs
index 034b3538..0209644a 100644
--- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs
+++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs
@@ -17,13 +17,26 @@ public sealed class FeatureContext
public string PartName { get; set; } = "";
public bool IsFirstFeatureOfPart { get; set; }
public bool IsLastFeatureOnSheet { get; set; }
+
+ ///
+ /// True when the rapid after this feature goes to a different part, so its head
+ /// raise is the safety head raise.
+ ///
public bool IsSafetyHeadraise { get; set; }
+
+ /// True when this feature cuts the part's exterior (selects ExteriorM47).
public bool IsExteriorFeature { get; set; }
public bool IsEtch { get; set; }
public string LibraryFile { get; set; } = "";
public double CutDistance { get; set; }
public double SheetDiagonal { get; set; }
+ ///
+ /// Rapid travel from this feature's end to the next pierce, used by
+ /// . Null when unknown.
+ ///
+ public double? RapidDistance { get; set; }
+
///
/// Part location on the plate. Added to all output X/Y coordinates
/// so part-relative programs become plate-absolute under G90.
@@ -93,19 +106,16 @@ public sealed class CincinnatiFeatureWriter
writer.WriteLine(CoordinateFormatter.Comment($"PART: {ctx.PartName}"));
// 3. G89 process params
- if (_config.ProcessParameterMode == G89Mode.LibraryFile)
+ var lib = ctx.LibraryFile;
+ if (!string.IsNullOrEmpty(lib))
{
- var lib = ctx.LibraryFile;
- if (!string.IsNullOrEmpty(lib))
- {
- var speedClass = _speedClassifier.Classify(ctx.CutDistance, ctx.SheetDiagonal);
- var cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal);
- writer.WriteLine($"G89 P{lib} ({speedClass} {cutDist})");
- }
- else
- {
- writer.WriteLine("(WARNING: No library found)");
- }
+ var speedClass = _speedClassifier.Classify(ctx.CutDistance, ctx.SheetDiagonal);
+ var cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal);
+ writer.WriteLine($"G89 P{lib} ({speedClass} {cutDist})");
+ }
+ else
+ {
+ writer.WriteLine("(WARNING: No library found)");
}
// 4. Pierce/beam on — G85 for etch (no pierce), G84 for cut
@@ -322,25 +332,14 @@ public sealed class CincinnatiFeatureWriter
private void WriteM47(TextWriter writer, FeatureContext ctx)
{
- if (ctx.IsSafetyHeadraise && _config.SafetyHeadraiseDistance.HasValue)
- {
- writer.WriteLine($"M47 P{_config.SafetyHeadraiseDistance.Value} (Safety Headraise)");
- return;
- }
-
- var mode = ctx.IsExteriorFeature ? _config.ExteriorM47 : _config.InteriorM47;
-
- switch (mode)
- {
- case M47Mode.Always:
- writer.WriteLine("M47");
- break;
- case M47Mode.BlockDelete:
- writer.WriteLine("/M47");
- break;
- case M47Mode.None:
- break;
- }
+ var line = CincinnatiHeadRaise.Line(
+ _config,
+ ctx.IsSafetyHeadraise,
+ ctx.IsExteriorFeature,
+ ctx.RapidDistance
+ );
+ if (line != null)
+ writer.WriteLine(line);
}
private static string GetLinearFeedVariable(LayerType layer)
diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiHeadRaise.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiHeadRaise.cs
new file mode 100644
index 00000000..173f82d7
--- /dev/null
+++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiHeadRaise.cs
@@ -0,0 +1,37 @@
+namespace OpenNest.Posts.Cincinnati;
+
+///
+/// Chooses the M47 head raise (§3.47) written before the rapid that leaves a feature.
+///
+internal static class CincinnatiHeadRaise
+{
+ /// True when the rapid goes to a different part.
+ /// True when the feature just cut is a part exterior.
+ /// Travel to the next pierce; null when unknown.
+ /// The block to write, or null for no head raise.
+ internal static string Line(
+ CincinnatiPostConfig config,
+ bool isSafetyHeadraise,
+ bool isExterior,
+ double? rapidDistance
+ )
+ {
+ if (isSafetyHeadraise && config.SafetyHeadraiseDistance.HasValue)
+ return $"M47 P{config.SafetyHeadraiseDistance.Value} (Safety Headraise)";
+
+ var mode = isExterior ? config.ExteriorM47 : config.InteriorM47;
+ return mode switch
+ {
+ M47Mode.Always => "M47",
+ M47Mode.BlockDelete => "/M47",
+ M47Mode.Auto => IsLongRapid(config, rapidDistance) ? "M47" : "/M47",
+ _ => null,
+ };
+ }
+
+ // Unknown travel, or no threshold configured, always gets the full raise.
+ private static bool IsLongRapid(CincinnatiPostConfig config, double? rapidDistance) =>
+ config.M47OverrideDistanceThreshold is not { } threshold
+ || rapidDistance is not { } distance
+ || distance > threshold;
+}
diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs
index f862b538..f409ab2c 100644
--- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs
+++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs
@@ -7,10 +7,18 @@ using OpenNest.Geometry;
namespace OpenNest.Posts.Cincinnati;
+///
+/// A posted part sub-program and where one part's program frame puts its origin.
+///
+/// Posted sub-program number.
+/// The normalized, absolute program the sub-program is written from.
+/// The sub-program origin in the part's program frame.
+public sealed record PartSubprogram(int Number, Program Program, Vector Corner);
+
///
/// Writes a Cincinnati-format part sub-program definition.
-/// Each sub-program contains the complete cutting sequence for one unique part geometry
-/// (drawing + rotation), with coordinates normalized to origin (0,0).
+/// Each sub-program contains the complete cutting sequence for one unique posted part
+/// toolpath, with coordinates normalized to origin (0,0).
/// Called via M98 from sheet sub-programs.
///
public sealed class CincinnatiPartSubprogramWriter
@@ -51,6 +59,7 @@ public sealed class CincinnatiPartSubprogramWriter
// Classify and order: etch features first, then cut features
var ordered = FeatureUtils.ClassifyAndOrder(allFeatures);
+ var exterior = FeatureUtils.FindExterior(ordered);
w.WriteLine("(*****************************************************)");
w.WriteLine($":{subNumber}");
@@ -81,11 +90,14 @@ public sealed class CincinnatiPartSubprogramWriter
IsFirstFeatureOfPart = false,
IsLastFeatureOnSheet = isLastFeature,
IsSafetyHeadraise = false,
- IsExteriorFeature = false,
+ IsExteriorFeature = exterior[i],
IsEtch = isEtch,
LibraryFile = isEtch ? etchLibrary : cutLibrary,
CutDistance = cutDistance,
SheetDiagonal = sheetDiagonal,
+ RapidDistance = isLastFeature
+ ? null
+ : FeatureUtils.EndPoint(codes).DistanceTo(FeatureUtils.StartPoint(ordered[i + 1].codes)),
};
_featureWriter.Write(w, ctx);
@@ -102,22 +114,19 @@ public sealed class CincinnatiPartSubprogramWriter
internal static void EnsureLeadingRapid(Program pgm) => FeatureUtils.InsertLeadingRapid(pgm.Codes);
///
- /// Creates a sub-program key for matching parts to their sub-programs.
- ///
- internal static (int drawingId, long rotationKey) SubprogramKey(Part part) =>
- (part.BaseDrawing.Id, (long)System.Math.Round(part.Rotation * 1e6));
-
- ///
- /// Scans all plates and builds a mapping of unique part geometries to sub-program numbers,
- /// along with their normalized programs for writing.
+ /// Scans all plates and assigns each non-cutoff part the sub-program for its posted
+ /// toolpath: parts share a sub-program only when their normalized programs, lead-ins
+ /// included, post identically under the same drawing name. Part programs are copied,
+ /// never modified.
///
internal static (
- Dictionary<(int, long), int> mapping,
+ Dictionary mapping,
List<(int subNum, string name, Program program)> entries
) BuildRegistry(IEnumerable plates, int startNumber)
{
- var mapping = new Dictionary<(int, long), int>();
+ var mapping = new Dictionary(ReferenceEqualityComparer.Instance);
var entries = new List<(int, string, Program)>();
+ var contentIndex = new Dictionary();
var nextSubNum = startNumber;
foreach (var plate in plates)
@@ -126,32 +135,44 @@ public sealed class CincinnatiPartSubprogramWriter
{
if (part.BaseDrawing.IsCutOff)
continue;
- var key = SubprogramKey(part);
- if (!mapping.ContainsKey(key))
+
+ var pgm = PostedCopy(part.Program);
+ var bbox = pgm.BoundingBox();
+ pgm.Offset(-bbox.Location.X, -bbox.Location.Y);
+
+ // If the program has no leading rapid, the feature writer
+ // will use the first motion endpoint as the pierce point,
+ // losing the first contour segment. Insert a synthetic rapid
+ // at the contour's return point (last motion endpoint) so
+ // the full contour is preserved.
+ EnsureLeadingRapid(pgm);
+
+ var name = part.BaseDrawing.Name;
+ var key = name + "\n" + ProgramToCanonical(pgm);
+ if (!contentIndex.TryGetValue(key, out var entry))
{
- var subNum = nextSubNum++;
- mapping[key] = subNum;
-
- var pgm = part.Program.Clone() as Program;
- pgm.Mode = Mode.Absolute;
- var bbox = pgm.BoundingBox();
- pgm.Offset(-bbox.Location.X, -bbox.Location.Y);
-
- // If the program has no leading rapid, the feature writer
- // will use the first motion endpoint as the pierce point,
- // losing the first contour segment. Insert a synthetic rapid
- // at the contour's return point (last motion endpoint) so
- // the full contour is preserved.
- EnsureLeadingRapid(pgm);
-
- entries.Add((subNum, part.BaseDrawing.Name, pgm));
+ entry = (nextSubNum++, pgm);
+ contentIndex[key] = entry;
+ entries.Add((entry.subNum, name, pgm));
}
+
+ mapping[part] = new PartSubprogram(entry.subNum, entry.program, bbox.Location);
}
}
return (mapping, entries);
}
+ // An absolute copy with suppressed moves left uncut, as the post writes it.
+ private static Program PostedCopy(Program program)
+ {
+ var copy = FeatureUtils.ToAbsoluteCopy(program);
+ var codes = FeatureUtils.RemoveSuppressed(copy.Codes);
+ copy.Codes.Clear();
+ copy.Codes.AddRange(codes);
+ return copy;
+ }
+
///
/// Builds a nest-level registry of unique hole sub-programs from every hole call in
/// the plates' parts and in the part sub-program copies. Calls are keyed by their
@@ -179,10 +200,10 @@ public sealed class CincinnatiPartSubprogramWriter
{
if (code is not SubProgramCall { Program: not null } call)
continue;
- if (mapping.ContainsKey(call.Program))
+ if (mapping.ContainsKey(call.Program) || !FeatureUtils.HasCut(call.Program))
continue;
- var absolute = FeatureUtils.ToAbsoluteCopy(call.Program);
+ var absolute = PostedCopy(call.Program);
EnsureLeadingRapid(absolute);
var canonical = ProgramToCanonical(absolute);
@@ -232,6 +253,11 @@ public sealed class CincinnatiPartSubprogramWriter
);
else if (code is RapidMove rm)
sb.Append(CultureInfo.InvariantCulture, $"R{rm.EndPoint.X:F6},{rm.EndPoint.Y:F6}");
+ else if (code is SubProgramCall { Program: not null } call)
+ sb.Append(
+ CultureInfo.InvariantCulture,
+ $"S{call.Offset.X:F6},{call.Offset.Y:F6}[{ProgramToCanonical(PostedCopy(call.Program))}]"
+ );
}
return sb.ToString();
}
diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs
index 64258d72..0d7fd7e6 100644
--- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs
+++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs
@@ -5,33 +5,6 @@ using System.Linq;
namespace OpenNest.Posts.Cincinnati
{
- ///
- /// Specifies how coordinate positioning is handled between parts.
- ///
- public enum CoordinateMode
- {
- /// Set absolute position.
- G92,
-
- /// Use relative/incremental positioning.
- G91,
-
- /// Use machine coordinate system.
- G53,
- }
-
- ///
- /// Specifies how G89 (hole drilling/tapping parameters) are provided.
- ///
- public enum G89Mode
- {
- /// Use external library file for G89 parameters.
- LibraryFile,
-
- /// Explicitly define G89 parameters in the program.
- Explicit,
- }
-
///
/// Specifies where kerf compensation is applied.
///
@@ -57,17 +30,20 @@ namespace OpenNest.Posts.Cincinnati
}
///
- /// Specifies how M47 (optional stop) commands are used.
+ /// Specifies how the M47 head raise before each rapid is posted.
///
public enum M47Mode
{
/// Always include M47.
Always,
- /// Include M47 with block delete functionality.
+ /// Post /M47, which the operator can skip with block delete.
BlockDelete,
- /// Automatically determine M47 placement.
+ ///
+ /// M47 before rapids longer than the M47 override distance threshold, /M47 before
+ /// shorter ones. Without a threshold every raise is M47.
+ ///
Auto,
/// Do not use M47.
@@ -120,11 +96,6 @@ namespace OpenNest.Posts.Cincinnati
[Description("Starting line number for features.")]
public int FeatureLineNumberStart { get; set; } = 1;
- [Category("2. Subprograms")]
- [DisplayName("Use Sheet Subprograms")]
- [Description("Use subprograms for sheet operations.")]
- public bool UseSheetSubprograms { get; set; } = true;
-
[Category("2. Subprograms")]
[DisplayName("Sheet Subprogram Start")]
[Description("Starting subprogram number for sheet operations.")]
@@ -147,16 +118,6 @@ namespace OpenNest.Posts.Cincinnati
[Description("Subprogram number for variable declarations.")]
public int VariableDeclarationSubprogram { get; set; } = 100;
- [Category("3. Positioning")]
- [DisplayName("Coordinate Mode Between Parts")]
- [Description("How coordinate positioning is handled between parts (G92, G91, or G53).")]
- public CoordinateMode CoordModeBetweenParts { get; set; } = CoordinateMode.G92;
-
- [Category("4. Process")]
- [DisplayName("Process Parameter Mode")]
- [Description("How G89 parameters are provided (LibraryFile or Explicit).")]
- public G89Mode ProcessParameterMode { get; set; } = G89Mode.LibraryFile;
-
[Category("4. Process")]
[DisplayName("Default Assist Gas")]
[Description("Default assist gas when Nest.AssistGas is empty.")]
@@ -199,22 +160,28 @@ namespace OpenNest.Posts.Cincinnati
[Category("6. M47 (Optional Stop)")]
[DisplayName("Interior M47")]
- [Description("How M47 is used in interior cuts.")]
+ [Description("How M47 is posted after features other than a part's exterior, such as cutouts and etch.")]
public M47Mode InteriorM47 { get; set; } = M47Mode.Always;
[Category("6. M47 (Optional Stop)")]
[DisplayName("Exterior M47")]
- [Description("How M47 is used in exterior cuts.")]
+ [Description(
+ "How M47 is posted after a part's exterior: the cut contour spanning the part's cut extents, or a part sub-program call."
+ )]
public M47Mode ExteriorM47 { get; set; } = M47Mode.Always;
[Category("6. M47 (Optional Stop)")]
[DisplayName("M47 Override Distance Threshold")]
- [Description("Distance threshold for M47 override. Null = no override.")]
+ [Description(
+ "Auto M47 mode: rapids to the next pierce longer than this get M47, shorter ones /M47. Program units. Null = Auto always posts M47."
+ )]
public double? M47OverrideDistanceThreshold { get; set; } = null;
[Category("7. Safety")]
[DisplayName("Safety Headraise Distance")]
- [Description("Safety head raise distance in machine units. Null = disabled.")]
+ [Description(
+ "Safety head raise distance in machine units, posted as M47 P before the rapid to a different part. Null = disabled."
+ )]
public int? SafetyHeadraiseDistance { get; set; } = 2000;
[Category("8. Pallet")]
@@ -309,21 +276,6 @@ namespace OpenNest.Posts.Cincinnati
)]
[TypeConverter(typeof(MaterialLibraryNameConverter))]
public string SelectedLibrary { get; set; } = "";
-
- public string FindBestLibrary(string materialName, double thickness)
- {
- if (MaterialLibraries == null || string.IsNullOrEmpty(materialName))
- return "";
-
- return MaterialLibraries
- .Where(e =>
- string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase)
- )
- .OrderBy(e => System.Math.Abs(e.Thickness - thickness))
- .Select(e => e.Library)
- .FirstOrDefault()
- ?? "";
- }
}
public class MaterialLibraryEntry
diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs
index 08f1e884..14aea7ca 100644
--- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs
+++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs
@@ -38,11 +38,24 @@ namespace OpenNest.Posts.Cincinnati
.Where(s => !string.IsNullOrWhiteSpace(s));
}
+ ///
+ /// Pre-selects the library auto-resolution would post for this nest (material,
+ /// thickness within tolerance and assist gas), so the configuration dialog shows
+ /// it. With no matching entry the selection is cleared rather than guessed.
+ ///
public void PrepareForNest(Nest nest)
{
- var materialName = nest?.Material?.Name ?? "";
- var thickness = nest?.Thickness ?? 0.0;
- Config.SelectedLibrary = Config.FindBestLibrary(materialName, thickness);
+ Config.SelectedLibrary = "";
+ if (nest == null)
+ return;
+
+ var entry = MaterialLibraryResolver.FindCutEntry(
+ Config.MaterialLibraries,
+ nest.Material?.Name ?? "",
+ nest.Thickness,
+ MaterialLibraryResolver.ResolveGas(nest, Config)
+ );
+ Config.SelectedLibrary = entry?.Library ?? "";
}
public CincinnatiPostProcessor()
@@ -105,7 +118,7 @@ namespace OpenNest.Posts.Cincinnati
);
// 5. Build part sub-program registry (if enabled)
- Dictionary<(int, long), int> partSubprograms = null;
+ Dictionary partSubprograms = null;
List<(int subNum, string name, Program program)> subprogramEntries = null;
if (Config.UsePartSubprograms)
diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs
index 2dc34f26..120ee1a3 100644
--- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs
+++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs
@@ -57,10 +57,7 @@ public sealed class CincinnatiPreambleWriter
w.WriteLine("M42");
- if (
- _config.ProcessParameterMode == G89Mode.LibraryFile
- && !string.IsNullOrEmpty(initialLibrary)
- )
+ if (!string.IsNullOrEmpty(initialLibrary))
w.WriteLine($"G89 P{initialLibrary}");
w.WriteLine($"M98 P{_config.VariableDeclarationSubprogram} (Variable Declaration)");
diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs
index 9ffa8a11..959f5f6b 100644
--- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs
+++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs
@@ -1,9 +1,9 @@
-using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using OpenNest.CNC;
+using OpenNest.Geometry;
namespace OpenNest.Posts.Cincinnati;
@@ -38,8 +38,8 @@ public sealed class CincinnatiSheetWriter
/// Resolved G89 library file for cut operations.
/// Resolved G89 library file for etch operations.
///
- /// Optional mapping of (drawingId, rotationKey) to sub-program number.
- /// When provided, non-cutoff parts are emitted as M98 calls instead of inline features.
+ /// Optional mapping of parts (by reference) to their posted part sub-programs.
+ /// When provided, mapped non-cutoff parts are emitted as M98 calls instead of inline features.
///
public void Write(
TextWriter w,
@@ -49,7 +49,7 @@ public sealed class CincinnatiSheetWriter
int subNumber,
string cutLibrary,
string etchLibrary,
- Dictionary<(int, long), int> partSubprograms = null,
+ IReadOnlyDictionary partSubprograms = null,
Dictionary<(int drawingId, string varName), int> userVarMapping = null
)
{
@@ -87,36 +87,18 @@ public sealed class CincinnatiSheetWriter
w.WriteLine("M47");
if (!string.IsNullOrEmpty(cutLibrary))
w.WriteLine($"G89 P{cutLibrary}");
- w.WriteLine("GOTO1( Goto Feature )");
+
+ // The operator edits this target to restart at a feature. It names the first
+ // feature's line number, which exists only when line numbers are posted.
+ if (_config.UseLineNumbers)
+ w.WriteLine($"GOTO{_config.FeatureLineNumberStart}( Goto Feature )");
// 3. Plate order is the cut sequence, cut-offs included: manual, cut-first
// and automatic sequencing all set it, so the post must not reorder.
- var allParts = plate.Parts.ToList();
+ var items = BuildItems(plate.Parts, partSubprograms);
// 4. Emit parts
- if (partSubprograms != null)
- WritePartsWithSubprograms(
- w,
- allParts,
- cutLibrary,
- etchLibrary,
- sheetDiagonal,
- width,
- length,
- partSubprograms,
- userVarMapping
- );
- else
- WritePartsInline(
- w,
- allParts,
- cutLibrary,
- etchLibrary,
- sheetDiagonal,
- width,
- length,
- userVarMapping
- );
+ WriteItems(w, items, cutLibrary, etchLibrary, sheetDiagonal, width, length, userVarMapping);
// 5. Footer
w.WriteLine("M42");
@@ -125,182 +107,135 @@ public sealed class CincinnatiSheetWriter
w.WriteLine($"M99 (END OF {nestName}.{layoutIndex:D3})");
}
- private void WritePartsWithSubprograms(
+ ///
+ /// One posted step of the sheet: a part feature, a hole call or a part sub-program
+ /// call. Points are in sheet coordinates.
+ ///
+ private sealed class SheetItem
+ {
+ public Part Part { get; init; }
+ public List Codes { get; init; }
+ public bool IsEtch { get; init; }
+ public bool IsExterior { get; init; }
+ public PartSubprogram Subprogram { get; init; }
+
+ /// First rapid target: the sub-program corner, else the first pierce.
+ public Vector Entry { get; init; }
+
+ public Vector Start { get; init; }
+ public Vector End { get; init; }
+
+ /// Where the tool leaves from: back at the corner after a sub-program call.
+ public Vector Exit { get; init; }
+ }
+
+ private static List BuildItems(
+ IEnumerable parts,
+ IReadOnlyDictionary partSubprograms
+ )
+ {
+ var items = new List();
+ foreach (var part in parts)
+ {
+ if (
+ partSubprograms != null
+ && !part.BaseDrawing.IsCutOff
+ && partSubprograms.TryGetValue(part, out var sub)
+ )
+ {
+ var subFeatures = FeatureUtils.ClassifyAndOrder(
+ FeatureUtils.SplitByRapids(sub.Program.Codes)
+ );
+ if (subFeatures.Count == 0)
+ continue;
+
+ var corner = part.Location + sub.Corner;
+ items.Add(
+ new SheetItem
+ {
+ Part = part,
+ Subprogram = sub,
+ Entry = corner,
+ Start = corner + FeatureUtils.StartPoint(subFeatures[0].codes),
+ End = corner + FeatureUtils.EndPoint(subFeatures[^1].codes),
+ Exit = corner,
+ }
+ );
+ continue;
+ }
+
+ var features = FeatureUtils.SplitAndClassify(part);
+ var exterior = FeatureUtils.FindExterior(features);
+ for (var f = 0; f < features.Count; f++)
+ {
+ var (codes, isEtch) = features[f];
+ var start = part.Location + FeatureUtils.StartPoint(codes);
+ var end = part.Location + FeatureUtils.EndPoint(codes);
+ items.Add(
+ new SheetItem
+ {
+ Part = part,
+ Codes = codes,
+ IsEtch = isEtch,
+ IsExterior = exterior[f],
+ Entry = start,
+ Start = start,
+ End = end,
+ Exit = end,
+ }
+ );
+ }
+ }
+ return items;
+ }
+
+ private void WriteItems(
TextWriter w,
- List allParts,
+ List items,
string cutLibrary,
string etchLibrary,
double sheetDiagonal,
double plateWidth,
double plateLength,
- Dictionary<(int, long), int> partSubprograms,
Dictionary<(int drawingId, string varName), int> userVarMapping
)
{
var lastPartName = "";
- var featureIndex = 0;
-
- for (var p = 0; p < allParts.Count; p++)
+ for (var i = 0; i < items.Count; i++)
{
- var part = allParts[p];
+ var item = items[i];
+ var part = item.Part;
var partName = part.BaseDrawing.Name;
- var isNewPart = partName != lastPartName;
- var isSafetyHeadraise = isNewPart && lastPartName != "";
- var isLastPart = p == allParts.Count - 1;
+ var isFirstFeatureOfPart = partName != lastPartName;
+ var next = i + 1 < items.Count ? items[i + 1] : null;
+ var isLastFeature = next == null;
- var key = CincinnatiPartSubprogramWriter.SubprogramKey(part);
- partSubprograms.TryGetValue(key, out var subNum);
- var hasSubprogram = !part.BaseDrawing.IsCutOff && subNum != 0;
+ // A head raise belongs before the rapid that leaves this item, so the safety
+ // raise goes on a part's last item when a different part follows.
+ var isSafetyHeadraise = next != null && next.Part.BaseDrawing.Name != partName;
+ double? rapidDistance =
+ next == null
+ ? null
+ : item.End.DistanceTo(item.Exit)
+ + item.Exit.DistanceTo(next.Entry)
+ + next.Entry.DistanceTo(next.Start);
+ lastPartName = partName;
- if (hasSubprogram)
+ if (item.Subprogram != null)
{
WriteSubprogramCall(
w,
- part,
- subNum,
- featureIndex,
+ item,
+ i,
partName,
isSafetyHeadraise,
- isLastPart
+ rapidDistance,
+ isLastFeature
);
- featureIndex++;
- }
- else
- {
- // Inline features for cutoffs or parts without sub-programs
- var features = FeatureUtils.SplitAndClassify(part);
- for (var f = 0; f < features.Count; f++)
- {
- var (codes, isEtch) = features[f];
- var isLastFeature = isLastPart && f == features.Count - 1;
-
- // SubProgramCall features are emitted as M98 hole calls
- if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
- {
- CincinnatiHoleCallWriter.Write(
- w,
- holeCall,
- part.Location,
- featureIndex,
- isLastFeature,
- _config,
- _fmt,
- _holeSubprograms,
- isNewPart && f == 0 ? partName : null,
- isSafetyHeadraise && f == 0
- );
- featureIndex++;
- lastPartName = partName;
- continue;
- }
-
- var featureNumber =
- featureIndex == 0
- ? _config.FeatureLineNumberStart
- : 1000 + featureIndex + 1;
-
- var cutDistance = FeatureUtils.ComputeCutDistance(codes);
-
- var ctx = new FeatureContext
- {
- Codes = codes,
- FeatureNumber = featureNumber,
- PartName = partName,
- IsFirstFeatureOfPart = isNewPart && f == 0,
- IsLastFeatureOnSheet = isLastFeature,
- IsSafetyHeadraise = isSafetyHeadraise && f == 0,
- IsExteriorFeature = false,
- IsEtch = isEtch,
- LibraryFile = isEtch ? etchLibrary : cutLibrary,
- CutDistance = cutDistance,
- SheetDiagonal = sheetDiagonal,
- PartLocation = part.Location,
- UserVariableMapping = userVarMapping,
- DrawingId = part.BaseDrawing.Id,
- IsCutOff = part.BaseDrawing.IsCutOff,
- PlateWidth = plateWidth,
- PlateLength = plateLength,
- };
-
- _featureWriter.Write(w, ctx);
- featureIndex++;
- }
+ continue;
}
- lastPartName = partName;
- }
- }
-
- private void WriteSubprogramCall(
- TextWriter w,
- Part part,
- int subNum,
- int featureIndex,
- string partName,
- bool isSafetyHeadraise,
- bool isLastPart
- )
- {
- // Safety headraise before rapid to new part
- if (isSafetyHeadraise && _config.SafetyHeadraiseDistance.HasValue)
- w.WriteLine($"M47 P{_config.SafetyHeadraiseDistance.Value} (Safety Headraise)");
-
- // Rapid to part position (bounding box lower-left)
- var featureNumber =
- featureIndex == 0 ? _config.FeatureLineNumberStart : 1000 + featureIndex + 1;
-
- var sb = new StringBuilder();
- if (_config.UseLineNumbers)
- sb.Append($"N{featureNumber} ");
- sb.Append($"G0 X{_fmt.FormatCoord(part.Left)} Y{_fmt.FormatCoord(part.Bottom)}");
- w.WriteLine(sb.ToString());
-
- // Part name comment
- w.WriteLine(CoordinateFormatter.Comment($"PART: {partName}"));
-
- // Set local coordinate system at part position
- w.WriteLine("G92 X0 Y0");
-
- // Call part sub-program
- w.WriteLine($"M98 P{subNum} ({partName})");
-
- // Restore sheet coordinate system
- w.WriteLine($"G92 X{_fmt.FormatCoord(part.Left)} Y{_fmt.FormatCoord(part.Bottom)}");
-
- // Head raise (unless last part on sheet)
- if (!isLastPart)
- w.WriteLine("M47");
- }
-
- private void WritePartsInline(
- TextWriter w,
- List allParts,
- string cutLibrary,
- string etchLibrary,
- double sheetDiagonal,
- double plateWidth,
- double plateLength,
- Dictionary<(int drawingId, string varName), int> userVarMapping
- )
- {
- // Split and classify features, ordering etch before cut per part
- var features = new List<(Part part, List codes, bool isEtch)>();
- foreach (var part in allParts)
- {
- var partFeatures = FeatureUtils.SplitAndClassify(part);
- foreach (var (codes, isEtch) in partFeatures)
- features.Add((part, codes, isEtch));
- }
-
- // Emit features
- var lastPartName = "";
- for (var i = 0; i < features.Count; i++)
- {
- var (part, codes, isEtch) = features[i];
- var partName = part.BaseDrawing.Name;
- var isFirstFeatureOfPart = partName != lastPartName;
- var isSafetyHeadraise = partName != lastPartName && lastPartName != "";
- var isLastFeature = i == features.Count - 1;
+ var codes = item.Codes;
// SubProgramCall features are emitted as M98 hole calls
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
@@ -317,27 +252,23 @@ public sealed class CincinnatiSheetWriter
isFirstFeatureOfPart ? partName : null,
isSafetyHeadraise
);
- lastPartName = partName;
continue;
}
- var featureNumber = i == 0 ? _config.FeatureLineNumberStart : 1000 + i + 1;
-
- var cutDistance = FeatureUtils.ComputeCutDistance(codes);
-
var ctx = new FeatureContext
{
Codes = codes,
- FeatureNumber = featureNumber,
+ FeatureNumber = FeatureNumber(i),
PartName = partName,
IsFirstFeatureOfPart = isFirstFeatureOfPart,
IsLastFeatureOnSheet = isLastFeature,
IsSafetyHeadraise = isSafetyHeadraise,
- IsExteriorFeature = false,
- IsEtch = isEtch,
- LibraryFile = isEtch ? etchLibrary : cutLibrary,
- CutDistance = cutDistance,
+ IsExteriorFeature = item.IsExterior,
+ IsEtch = item.IsEtch,
+ LibraryFile = item.IsEtch ? etchLibrary : cutLibrary,
+ CutDistance = FeatureUtils.ComputeCutDistance(codes),
SheetDiagonal = sheetDiagonal,
+ RapidDistance = rapidDistance,
PartLocation = part.Location,
UserVariableMapping = userVarMapping,
DrawingId = part.BaseDrawing.Id,
@@ -347,7 +278,59 @@ public sealed class CincinnatiSheetWriter
};
_featureWriter.Write(w, ctx);
- lastPartName = partName;
}
}
+
+ private int FeatureNumber(int index) =>
+ index == 0 ? _config.FeatureLineNumberStart : 1000 + index + 1;
+
+ ///
+ /// Calls a part sub-program in a G92 frame at the part corner (§1.92). After the
+ /// call the head is raised, the tool rapids back to local 0,0 and G92 gives that
+ /// point its sheet coordinates again, so the frame is restored where the tool really
+ /// is. The last part skips the restore: nothing moves before the next sheet
+ /// re-anchors with G92 X#5021 Y#5022.
+ ///
+ private void WriteSubprogramCall(
+ TextWriter w,
+ SheetItem item,
+ int index,
+ string partName,
+ bool isSafetyHeadraise,
+ double? rapidDistance,
+ bool isLastPart
+ )
+ {
+ var x = _fmt.FormatCoord(item.Entry.X);
+ var y = _fmt.FormatCoord(item.Entry.Y);
+
+ // Rapid to the sub-program origin (the part's toolpath corner)
+ var sb = new StringBuilder();
+ if (_config.UseLineNumbers)
+ sb.Append($"N{FeatureNumber(index)} ");
+ sb.Append($"G0 X{x} Y{y}");
+ w.WriteLine(sb.ToString());
+
+ // Part name comment
+ w.WriteLine(CoordinateFormatter.Comment($"PART: {partName}"));
+
+ // Set local coordinate system at part position
+ w.WriteLine("G92 X0 Y0");
+
+ // Call part sub-program
+ w.WriteLine($"M98 P{item.Subprogram.Number} ({partName})");
+
+ if (isLastPart)
+ return;
+
+ // One head raise covers the rapid back and the rapid on. The part is complete,
+ // so the raise follows the exterior M47 mode.
+ var raise = CincinnatiHeadRaise.Line(_config, isSafetyHeadraise, true, rapidDistance);
+ if (raise != null)
+ w.WriteLine(raise);
+
+ // Restore the sheet coordinate system at the corner
+ w.WriteLine("G0 X0 Y0");
+ w.WriteLine($"G92 X{x} Y{y}");
+ }
}
diff --git a/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs b/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs
index 2f20de54..516a389c 100644
--- a/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs
+++ b/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs
@@ -78,17 +78,205 @@ public static class FeatureUtils
///
/// Splits a part's program into features by rapids, classifies each as etch or cut,
- /// and orders etch features before cut features.
+ /// and orders etch features before cut features. The part's program is not modified.
///
public static List<(List codes, bool isEtch)> SplitAndClassify(Part part)
{
- part.Program.Mode = Mode.Absolute;
- var codes = new List(part.Program.Codes);
+ var codes = RemoveSuppressed(AbsoluteCodes(part.Program));
InsertLeadingRapid(codes);
return ClassifyAndOrder(SplitByRapids(codes));
}
+ ///
+ /// Returns 's codes in absolute mode without changing the
+ /// program. Hole calls are the program's own instances, so lookups by hole program
+ /// reference still match; every other code of an incremental program is a copy.
+ ///
+ public static List AbsoluteCodes(Program program)
+ {
+ if (program.Mode == Mode.Absolute)
+ return new List(program.Codes);
+
+ var copy = ToAbsoluteCopy(program);
+ var codes = new List(copy.Codes.Count);
+ for (var i = 0; i < copy.Codes.Count; i++)
+ codes.Add(copy.Codes[i] is SubProgramCall ? program.Codes[i] : copy.Codes[i]);
+ return codes;
+ }
+
+ ///
+ /// Leaves suppressed moves uncut, matching the other posts and the cut simulation.
+ /// Absolute codes only. A cut that resumes after a suppressed run starts with a
+ /// rapid to the run's end, so the gap is crossed with the beam off rather than cut
+ /// as a chord. Rapids left with nothing to cut, and hole calls whose moves are all
+ /// suppressed, are dropped so they cannot post an empty pierce.
+ ///
+ public static List RemoveSuppressed(List codes)
+ {
+ var kept = new List(codes.Count);
+ Vector? resumeAt = null;
+ foreach (var code in codes)
+ {
+ if (code is Motion { Suppressed: true } suppressed)
+ {
+ resumeAt = suppressed.EndPoint;
+ continue;
+ }
+
+ if (code is SubProgramCall call && !HasCut(call.Program))
+ continue;
+
+ if (resumeAt.HasValue && code is Motion and not RapidMove)
+ kept.Add(new RapidMove(resumeAt.Value));
+ resumeAt = null;
+ kept.Add(code);
+ }
+
+ var result = new List(kept.Count);
+ for (var i = 0; i < kept.Count; i++)
+ {
+ if (kept[i] is RapidMove && !CutFollows(kept, i + 1))
+ continue;
+ result.Add(kept[i]);
+ }
+ return result;
+ }
+
+ // True when a cutting move comes before the next rapid or hole call.
+ private static bool CutFollows(List codes, int start)
+ {
+ for (var i = start; i < codes.Count; i++)
+ {
+ if (codes[i] is RapidMove or SubProgramCall)
+ return false;
+ if (codes[i] is Motion)
+ return true;
+ }
+ return false;
+ }
+
+ /// True when has an unsuppressed cutting move.
+ public static bool HasCut(Program program)
+ {
+ if (program == null)
+ return false;
+ foreach (var code in program.Codes)
+ {
+ if (code is Motion { Suppressed: false } and not RapidMove)
+ return true;
+ if (code is SubProgramCall call && HasCut(call.Program))
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// The pierce point of a feature in its program's frame: its first rapid, or for a
+ /// hole call the hole's first pierce shifted to the call offset.
+ ///
+ public static Vector StartPoint(List feature)
+ {
+ if (feature.Count == 1 && feature[0] is SubProgramCall call && call.Program != null)
+ return call.Offset + StartPoint(AbsoluteCodes(call.Program));
+
+ foreach (var code in feature)
+ {
+ if (code is RapidMove rapid)
+ return rapid.EndPoint;
+ }
+ foreach (var code in feature)
+ {
+ if (code is Motion motion)
+ return motion.EndPoint;
+ }
+ return Vector.Zero;
+ }
+
+ /// Where the tool stops at the end of a feature, in its program's frame.
+ public static Vector EndPoint(List feature)
+ {
+ if (feature.Count == 1 && feature[0] is SubProgramCall call && call.Program != null)
+ return call.Offset + EndPoint(AbsoluteCodes(call.Program));
+
+ for (var i = feature.Count - 1; i >= 0; i--)
+ {
+ if (feature[i] is Motion motion)
+ return motion.EndPoint;
+ }
+ return Vector.Zero;
+ }
+
+ ///
+ /// Marks each part's exterior features: cut features whose cut moves (lead moves
+ /// excluded) span the extents of all the part's cut moves. Etch features and hole
+ /// calls are never exterior. A part whose outline is cut in pieces has none.
+ ///
+ public static bool[] FindExterior(IReadOnlyList<(List codes, bool isEtch)> features)
+ {
+ var bounds = new Box[features.Count];
+ Box all = null;
+ for (var i = 0; i < features.Count; i++)
+ {
+ var (codes, isEtch) = features[i];
+ if (isEtch || (codes.Count == 1 && codes[0] is SubProgramCall))
+ continue;
+ bounds[i] = CutBounds(codes);
+ if (bounds[i] != null)
+ all = all == null ? bounds[i] : Union(all, bounds[i]);
+ }
+
+ var result = new bool[features.Count];
+ for (var i = 0; i < features.Count; i++)
+ result[i] = bounds[i] != null && SameExtents(bounds[i], all);
+ return result;
+ }
+
+ private static Box CutBounds(List codes)
+ {
+ var cut = new Program(Mode.Absolute);
+ var position = Vector.Zero;
+ var inCut = false;
+ foreach (var code in codes)
+ {
+ if (code is not Motion motion)
+ continue;
+
+ var layer = motion switch
+ {
+ LinearMove linear => linear.Layer,
+ ArcMove arc => arc.Layer,
+ _ => LayerType.Leadin,
+ };
+ var isCut = motion is not RapidMove && layer is not (LayerType.Leadin or LayerType.Leadout);
+ if (isCut)
+ {
+ if (!inCut)
+ cut.Codes.Add(new RapidMove(position));
+ cut.Codes.Add(motion);
+ }
+ inCut = isCut;
+ position = motion.EndPoint;
+ }
+
+ return cut.Codes.Count == 0 ? null : cut.BoundingBox();
+ }
+
+ private static Box Union(Box a, Box b)
+ {
+ var left = System.Math.Min(a.Left, b.Left);
+ var bottom = System.Math.Min(a.Bottom, b.Bottom);
+ var right = System.Math.Max(a.Right, b.Right);
+ var top = System.Math.Max(a.Top, b.Top);
+ return new Box(left, bottom, right - left, top - bottom);
+ }
+
+ private static bool SameExtents(Box a, Box b) =>
+ a.Left.IsEqualTo(b.Left)
+ && a.Right.IsEqualTo(b.Right)
+ && a.Bottom.IsEqualTo(b.Bottom)
+ && a.Top.IsEqualTo(b.Top);
+
///
/// If the first contour motion has no rapid before it, the feature writer would
/// pierce at that motion's endpoint and lose the first segment. Inserts a synthetic
diff --git a/Posts/OpenNest.Posts.Cincinnati/MaterialLibraryResolver.cs b/Posts/OpenNest.Posts.Cincinnati/MaterialLibraryResolver.cs
index 63fd24cd..4f9b007b 100644
--- a/Posts/OpenNest.Posts.Cincinnati/MaterialLibraryResolver.cs
+++ b/Posts/OpenNest.Posts.Cincinnati/MaterialLibraryResolver.cs
@@ -24,13 +24,26 @@ public sealed class MaterialLibraryResolver
if (!string.IsNullOrEmpty(_selectedLibrary))
return EnsureLibExtension(_selectedLibrary);
- var entry = _materialLibraries.FirstOrDefault(e =>
+ var entry = FindCutEntry(_materialLibraries, materialName, thickness, gas);
+ return EnsureLibExtension(entry?.Library ?? "");
+ }
+
+ ///
+ /// The first cut library entry for this material, thickness (within tolerance) and
+ /// assist gas, or null when none matches.
+ ///
+ public static MaterialLibraryEntry FindCutEntry(
+ IEnumerable entries,
+ string materialName,
+ double thickness,
+ string gas
+ )
+ {
+ return entries?.FirstOrDefault(e =>
string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase)
&& System.Math.Abs(e.Thickness - thickness) <= ThicknessTolerance
&& string.Equals(e.Gas, gas, StringComparison.OrdinalIgnoreCase)
);
-
- return EnsureLibExtension(entry?.Library ?? "");
}
public string ResolveEtchLibrary(string gas)
diff --git a/Posts/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.json b/Posts/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.json
index 692d9983..e16ad7f6 100644
--- a/Posts/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.json
+++ b/Posts/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.json
@@ -4,13 +4,10 @@
"PostedAccuracy": 4,
"UseLineNumbers": true,
"FeatureLineNumberStart": 1,
- "UseSheetSubprograms": true,
"SheetSubprogramStart": 101,
"UsePartSubprograms": false,
"PartSubprogramStart": 200,
"VariableDeclarationSubprogram": 100,
- "CoordModeBetweenParts": "G92",
- "ProcessParameterMode": "LibraryFile",
"DefaultAssistGas": "O2",
"DefaultEtchGas": "N2",
"UseExactStopMode": false,
diff --git a/docs/cincinnati-post-output.md b/docs/cincinnati-post-output.md
index 10c79f44..ab77a575 100644
--- a/docs/cincinnati-post-output.md
+++ b/docs/cincinnati-post-output.md
@@ -37,8 +37,11 @@ A generated file contains, in order:
never reorders them.
4. **Part sub-programs** (`CincinnatiPartSubprogramWriter.Write`), one per
- unique `(drawing, rotation)` pair, only emitted when
- `Config.UsePartSubprograms` is enabled.
+ unique posted part toolpath, only emitted when `Config.UsePartSubprograms`
+ is enabled. Parts share a sub-program only when they have the same drawing
+ name and their normalized programs (lead-ins, hole calls and suppression
+ included) post identically, so parts of one drawing and rotation with
+ different lead-ins get their own sub-programs.
5. **Hole sub-programs** (`CincinnatiPartSubprogramWriter.Write` reused with a
`"HOLE"` label), one per unique posted hole toolpath. Calls are matched to
@@ -49,6 +52,36 @@ A generated file contains, in order:
Sub-program bodies start with a `:` label and end with `M99`.
+The post reads part programs through absolute-mode copies and never changes the
+nest: an incremental part program stays incremental after posting.
+
+### Sheet header
+
+Each sheet sub-program re-anchors the work frame with `G92 X#5021 Y#5022`
+(§1.92; `#5021`/`#5022` are the machine X/Y system variables), loads the cut
+library with `G89 P` (§2.89) when one resolved, and raises the head
+with `M47`. When `Config.UseLineNumbers` is on it then posts
+`GOTO( Goto Feature )` (§5.5), which targets the
+sheet's first feature line; the operator edits it to restart at another
+feature. Without line numbers there is no target, so no `GOTO` is posted.
+
+### Suppressed moves
+
+Moves with `Motion.Suppressed` set are not cut, as in the CI Fiber post and the
+cut simulation. When cutting resumes after a suppressed run, the post ends the
+feature and starts a new one with a rapid to the run's end point, so the gap is
+crossed with the beam off. Rapids left with nothing to cut, and hole calls whose
+moves are all suppressed, are dropped.
+
+### Cut library selection
+
+`MaterialLibraryResolver` picks the first `MaterialLibraries` entry matching the
+nest material, thickness within 0.001 and assist gas (`Nest.AssistGas`, else
+`Config.DefaultAssistGas`). Before the configuration dialog opens,
+`PrepareForNest` pre-selects that same entry as `SelectedLibrary`, or clears the
+selection when nothing matches; it never substitutes another gas or thickness.
+A `SelectedLibrary` the operator then picks overrides auto-resolution.
+
## Feature blocks
A "feature" is a single contour: lead-in → cut moves → lead-out. Each feature
@@ -58,7 +91,8 @@ block in a sheet or sub-program output follows this order
1. `G0 X_ Y_` — rapid to the pierce point (§1.00).
2. Optional part-name comment, only on the first feature of each part.
3. `G89 P` — load process parameters (§2.89). `P` is a library file
- name; the `(...)` trailing comment carries speed-class info.
+ name; the `(...)` trailing comment carries speed-class info. With no
+ resolved library the post writes `(WARNING: No library found)` instead.
4. `G84` (cut) or `G85` (etch / no-pierce) — pierce and start cut, or start
cut without pierce (§2.84 / §2.85).
5. `M130 (ANTI DIVE OFF)` — disable anti-dive, only if configured (§3.130).
@@ -72,13 +106,35 @@ block in a sheet or sub-program output follows this order
7. `G40` — cancel kerf compensation (§1.40), only if it was applied.
8. `M35` (or `M135` if SpeedGas is enabled) — beam off (§3.35 / §3.135).
9. `M131 (ANTI DIVE ON)` — re-enable anti-dive (§3.131).
-10. `M47` or `M47 P` — raise Z-axis, unless this is the last feature
- on the sheet (§3.47). A leading `/` (block delete, §5.6) is prepended when
- the configured override distance exceeds the default.
+10. Head raise before the rapid to the next feature, unless this is the last
+ feature on the sheet (§3.47). See [Head raises](#head-raises-m47).
-Sheet sub-program and sheet-level feature calls add `G92 X#5021 Y#5022`
-(§1.92) at the top so the local origin is anchored to the machine's current
-absolute position (`#5021`/`#5022` are the machine X/Y system variables).
+## Head raises (M47)
+
+Every raise is written at the end of the feature it follows, before the rapid
+that leaves it, and there is exactly one raise per rapid. The block is chosen
+in this order (`CincinnatiHeadRaise`):
+
+1. `M47 P (Safety Headraise)` when the next feature
+ belongs to a part with a different drawing name and the distance is set.
+2. Otherwise the M47 mode for the feature just cut: `ExteriorM47` after a
+ part's exterior, `InteriorM47` after anything else.
+ - `Always`: `M47`.
+ - `BlockDelete`: `/M47`; the operator can skip it with block delete (§5.6).
+ - `Auto`: `M47` when the rapid travel to the next pierce exceeds
+ `M47OverrideDistanceThreshold` (program units), `/M47` when it does not.
+ With no threshold, `Auto` always posts `M47`.
+ - `None`: no raise.
+
+The exterior is the cut feature whose cut moves (lead-in and lead-out moves
+excluded) span the extents of all of the part's cut moves; etch features and
+hole calls are never exterior, and a part whose outline is cut in several
+features has no exterior. The raise after a part sub-program call follows
+`ExteriorM47`. These rules are this project's interpretation of the settings,
+not vendor-documented behavior.
+
+Hole calls (see below) write `M47`, or the safety raise, after `G52 X0 Y0`
+regardless of the M47 mode.
## Sub-program call patterns
@@ -88,24 +144,32 @@ targets a whole-part sub-program or a hole sub-program.
### Part sub-program call (`WriteSubprogramCall`)
Used when `Config.UsePartSubprograms` is enabled. The tool physically rapids
-to the part corner, then G92 sets the current position as the local origin,
-the sub-program executes in its own local coordinate frame, and G92 restores
-the original absolute position after return.
+to the corner of the part's toolpath, G92 sets that position as the local
+origin, and the sub-program executes in its own local coordinate frame. After
+it returns, the tool is wherever the sub-program's last feature ended, so the
+head is raised, the tool rapids back to local `0,0` (the corner) and G92 gives
+that point its sheet coordinates again.
```
-G0 X Y ; rapid to part bounding box corner (§1.00)
-(PART: )
+G0 X Y ; rapid to the toolpath corner (§1.00)
+( PART: )
G92 X0 Y0 ; set local origin at current position (§1.92)
M98 P () ; call the part sub-program (§3.98)
+M47 ; head raise for the rapids that follow (§3.47)
+G0 X0 Y0 ; back to the corner (§1.00)
G92 X Y ; restore the sheet coordinate system (§1.92)
-M47 ; head raise unless this is the last part (§3.47)
```
-This pattern uses G92 because the tool is physically positioned at the part
-corner first. The sub-program's coordinates are part-local, so they are
-interpreted against the new origin until G92 restores the sheet frame.
+The last part on a sheet stops after `M98`: nothing moves before the next
+sheet re-anchors with `G92 X#5021 Y#5022`. The sub-program's last feature
+posts no raise, so the raise after the call is the only one before the rapids,
+chosen by the [head raise](#head-raises-m47) rules.
-### Hole sub-program call (`WriteHoleSubprogramCall`)
+This pattern uses G92 rather than G52 because hole calls inside a part
+sub-program use G52 themselves, and a nested G52 would replace the part shift
+instead of adding to it.
+
+### Hole sub-program call (`CincinnatiHoleCallWriter.Write`)
Used for the `SubProgramCall` codes that a `ContourCuttingStrategy` emits for
each circular hole. Unlike parts, we do **not** want a physical rapid to the