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;
+ }
+}