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

View File

@@ -29,6 +29,17 @@ public sealed class FeatureContext
/// so part-relative programs become plate-absolute under G90.
/// </summary>
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>
@@ -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);
}
/// <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)
{
foreach (var code in codes)

View File

@@ -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.")]

View File

@@ -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<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()
{
var vars = new ProgramVariableManager();

View File

@@ -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<Part> 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<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
var features = new List<(Part part, List<ICode> 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);

View File

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