From 52ad5b4575fbab565a12c62f90f7192e415562ac Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 2 Apr 2026 10:16:15 -0400 Subject: [PATCH] feat: Cincinnati post emits user variables as numbered #variables When programs have user-defined variables, the Cincinnati post now: - Assigns numbered machine variables (#200, #201, etc.) to non-inline variables - Emits declarations like #200=48.0 (SHEET WIDTH) in the variable declaration subprogram - Emits X#200 instead of X48.0 in coordinates that have VariableRefs - Handles global variables (shared number across drawings) vs local (per-drawing number) - Inline variables emit the literal value as before Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CincinnatiFeatureWriter.cs | 38 ++++- .../CincinnatiPostConfig.cs | 5 + .../CincinnatiPostProcessor.cs | 112 +++++++++++++- .../CincinnatiSheetWriter.cs | 21 ++- .../Cincinnati/UserVariablePostTests.cs | 142 ++++++++++++++++++ 5 files changed, 303 insertions(+), 15 deletions(-) create mode 100644 OpenNest.Tests/Cincinnati/UserVariablePostTests.cs diff --git a/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs b/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs index f2885cb..18ff323 100644 --- a/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs +++ b/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs @@ -29,6 +29,17 @@ public sealed class FeatureContext /// so part-relative programs become plate-absolute under G90. /// public Vector PartLocation { get; set; } = Vector.Zero; + + /// + /// Maps (drawingId, variableName) to assigned machine variable numbers. + /// Used to emit #number references instead of literal values for user variables. + /// + public Dictionary<(int drawingId, string varName), int> UserVariableMapping { get; set; } + + /// + /// The drawing ID for the current part, used to look up user variable mappings. + /// + public int DrawingId { get; set; } } /// @@ -112,7 +123,9 @@ public sealed class CincinnatiFeatureWriter kerfEmitted = true; } - sb.Append($"G1 X{_fmt.FormatCoord(linear.EndPoint.X + offset.X)} Y{_fmt.FormatCoord(linear.EndPoint.Y + offset.Y)}"); + var xCoord = FormatCoordWithVars(linear.EndPoint.X + offset.X, "X", linear.VariableRefs, ctx); + var yCoord = FormatCoordWithVars(linear.EndPoint.Y + offset.Y, "Y", linear.VariableRefs, ctx); + sb.Append($"G1 X{xCoord} Y{yCoord}"); // Feedrate — etch always uses process feedrate var feedVar = ctx.IsEtch ? "#148" : GetLinearFeedVariable(linear.Layer); @@ -138,7 +151,9 @@ public sealed class CincinnatiFeatureWriter // G2 = CW, G3 = CCW var gCode = arc.Rotation == RotationType.CW ? "G2" : "G3"; - sb.Append($"{gCode} X{_fmt.FormatCoord(arc.EndPoint.X + offset.X)} Y{_fmt.FormatCoord(arc.EndPoint.Y + offset.Y)}"); + var xCoord = FormatCoordWithVars(arc.EndPoint.X + offset.X, "X", arc.VariableRefs, ctx); + var yCoord = FormatCoordWithVars(arc.EndPoint.Y + offset.Y, "Y", arc.VariableRefs, ctx); + sb.Append($"{gCode} X{xCoord} Y{yCoord}"); // Convert absolute center to incremental I/J var i = arc.CenterPoint.X - currentPos.X; @@ -177,6 +192,25 @@ public sealed class CincinnatiFeatureWriter WriteM47(writer, ctx); } + /// + /// Formats a coordinate value, using a #number variable reference if the motion + /// has a VariableRef for this axis and the variable is mapped (non-inline). + /// Inline variables fall through to literal formatting. + /// + private string FormatCoordWithVars(double value, string axis, + Dictionary variableRefs, FeatureContext ctx) + { + if (variableRefs != null + && variableRefs.TryGetValue(axis, out var varName) + && ctx.UserVariableMapping != null + && ctx.UserVariableMapping.TryGetValue((ctx.DrawingId, varName), out var varNum)) + { + return $"#{varNum}"; + } + + return _fmt.FormatCoord(value); + } + private Vector FindPiercePoint(List codes) { foreach (var code in codes) diff --git a/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs b/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs index 1b595da..d8ecf21 100644 --- a/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs +++ b/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs @@ -253,6 +253,11 @@ namespace OpenNest.Posts.Cincinnati new() { MaxRadius = 4.500, FeedratePercent = 0.80, VariableNumber = 125 } }; + [Category("A. Variables")] + [DisplayName("User Variable Start")] + [Description("Starting variable number for user-defined variables (#200, #201, etc.).")] + public int UserVariableStart { get; set; } = 200; + [Category("A. Variables")] [DisplayName("Sheet Width Variable")] [Description("Variable number for sheet width.")] diff --git a/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs b/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs index 4f50a33..f64a0f5 100644 --- a/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs +++ b/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs @@ -70,7 +70,10 @@ namespace OpenNest.Posts.Cincinnati .Where(p => p.Parts.Count > 0) .ToList(); - // 3. Resolve gas and library files + // 3. Register user variables from drawing programs + var userVarMapping = RegisterUserVariables(vars, plates); + + // 4. Resolve gas and library files var resolver = new MaterialLibraryResolver(Config); var gas = MaterialLibraryResolver.ResolveGas(nest, Config); var etchLibrary = resolver.ResolveEtchLibrary(Config.DefaultEtchGas); @@ -79,24 +82,24 @@ namespace OpenNest.Posts.Cincinnati var firstPlate = plates.FirstOrDefault(); var initialCutLibrary = resolver.ResolveCutLibrary(nest.Material?.Name ?? "", nest.Thickness, gas); - // 4. Build part sub-program registry (if enabled) + // 5. Build part sub-program registry (if enabled) Dictionary<(int, long), int> partSubprograms = null; List<(int subNum, string name, Program program)> subprogramEntries = null; if (Config.UsePartSubprograms) (partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart); - // 5. Create writers + // 6. Create writers var preamble = new CincinnatiPreambleWriter(Config); var sheetWriter = new CincinnatiSheetWriter(Config, vars); - // 6. Build material description from nest + // 7. Build material description from nest var material = nest.Material; var materialDesc = material != null ? $"{material.Name}{(string.IsNullOrEmpty(material.Grade) ? "" : $", {material.Grade}")}" : ""; - // 7. Write to stream + // 8. Write to stream using var writer = new StreamWriter(outputStream, Encoding.UTF8, 1024, leaveOpen: true); // Main program @@ -114,7 +117,7 @@ namespace OpenNest.Posts.Cincinnati var cutLibrary = resolver.ResolveCutLibrary(nest.Material?.Name ?? "", nest.Thickness, gas); var isLastSheet = i == plates.Count - 1; sheetWriter.Write(writer, plate, nest.Name ?? "NEST", sheetIndex, subNumber, - cutLibrary, etchLibrary, partSubprograms, isLastSheet); + cutLibrary, etchLibrary, partSubprograms, isLastSheet, userVarMapping); } // Part sub-programs (if enabled) @@ -142,6 +145,103 @@ namespace OpenNest.Posts.Cincinnati Post(nest, fs); } + private Dictionary<(int drawingId, string varName), int> RegisterUserVariables( + ProgramVariableManager vars, List plates) + { + var mapping = new Dictionary<(int drawingId, string varName), int>(); + var nextNumber = Config.UserVariableStart; + + // Track global variables by name so they share a single number + var globalNumbers = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + + // Collect unique drawings from all plates + var seenDrawings = new HashSet(); + foreach (var plate in plates) + { + foreach (var part in plate.Parts) + { + var drawing = part.BaseDrawing; + if (drawing.IsCutOff || !seenDrawings.Add(drawing.Id)) + continue; + + foreach (var kvp in drawing.Program.Variables) + { + var varDef = kvp.Value; + + // Skip inline variables — they emit literal values + if (varDef.Inline) + continue; + + if (varDef.Global) + { + if (!globalNumbers.TryGetValue(varDef.Name, out var globalNum)) + { + globalNum = nextNumber++; + globalNumbers[varDef.Name] = globalNum; + + // Register once in the variable manager + var commentName = ToPascalCase(varDef.Name); + var expression = FormatVariableValue(varDef.Value); + vars.GetOrCreate(commentName, globalNum, expression); + } + + mapping[(drawing.Id, varDef.Name)] = globalNum; + } + else + { + var num = nextNumber++; + mapping[(drawing.Id, varDef.Name)] = num; + + // Register with drawing name prefix in the comment + var drawingLabel = ToPascalCase(drawing.Name); + var varLabel = ToPascalCase(varDef.Name); + var commentName = $"{drawingLabel}{varLabel}"; + var expression = FormatVariableValue(varDef.Value); + vars.GetOrCreate(commentName, num, expression); + } + } + } + } + + return mapping; + } + + /// + /// Converts a variable name from snake_case or camelCase to PascalCase. + /// Examples: "sheet_width" → "SheetWidth", "holeSpacing" → "HoleSpacing" + /// + private static string ToPascalCase(string name) + { + var sb = new StringBuilder(name.Length); + var capitalizeNext = true; + + foreach (var c in name) + { + if (c == '_') + { + capitalizeNext = true; + continue; + } + + if (capitalizeNext) + { + sb.Append(char.ToUpper(c)); + capitalizeNext = false; + } + else + { + sb.Append(c); + } + } + + return sb.ToString(); + } + + private static string FormatVariableValue(double value) + { + return value.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture); + } + private ProgramVariableManager CreateVariableManager() { var vars = new ProgramVariableManager(); diff --git a/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs b/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs index 6a18ef2..87b5449 100644 --- a/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs +++ b/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs @@ -38,7 +38,8 @@ public sealed class CincinnatiSheetWriter public void Write(TextWriter w, Plate plate, string nestName, int sheetIndex, int subNumber, string cutLibrary, string etchLibrary, Dictionary<(int, long), int> partSubprograms = null, - bool isLastSheet = false) + bool isLastSheet = false, + Dictionary<(int drawingId, string varName), int> userVarMapping = null) { if (plate.Parts.Count == 0) return; @@ -88,9 +89,9 @@ public sealed class CincinnatiSheetWriter // 4. Emit parts if (partSubprograms != null) - WritePartsWithSubprograms(w, allParts, cutLibrary, etchLibrary, sheetDiagonal, partSubprograms); + WritePartsWithSubprograms(w, allParts, cutLibrary, etchLibrary, sheetDiagonal, partSubprograms, userVarMapping); else - WritePartsInline(w, allParts, cutLibrary, etchLibrary, sheetDiagonal); + WritePartsInline(w, allParts, cutLibrary, etchLibrary, sheetDiagonal, userVarMapping); // 5. Footer w.WriteLine("M42"); @@ -104,7 +105,8 @@ public sealed class CincinnatiSheetWriter private void WritePartsWithSubprograms(TextWriter w, List allParts, string cutLibrary, string etchLibrary, double sheetDiagonal, - Dictionary<(int, long), int> partSubprograms) + Dictionary<(int, long), int> partSubprograms, + Dictionary<(int drawingId, string varName), int> userVarMapping) { var lastPartName = ""; var featureIndex = 0; @@ -154,7 +156,9 @@ public sealed class CincinnatiSheetWriter LibraryFile = isEtch ? etchLibrary : cutLibrary, CutDistance = cutDistance, SheetDiagonal = sheetDiagonal, - PartLocation = part.Location + PartLocation = part.Location, + UserVariableMapping = userVarMapping, + DrawingId = part.BaseDrawing.Id }; _featureWriter.Write(w, ctx); @@ -202,7 +206,8 @@ public sealed class CincinnatiSheetWriter } private void WritePartsInline(TextWriter w, List allParts, - string cutLibrary, string etchLibrary, double sheetDiagonal) + string cutLibrary, string etchLibrary, double sheetDiagonal, + 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)>(); @@ -242,7 +247,9 @@ public sealed class CincinnatiSheetWriter LibraryFile = isEtch ? etchLibrary : cutLibrary, CutDistance = cutDistance, SheetDiagonal = sheetDiagonal, - PartLocation = part.Location + PartLocation = part.Location, + UserVariableMapping = userVarMapping, + DrawingId = part.BaseDrawing.Id }; _featureWriter.Write(w, ctx); diff --git a/OpenNest.Tests/Cincinnati/UserVariablePostTests.cs b/OpenNest.Tests/Cincinnati/UserVariablePostTests.cs new file mode 100644 index 0000000..cb72170 --- /dev/null +++ b/OpenNest.Tests/Cincinnati/UserVariablePostTests.cs @@ -0,0 +1,142 @@ +using System.IO; +using System.Linq; +using System.Text; +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.IO; +using OpenNest.Posts.Cincinnati; + +namespace OpenNest.Tests.Cincinnati; + +public class UserVariablePostTests +{ + [Fact] + public void UserVariables_EmittedInDeclarationSubprogram() + { + var output = PostNestWithVariables("width = 48.0\nG90\nG01X$widthY0"); + + Assert.Contains("#200=48", output); + Assert.Contains("WIDTH", output.ToUpper()); + } + + [Fact] + public void UserVariables_InlineVariable_NotEmittedAsNumbered() + { + var output = PostNestWithVariables("kerf = 0.06 inline\nG90\nG01X1Y0"); + + Assert.DoesNotContain("#200", output); + } + + [Fact] + public void UserVariables_CoordinateUsesNumberedVariable() + { + var output = PostNestWithVariables("width = 48.0\nG90\nG01X$widthY0"); + + Assert.Contains("X#200", output); + } + + [Fact] + public void UserVariables_InlineVariable_CoordinateUsesLiteral() + { + var output = PostNestWithVariables("kerf = 0.06 inline\nG90\nG01X$kerfY0"); + + Assert.Contains("X0.06", output); + // G1 coordinate lines should not use X#nnn variable references for inline vars + var g1Lines = output.Split('\n').Where(l => l.TrimStart().StartsWith("G1 ")).ToList(); + Assert.All(g1Lines, line => Assert.DoesNotContain("X#", line)); + } + + [Fact] + public void UserVariables_GlobalVariables_SharedAcrossDrawings() + { + var pgm1 = ParseProgram("sheet_width = 48.0 global\nG90\nG01X$sheet_widthY0"); + var pgm2 = ParseProgram("sheet_width = 48.0 global\nG90\nG01X$sheet_widthY0"); + + var drawing1 = new Drawing("Part1", pgm1); + var drawing2 = new Drawing("Part2", pgm2); + var nest = new Nest { Name = "Test" }; + nest.Drawings.Add(drawing1); + nest.Drawings.Add(drawing2); + var plate = new Plate(new Size(100, 100)); + plate.Parts.Add(new Part(drawing1, new Vector(0, 0))); + plate.Parts.Add(new Part(drawing2, new Vector(50, 0))); + nest.Plates.Add(plate); + + var config = new CincinnatiPostConfig { UserVariableStart = 200 }; + var post = new CincinnatiPostProcessor(config); + var output = PostToString(post, nest); + + // Both should use the same #200 — only one declaration + var declarationCount = output.Split('\n') + .Count(l => l.Contains("#200=") && l.ToUpper().Contains("SHEET WIDTH")); + Assert.Equal(1, declarationCount); + } + + [Fact] + public void UserVariables_LocalVariables_GetSeparateNumbers() + { + var pgm1 = ParseProgram("diameter = 0.3\nG90\nG01X$diameterY0"); + var pgm2 = ParseProgram("diameter = 0.5\nG90\nG01X$diameterY0"); + + var drawing1 = new Drawing("TubeA", pgm1); + var drawing2 = new Drawing("TubeB", pgm2); + var nest = new Nest { Name = "Test" }; + nest.Drawings.Add(drawing1); + nest.Drawings.Add(drawing2); + var plate = new Plate(new Size(100, 100)); + plate.Parts.Add(new Part(drawing1, new Vector(0, 0))); + plate.Parts.Add(new Part(drawing2, new Vector(50, 0))); + nest.Plates.Add(plate); + + var config = new CincinnatiPostConfig { UserVariableStart = 200 }; + var post = new CincinnatiPostProcessor(config); + var output = PostToString(post, nest); + + // Two separate declarations with different numbers + Assert.Contains("#200=0.3", output); + Assert.Contains("#201=0.5", output); + Assert.Contains("TUBE A", output.ToUpper()); + Assert.Contains("TUBE B", output.ToUpper()); + } + + [Fact] + public void UserVariables_StartNumberConfigurable() + { + var config = new CincinnatiPostConfig { UserVariableStart = 300 }; + var output = PostNestWithVariables("width = 48.0\nG90\nG01X$widthY0", config); + + Assert.Contains("#300=48", output); + } + + private static string PostNestWithVariables(string gcode, CincinnatiPostConfig config = null) + { + var program = ParseProgram(gcode); + var drawing = new Drawing("TestPart", program); + var nest = new Nest { Name = "Test" }; + nest.Drawings.Add(drawing); + var plate = new Plate(new Size(100, 100)); + plate.Parts.Add(new Part(drawing, new Vector(0, 0))); + nest.Plates.Add(plate); + + config ??= new CincinnatiPostConfig { UserVariableStart = 200 }; + var post = new CincinnatiPostProcessor(config); + return PostToString(post, nest); + } + + private static string PostToString(CincinnatiPostProcessor post, Nest nest) + { + var ms = new MemoryStream(); + post.Post(nest, ms); + ms.Position = 0; + return new StreamReader(ms).ReadToEnd(); + } + + private static Program ParseProgram(string gcode) + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes(gcode)); + var reader = new ProgramReader(stream); + var program = reader.Read(); + reader.Close(); + return program; + } +}