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) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 10:16:15 -04:00
parent 7416f8ae3f
commit 52ad5b4575
5 changed files with 303 additions and 15 deletions
@@ -29,6 +29,17 @@ public sealed class FeatureContext
/// so part-relative programs become plate-absolute under G90. /// so part-relative programs become plate-absolute under G90.
/// </summary> /// </summary>
public Vector PartLocation { get; set; } = Vector.Zero; public Vector PartLocation { get; set; } = Vector.Zero;
/// <summary>
/// Maps (drawingId, variableName) to assigned machine variable numbers.
/// Used to emit #number references instead of literal values for user variables.
/// </summary>
public Dictionary<(int drawingId, string varName), int> UserVariableMapping { get; set; }
/// <summary>
/// The drawing ID for the current part, used to look up user variable mappings.
/// </summary>
public int DrawingId { get; set; }
} }
/// <summary> /// <summary>
@@ -112,7 +123,9 @@ public sealed class CincinnatiFeatureWriter
kerfEmitted = true; 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 // Feedrate — etch always uses process feedrate
var feedVar = ctx.IsEtch ? "#148" : GetLinearFeedVariable(linear.Layer); var feedVar = ctx.IsEtch ? "#148" : GetLinearFeedVariable(linear.Layer);
@@ -138,7 +151,9 @@ public sealed class CincinnatiFeatureWriter
// G2 = CW, G3 = CCW // G2 = CW, G3 = CCW
var gCode = arc.Rotation == RotationType.CW ? "G2" : "G3"; 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 // Convert absolute center to incremental I/J
var i = arc.CenterPoint.X - currentPos.X; var i = arc.CenterPoint.X - currentPos.X;
@@ -177,6 +192,25 @@ public sealed class CincinnatiFeatureWriter
WriteM47(writer, ctx); WriteM47(writer, ctx);
} }
/// <summary>
/// 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.
/// </summary>
private string FormatCoordWithVars(double value, string axis,
Dictionary<string, string> 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<ICode> codes) private Vector FindPiercePoint(List<ICode> codes)
{ {
foreach (var code in codes) foreach (var code in codes)
@@ -253,6 +253,11 @@ namespace OpenNest.Posts.Cincinnati
new() { MaxRadius = 4.500, FeedratePercent = 0.80, VariableNumber = 125 } 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")] [Category("A. Variables")]
[DisplayName("Sheet Width Variable")] [DisplayName("Sheet Width Variable")]
[Description("Variable number for sheet width.")] [Description("Variable number for sheet width.")]
@@ -70,7 +70,10 @@ namespace OpenNest.Posts.Cincinnati
.Where(p => p.Parts.Count > 0) .Where(p => p.Parts.Count > 0)
.ToList(); .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 resolver = new MaterialLibraryResolver(Config);
var gas = MaterialLibraryResolver.ResolveGas(nest, Config); var gas = MaterialLibraryResolver.ResolveGas(nest, Config);
var etchLibrary = resolver.ResolveEtchLibrary(Config.DefaultEtchGas); var etchLibrary = resolver.ResolveEtchLibrary(Config.DefaultEtchGas);
@@ -79,24 +82,24 @@ namespace OpenNest.Posts.Cincinnati
var firstPlate = plates.FirstOrDefault(); var firstPlate = plates.FirstOrDefault();
var initialCutLibrary = resolver.ResolveCutLibrary(nest.Material?.Name ?? "", nest.Thickness, gas); 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; Dictionary<(int, long), int> partSubprograms = null;
List<(int subNum, string name, Program program)> subprogramEntries = null; List<(int subNum, string name, Program program)> subprogramEntries = null;
if (Config.UsePartSubprograms) if (Config.UsePartSubprograms)
(partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart); (partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart);
// 5. Create writers // 6. Create writers
var preamble = new CincinnatiPreambleWriter(Config); var preamble = new CincinnatiPreambleWriter(Config);
var sheetWriter = new CincinnatiSheetWriter(Config, vars); var sheetWriter = new CincinnatiSheetWriter(Config, vars);
// 6. Build material description from nest // 7. Build material description from nest
var material = nest.Material; var material = nest.Material;
var materialDesc = material != null var materialDesc = material != null
? $"{material.Name}{(string.IsNullOrEmpty(material.Grade) ? "" : $", {material.Grade}")}" ? $"{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); using var writer = new StreamWriter(outputStream, Encoding.UTF8, 1024, leaveOpen: true);
// Main program // Main program
@@ -114,7 +117,7 @@ namespace OpenNest.Posts.Cincinnati
var cutLibrary = resolver.ResolveCutLibrary(nest.Material?.Name ?? "", nest.Thickness, gas); var cutLibrary = resolver.ResolveCutLibrary(nest.Material?.Name ?? "", nest.Thickness, gas);
var isLastSheet = i == plates.Count - 1; var isLastSheet = i == plates.Count - 1;
sheetWriter.Write(writer, plate, nest.Name ?? "NEST", sheetIndex, subNumber, sheetWriter.Write(writer, plate, nest.Name ?? "NEST", sheetIndex, subNumber,
cutLibrary, etchLibrary, partSubprograms, isLastSheet); cutLibrary, etchLibrary, partSubprograms, isLastSheet, userVarMapping);
} }
// Part sub-programs (if enabled) // Part sub-programs (if enabled)
@@ -142,6 +145,103 @@ namespace OpenNest.Posts.Cincinnati
Post(nest, fs); Post(nest, fs);
} }
private Dictionary<(int drawingId, string varName), int> RegisterUserVariables(
ProgramVariableManager vars, List<Plate> 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<string, int>(System.StringComparer.OrdinalIgnoreCase);
// Collect unique drawings from all plates
var seenDrawings = new HashSet<int>();
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;
}
/// <summary>
/// Converts a variable name from snake_case or camelCase to PascalCase.
/// Examples: "sheet_width" → "SheetWidth", "holeSpacing" → "HoleSpacing"
/// </summary>
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() private ProgramVariableManager CreateVariableManager()
{ {
var vars = new ProgramVariableManager(); var vars = new ProgramVariableManager();
@@ -38,7 +38,8 @@ public sealed class CincinnatiSheetWriter
public void Write(TextWriter w, Plate plate, string nestName, int sheetIndex, int subNumber, public void Write(TextWriter w, Plate plate, string nestName, int sheetIndex, int subNumber,
string cutLibrary, string etchLibrary, string cutLibrary, string etchLibrary,
Dictionary<(int, long), int> partSubprograms = null, 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) if (plate.Parts.Count == 0)
return; return;
@@ -88,9 +89,9 @@ public sealed class CincinnatiSheetWriter
// 4. Emit parts // 4. Emit parts
if (partSubprograms != null) if (partSubprograms != null)
WritePartsWithSubprograms(w, allParts, cutLibrary, etchLibrary, sheetDiagonal, partSubprograms); WritePartsWithSubprograms(w, allParts, cutLibrary, etchLibrary, sheetDiagonal, partSubprograms, userVarMapping);
else else
WritePartsInline(w, allParts, cutLibrary, etchLibrary, sheetDiagonal); WritePartsInline(w, allParts, cutLibrary, etchLibrary, sheetDiagonal, userVarMapping);
// 5. Footer // 5. Footer
w.WriteLine("M42"); w.WriteLine("M42");
@@ -104,7 +105,8 @@ public sealed class CincinnatiSheetWriter
private void WritePartsWithSubprograms(TextWriter w, List<Part> allParts, private void WritePartsWithSubprograms(TextWriter w, List<Part> allParts,
string cutLibrary, string etchLibrary, double sheetDiagonal, 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 lastPartName = "";
var featureIndex = 0; var featureIndex = 0;
@@ -154,7 +156,9 @@ public sealed class CincinnatiSheetWriter
LibraryFile = isEtch ? etchLibrary : cutLibrary, LibraryFile = isEtch ? etchLibrary : cutLibrary,
CutDistance = cutDistance, CutDistance = cutDistance,
SheetDiagonal = sheetDiagonal, SheetDiagonal = sheetDiagonal,
PartLocation = part.Location PartLocation = part.Location,
UserVariableMapping = userVarMapping,
DrawingId = part.BaseDrawing.Id
}; };
_featureWriter.Write(w, ctx); _featureWriter.Write(w, ctx);
@@ -202,7 +206,8 @@ public sealed class CincinnatiSheetWriter
} }
private void WritePartsInline(TextWriter w, List<Part> allParts, private void WritePartsInline(TextWriter w, List<Part> 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 // Split and classify features, ordering etch before cut per part
var features = new List<(Part part, List<ICode> codes, bool isEtch)>(); var features = new List<(Part part, List<ICode> codes, bool isEtch)>();
@@ -242,7 +247,9 @@ public sealed class CincinnatiSheetWriter
LibraryFile = isEtch ? etchLibrary : cutLibrary, LibraryFile = isEtch ? etchLibrary : cutLibrary,
CutDistance = cutDistance, CutDistance = cutDistance,
SheetDiagonal = sheetDiagonal, SheetDiagonal = sheetDiagonal,
PartLocation = part.Location PartLocation = part.Location,
UserVariableMapping = userVarMapping,
DrawingId = part.BaseDrawing.Id
}; };
_featureWriter.Write(w, ctx); _featureWriter.Write(w, ctx);
@@ -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;
}
}