fix(posts): Cincinnati CL sub-program frames, head raises and dead settings

Part sub-program calls restored the sheet frame with G92 at the part
corner while the tool sat at the sub-program's last endpoint, shifting
every later part. The call now raises the head, rapids back to local 0,0
and restores there. Parts share a sub-program only when their posted
toolpaths match, so per-part lead-ins are kept.

Suppressed moves are left uncut: the cut resumes with a rapid past them.

Head raises go before the rapid that leaves a feature, one per rapid:
the safety raise now precedes the rapid to a new part instead of
following its first feature, and sub-program mode no longer posts M47
then M47 P2000. M47Mode.Auto posts M47 before rapids longer than
M47OverrideDistanceThreshold and /M47 otherwise. ExteriorM47 applies to
the contour spanning the part's cut extents.

The sheet GOTO targets FeatureLineNumberStart and is omitted without line
numbers. PrepareForNest pre-selects only the gas- and thickness-matching
library. Part programs are read through absolute copies, never switched
to absolute in place.

Removes the unimplemented UseSheetSubprograms, CoordModeBetweenParts and
ProcessParameterMode (G89Mode); old configs still load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-19 01:56:28 -04:00
1 parent 5dc13a03ae
commit 2b3f8e7b52
16 files changed
+1203 -544

No files matched your search

@@ -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);
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<CincinnatiPostConfig>(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> 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<string, Drawing>();
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<string> 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<Vector> expected, IReadOnlyList<Vector> 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);
}
}
@@ -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;
/// <summary>
/// Posts nests with the Cincinnati CL post and replays the G-code to see where the
/// machine actually cuts.
/// </summary>
internal static class CincinnatiReplay
{
/// <summary>A config whose output is easy to replay: no anti-dive, no pallet exchange.</summary>
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; }
/// <summary>Every pierce with the head raise posted before it (null for none).</summary>
public List<(Vector Point, string? Raise)> PierceLog { get; } = new();
/// <summary>Every cutting move, start to end, in machine coordinates.</summary>
public List<(Vector From, Vector To)> Cuts { get; } = new();
/// <summary>Head raises posted while the head was already raised.</summary>
public int DoubleRaises { get; set; }
/// <summary>Rapids taken after a cut with no head raise since.</summary>
public int HeadDownRapids { get; set; }
}
/// <summary>
/// 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.
/// </summary>
internal static Result Run(string output)
{
var lines = output.Split('\n').Select(Clean).ToList();
var labels = new Dictionary<int, int>();
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<int>();
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<string, string> Words(string line)
{
var words = new Dictionary<string, string>();
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);
}
@@ -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<IOException>(() => 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<Part, PartSubprogram>? 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<string>
{
"(*****************************************************)",
"( 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.
@@ -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<SubProgramCall>(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<SubProgramCall>().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; }
}
/// <summary>
/// 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.
/// </summary>
private static Replayed Replay(string output)
{
var lines = output.Split('\n').Select(Clean).ToList();
var labels = new Dictionary<int, int>();
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<int>();
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<string, string> Words(string line)
{
var words = new Dictionary<string, string>();
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);
}
@@ -17,13 +17,26 @@ public sealed class FeatureContext
public string PartName { get; set; } = "";
public bool IsFirstFeatureOfPart { get; set; }
public bool IsLastFeatureOnSheet { get; set; }
/// <summary>
/// True when the rapid after this feature goes to a different part, so its head
/// raise is the safety head raise.
/// </summary>
public bool IsSafetyHeadraise { get; set; }
/// <summary>True when this feature cuts the part's exterior (selects ExteriorM47).</summary>
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; }
/// <summary>
/// Rapid travel from this feature's end to the next pierce, used by
/// <see cref="M47Mode.Auto"/>. Null when unknown.
/// </summary>
public double? RapidDistance { get; set; }
/// <summary>
/// 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)
@@ -0,0 +1,37 @@
namespace OpenNest.Posts.Cincinnati;
/// <summary>
/// Chooses the M47 head raise (§3.47) written before the rapid that leaves a feature.
/// </summary>
internal static class CincinnatiHeadRaise
{
/// <param name="isSafetyHeadraise">True when the rapid goes to a different part.</param>
/// <param name="isExterior">True when the feature just cut is a part exterior.</param>
/// <param name="rapidDistance">Travel to the next pierce; null when unknown.</param>
/// <returns>The block to write, or null for no head raise.</returns>
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;
}
@@ -7,10 +7,18 @@ using OpenNest.Geometry;
namespace OpenNest.Posts.Cincinnati;
/// <summary>
/// A posted part sub-program and where one part's program frame puts its origin.
/// </summary>
/// <param name="Number">Posted sub-program number.</param>
/// <param name="Program">The normalized, absolute program the sub-program is written from.</param>
/// <param name="Corner">The sub-program origin in the part's program frame.</param>
public sealed record PartSubprogram(int Number, Program Program, Vector Corner);
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// Creates a sub-program key for matching parts to their sub-programs.
/// </summary>
internal static (int drawingId, long rotationKey) SubprogramKey(Part part) =>
(part.BaseDrawing.Id, (long)System.Math.Round(part.Rotation * 1e6));
/// <summary>
/// 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.
/// </summary>
internal static (
Dictionary<(int, long), int> mapping,
Dictionary<Part, PartSubprogram> mapping,
List<(int subNum, string name, Program program)> entries
) BuildRegistry(IEnumerable<Plate> plates, int startNumber)
{
var mapping = new Dictionary<(int, long), int>();
var mapping = new Dictionary<Part, PartSubprogram>(ReferenceEqualityComparer.Instance);
var entries = new List<(int, string, Program)>();
var contentIndex = new Dictionary<string, (int subNum, Program program)>();
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;
}
/// <summary>
/// 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();
}
@@ -5,33 +5,6 @@ using System.Linq;
namespace OpenNest.Posts.Cincinnati
{
/// <summary>
/// Specifies how coordinate positioning is handled between parts.
/// </summary>
public enum CoordinateMode
{
/// <summary>Set absolute position.</summary>
G92,
/// <summary>Use relative/incremental positioning.</summary>
G91,
/// <summary>Use machine coordinate system.</summary>
G53,
}
/// <summary>
/// Specifies how G89 (hole drilling/tapping parameters) are provided.
/// </summary>
public enum G89Mode
{
/// <summary>Use external library file for G89 parameters.</summary>
LibraryFile,
/// <summary>Explicitly define G89 parameters in the program.</summary>
Explicit,
}
/// <summary>
/// Specifies where kerf compensation is applied.
/// </summary>
@@ -57,17 +30,20 @@ namespace OpenNest.Posts.Cincinnati
}
/// <summary>
/// Specifies how M47 (optional stop) commands are used.
/// Specifies how the M47 head raise before each rapid is posted.
/// </summary>
public enum M47Mode
{
/// <summary>Always include M47.</summary>
Always,
/// <summary>Include M47 with block delete functionality.</summary>
/// <summary>Post /M47, which the operator can skip with block delete.</summary>
BlockDelete,
/// <summary>Automatically determine M47 placement.</summary>
/// <summary>
/// M47 before rapids longer than the M47 override distance threshold, /M47 before
/// shorter ones. Without a threshold every raise is M47.
/// </summary>
Auto,
/// <summary>Do not use M47.</summary>
@@ -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<distance> 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
@@ -38,11 +38,24 @@ namespace OpenNest.Posts.Cincinnati
.Where(s => !string.IsNullOrWhiteSpace(s));
}
/// <summary>
/// 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.
/// </summary>
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<Part, PartSubprogram> partSubprograms = null;
List<(int subNum, string name, Program program)> subprogramEntries = null;
if (Config.UsePartSubprograms)
@@ -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)");
@@ -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
/// <param name="cutLibrary">Resolved G89 library file for cut operations.</param>
/// <param name="etchLibrary">Resolved G89 library file for etch operations.</param>
/// <param name="partSubprograms">
/// 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.
/// </param>
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<Part, PartSubprogram> 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(
/// <summary>
/// One posted step of the sheet: a part feature, a hole call or a part sub-program
/// call. Points are in sheet coordinates.
/// </summary>
private sealed class SheetItem
{
public Part Part { get; init; }
public List<ICode> Codes { get; init; }
public bool IsEtch { get; init; }
public bool IsExterior { get; init; }
public PartSubprogram Subprogram { get; init; }
/// <summary>First rapid target: the sub-program corner, else the first pierce.</summary>
public Vector Entry { get; init; }
public Vector Start { get; init; }
public Vector End { get; init; }
/// <summary>Where the tool leaves from: back at the corner after a sub-program call.</summary>
public Vector Exit { get; init; }
}
private static List<SheetItem> BuildItems(
IEnumerable<Part> parts,
IReadOnlyDictionary<Part, PartSubprogram> partSubprograms
)
{
var items = new List<SheetItem>();
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<Part> allParts,
List<SheetItem> 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<Part> 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<ICode> 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;
/// <summary>
/// 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.
/// </summary>
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}");
}
}
+191 -3
View File
@@ -78,17 +78,205 @@ public static class FeatureUtils
/// <summary>
/// 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.
/// </summary>
public static List<(List<ICode> codes, bool isEtch)> SplitAndClassify(Part part)
{
part.Program.Mode = Mode.Absolute;
var codes = new List<ICode>(part.Program.Codes);
var codes = RemoveSuppressed(AbsoluteCodes(part.Program));
InsertLeadingRapid(codes);
return ClassifyAndOrder(SplitByRapids(codes));
}
/// <summary>
/// Returns <paramref name="program"/>'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.
/// </summary>
public static List<ICode> AbsoluteCodes(Program program)
{
if (program.Mode == Mode.Absolute)
return new List<ICode>(program.Codes);
var copy = ToAbsoluteCopy(program);
var codes = new List<ICode>(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;
}
/// <summary>
/// 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.
/// </summary>
public static List<ICode> RemoveSuppressed(List<ICode> codes)
{
var kept = new List<ICode>(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<ICode>(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<ICode> 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;
}
/// <summary>True when <paramref name="program"/> has an unsuppressed cutting move.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public static Vector StartPoint(List<ICode> 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;
}
/// <summary>Where the tool stops at the end of a feature, in its program's frame.</summary>
public static Vector EndPoint(List<ICode> 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;
}
/// <summary>
/// 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.
/// </summary>
public static bool[] FindExterior(IReadOnlyList<(List<ICode> 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<ICode> 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);
/// <summary>
/// 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
@@ -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 ?? "");
}
/// <summary>
/// The first cut library entry for this material, thickness (within tolerance) and
/// assist gas, or null when none matches.
/// </summary>
public static MaterialLibraryEntry FindCutEntry(
IEnumerable<MaterialLibraryEntry> 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)
@@ -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,
+83 -19
View File
@@ -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 `:<subNum>` 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<library>` (§2.89) when one resolved, and raises the head
with `M47`. When `Config.UseLineNumbers` is on it then posts
`GOTO<FeatureLineNumberStart>( 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<library>` — 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<distance>` — 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<SafetyHeadraiseDistance> (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<left> Y<bottom> ; rapid to part bounding box corner (§1.00)
(PART: <name>)
G0 X<left> Y<bottom> ; rapid to the toolpath corner (§1.00)
( PART: <name> )
G92 X0 Y0 ; set local origin at current position (§1.92)
M98 P<partSubNum> (<name>) ; 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<left> Y<bottom> ; 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