feat: add two-pass variable parsing to ProgramReader

ProgramReader now supports G-code user variables with a two-pass
approach: first pass collects variable definitions (name = expression
[inline] [global]) and evaluates them via topological sort and
ExpressionEvaluator; second pass parses G-code lines with $name
substitution and VariableRef tracking on motion and feedrate objects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 10:04:59 -04:00
parent 27afa04e4a
commit 46e3104dfc
2 changed files with 398 additions and 7 deletions

View File

@@ -1,7 +1,11 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace OpenNest.IO
@@ -15,6 +19,7 @@ namespace OpenNest.IO
private CodeSection section;
private Program program;
private StreamReader reader;
private Dictionary<string, double> resolvedVariables;
public ProgramReader(Stream stream)
{
@@ -24,11 +29,38 @@ namespace OpenNest.IO
public Program Read()
{
// First pass: read all lines, collect variable definitions
var allLines = new List<string>();
var variableDefs = new Dictionary<string, (string expression, bool inline, bool global)>(
StringComparer.OrdinalIgnoreCase);
var codeLines = new List<string>();
string line;
while ((line = reader.ReadLine()) != null)
{
block = ParseBlock(line);
allLines.Add(line);
if (TryParseVariableDefinition(line, out var name, out var expression, out var isInline, out var isGlobal))
variableDefs[name] = (expression, isInline, isGlobal);
else
codeLines.Add(line);
}
// Evaluate variables with topological sort for dependency ordering
resolvedVariables = ResolveVariables(variableDefs);
// Store evaluated variables on the program
foreach (var kvp in variableDefs)
{
var name = kvp.Key;
var (expression, isInline, isGlobal) = kvp.Value;
var value = resolvedVariables[name];
program.Variables[name] = new VariableDefinition(name, expression, value, isInline, isGlobal);
}
// Second pass: parse G-code lines with variable substitution
foreach (var codeLine in codeLines)
{
block = ParseBlock(codeLine);
ProcessCurrentBlock();
}
@@ -39,10 +71,43 @@ namespace OpenNest.IO
{
var block = new CodeBlock();
Code code = null;
for (int i = 0; i < line.Length; ++i)
for (var i = 0; i < line.Length; ++i)
{
var c = line[i];
if (char.IsLetter(c))
if (c == '$' && code != null && resolvedVariables != null)
{
// Read the maximal variable name (letters, digits, underscores)
var start = i + 1;
while (start < line.Length && (char.IsLetterOrDigit(line[start]) || line[start] == '_'))
start++;
var maxName = line.Substring(i + 1, start - i - 1);
// Try longest match first, then progressively shorter to handle
// cases like X$widthY0 where "widthY" isn't a variable but "width" is
string lookupKey = null;
var nameLen = maxName.Length;
while (nameLen > 0)
{
var candidate = maxName.Substring(0, nameLen);
lookupKey = resolvedVariables.Keys
.FirstOrDefault(k => string.Equals(k, candidate, StringComparison.OrdinalIgnoreCase));
if (lookupKey != null)
break;
nameLen--;
}
if (lookupKey != null)
{
code.Value = resolvedVariables[lookupKey].ToString(CultureInfo.InvariantCulture);
code.VariableRef = lookupKey;
i += nameLen; // advance past the matched variable name
}
else
{
i = start - 1; // no match, skip the whole thing
}
}
else if (char.IsLetter(c))
block.Add((code = new Code(c)));
else if (c == ':')
{
@@ -125,7 +190,10 @@ namespace OpenNest.IO
break;
case 'F':
program.Codes.Add(new Feedrate() { Value = double.Parse(code.Value) });
var feedrate = new Feedrate() { Value = double.Parse(code.Value) };
if (code.VariableRef != null)
feedrate.VariableRef = code.VariableRef;
program.Codes.Add(feedrate);
code = GetNextCode();
break;
@@ -143,6 +211,7 @@ namespace OpenNest.IO
double y = 0;
var layer = LayerType.Cut;
var suppressed = false;
string xRef = null, yRef = null;
while (section == CodeSection.Line)
{
@@ -157,10 +226,12 @@ namespace OpenNest.IO
{
case 'X':
x = double.Parse(code.Value);
xRef = code.VariableRef;
break;
case 'Y':
y = double.Parse(code.Value);
yRef = code.VariableRef;
break;
case ':':
@@ -200,10 +271,13 @@ namespace OpenNest.IO
break;
}
}
var refs = BuildVariableRefs(("X", xRef), ("Y", yRef));
if (isRapid)
program.Codes.Add(new RapidMove(x, y));
program.Codes.Add(new RapidMove(x, y) { VariableRefs = refs });
else
program.Codes.Add(new LinearMove(x, y) { Layer = layer, Suppressed = suppressed });
program.Codes.Add(new LinearMove(x, y) { Layer = layer, Suppressed = suppressed, VariableRefs = refs });
}
private void ReadArc(RotationType rotation)
@@ -214,6 +288,7 @@ namespace OpenNest.IO
double j = 0;
var layer = LayerType.Cut;
var suppressed = false;
string xRef = null, yRef = null, iRef = null, jRef = null;
while (section == CodeSection.Arc)
{
@@ -229,18 +304,22 @@ namespace OpenNest.IO
{
case 'X':
x = double.Parse(code.Value);
xRef = code.VariableRef;
break;
case 'Y':
y = double.Parse(code.Value);
yRef = code.VariableRef;
break;
case 'I':
i = double.Parse(code.Value);
iRef = code.VariableRef;
break;
case 'J':
j = double.Parse(code.Value);
jRef = code.VariableRef;
break;
case ':':
@@ -286,7 +365,8 @@ namespace OpenNest.IO
CenterPoint = new Vector(i, j),
Rotation = rotation,
Layer = layer,
Suppressed = suppressed
Suppressed = suppressed,
VariableRefs = BuildVariableRefs(("X", xRef), ("Y", yRef), ("I", iRef), ("J", jRef))
});
}
@@ -351,6 +431,179 @@ namespace OpenNest.IO
return block[codeIndex];
}
private static bool TryParseVariableDefinition(string line, out string name, out string expression,
out bool isInline, out bool isGlobal)
{
name = null;
expression = null;
isInline = false;
isGlobal = false;
var trimmed = line.Trim();
if (trimmed.Length == 0)
return false;
// Must start with a letter or underscore (not a G-code letter followed by a digit)
var firstChar = trimmed[0];
if (!char.IsLetter(firstChar) && firstChar != '_')
return false;
// If line starts with a known G-code letter followed by a digit, it's not a variable
if (trimmed.Length >= 2 && char.IsDigit(trimmed[1]))
{
var upper = char.ToUpper(firstChar);
if (upper is 'G' or 'M' or 'N' or 'F' or 'X' or 'Y' or 'I' or 'J' or 'T' or 'S' or 'O' or 'P' or 'R')
return false;
}
// Must contain '='
var eqIndex = trimmed.IndexOf('=');
if (eqIndex < 1)
return false;
// Extract name (everything before '=', trimmed)
var rawName = trimmed.Substring(0, eqIndex).Trim();
// Validate name: must be identifier (letter/underscore followed by alphanumeric/underscore)
if (rawName.Length == 0 || (!char.IsLetter(rawName[0]) && rawName[0] != '_'))
return false;
for (var i = 1; i < rawName.Length; i++)
{
if (!char.IsLetterOrDigit(rawName[i]) && rawName[i] != '_')
return false;
}
// Extract expression and flags from the remainder after '='
var remainder = trimmed.Substring(eqIndex + 1).Trim();
// Check for trailing flags: inline and/or global
// Parse from the end to separate expression from flags
var words = remainder.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var flagStart = words.Length;
for (var i = words.Length - 1; i >= 0; i--)
{
var word = words[i].ToLowerInvariant();
if (word == "inline" || word == "global")
flagStart = i;
else
break;
}
// Build expression from non-flag words
var expressionParts = words.Take(flagStart).ToArray();
if (expressionParts.Length == 0)
return false;
expression = string.Join(" ", expressionParts);
// Parse flags
for (var i = flagStart; i < words.Length; i++)
{
var word = words[i].ToLowerInvariant();
if (word == "inline") isInline = true;
else if (word == "global") isGlobal = true;
}
name = rawName;
return true;
}
private static Dictionary<string, double> ResolveVariables(
Dictionary<string, (string expression, bool inline, bool global)> variableDefs)
{
if (variableDefs.Count == 0)
return new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
// Build dependency graph
var dependencies = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in variableDefs)
{
var deps = new List<string>();
var expr = kvp.Value.expression;
for (var i = 0; i < expr.Length; i++)
{
if (expr[i] == '$')
{
var start = i + 1;
while (start < expr.Length && (char.IsLetterOrDigit(expr[start]) || expr[start] == '_'))
start++;
var refName = expr.Substring(i + 1, start - i - 1);
// Find the canonical name (case-insensitive match)
var canonical = variableDefs.Keys
.FirstOrDefault(k => string.Equals(k, refName, StringComparison.OrdinalIgnoreCase));
if (canonical != null)
deps.Add(canonical);
i = start - 1;
}
}
dependencies[kvp.Key] = deps;
}
// Topological sort (Kahn's algorithm)
var inDegree = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var name in variableDefs.Keys)
inDegree[name] = 0;
foreach (var kvp in dependencies)
{
foreach (var dep in kvp.Value)
{
if (inDegree.ContainsKey(dep))
inDegree[kvp.Key]++;
}
}
var queue = new Queue<string>();
foreach (var kvp in inDegree)
{
if (kvp.Value == 0)
queue.Enqueue(kvp.Key);
}
var resolved = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
var order = new List<string>();
while (queue.Count > 0)
{
var current = queue.Dequeue();
order.Add(current);
// Evaluate this variable
var expr = variableDefs[current].expression;
var value = ExpressionEvaluator.Evaluate(expr, resolved);
resolved[current] = value;
// Reduce in-degree of dependents
foreach (var kvp in dependencies)
{
if (kvp.Value.Contains(current, StringComparer.OrdinalIgnoreCase))
{
inDegree[kvp.Key]--;
if (inDegree[kvp.Key] == 0 && !order.Contains(kvp.Key))
queue.Enqueue(kvp.Key);
}
}
}
if (order.Count != variableDefs.Count)
throw new InvalidOperationException("Circular dependency detected among variables.");
return resolved;
}
private static Dictionary<string, string> BuildVariableRefs(params (string axis, string varRef)[] refs)
{
Dictionary<string, string> result = null;
foreach (var (axis, varRef) in refs)
{
if (varRef != null)
{
result ??= new Dictionary<string, string>();
result[axis] = varRef;
}
}
return result;
}
public void Close()
{
reader.Close();
@@ -374,6 +627,8 @@ namespace OpenNest.IO
public string Value { get; set; }
public string VariableRef { get; set; }
public override string ToString()
{
return Id + Value;