diff --git a/OpenNest.IO/ProgramReader.cs b/OpenNest.IO/ProgramReader.cs index ab2e896..5df8200 100644 --- a/OpenNest.IO/ProgramReader.cs +++ b/OpenNest.IO/ProgramReader.cs @@ -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 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(); + var variableDefs = new Dictionary( + StringComparer.OrdinalIgnoreCase); + var codeLines = new List(); 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 ResolveVariables( + Dictionary variableDefs) + { + if (variableDefs.Count == 0) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Build dependency graph + var dependencies = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in variableDefs) + { + var deps = new List(); + 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(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(); + foreach (var kvp in inDegree) + { + if (kvp.Value == 0) + queue.Enqueue(kvp.Key); + } + + var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); + var order = new List(); + + 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 BuildVariableRefs(params (string axis, string varRef)[] refs) + { + Dictionary result = null; + foreach (var (axis, varRef) in refs) + { + if (varRef != null) + { + result ??= new Dictionary(); + 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; diff --git a/OpenNest.Tests/IO/ProgramReaderVariableTests.cs b/OpenNest.Tests/IO/ProgramReaderVariableTests.cs new file mode 100644 index 0000000..c56707f --- /dev/null +++ b/OpenNest.Tests/IO/ProgramReaderVariableTests.cs @@ -0,0 +1,136 @@ +using System.IO; +using System.Text; +using OpenNest.CNC; +using OpenNest.IO; + +namespace OpenNest.Tests.IO; + +public class ProgramReaderVariableTests +{ + private Program Parse(string gcode) + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes(gcode)); + var reader = new ProgramReader(stream); + var program = reader.Read(); + reader.Close(); + return program; + } + + [Fact] + public void Parse_SimpleVariable_StoredInVariables() + { + var pgm = Parse("diameter = 0.3\nG90\nG01X1Y0"); + Assert.True(pgm.Variables.ContainsKey("diameter")); + Assert.Equal(0.3, pgm.Variables["diameter"].Value); + Assert.Equal("0.3", pgm.Variables["diameter"].Expression); + } + + [Fact] + public void Parse_VariableWithInlineFlag() + { + var pgm = Parse("kerf = 0.06 inline\nG90\nG01X1Y0"); + Assert.True(pgm.Variables["kerf"].Inline); + Assert.False(pgm.Variables["kerf"].Global); + } + + [Fact] + public void Parse_VariableWithGlobalFlag() + { + var pgm = Parse("sheet_width = 48.0 global\nG90\nG01X1Y0"); + Assert.True(pgm.Variables["sheet_width"].Global); + Assert.False(pgm.Variables["sheet_width"].Inline); + } + + [Fact] + public void Parse_VariableWithBothFlags() + { + var pgm = Parse("speed = 200 global inline\nG90\nG01X1Y0"); + Assert.True(pgm.Variables["speed"].Global); + Assert.True(pgm.Variables["speed"].Inline); + } + + [Fact] + public void Parse_VariableReference_SubstitutedInCoordinate() + { + var pgm = Parse("width = 48.0\nG90\nG01X$widthY0"); + var linear = (LinearMove)pgm.Codes[0]; + Assert.Equal(48.0, linear.EndPoint.X); + Assert.Equal(0.0, linear.EndPoint.Y); + } + + [Fact] + public void Parse_VariableReference_TrackedInVariableRefs() + { + var pgm = Parse("width = 48.0\nG90\nG01X$widthY0"); + var linear = (LinearMove)pgm.Codes[0]; + Assert.NotNull(linear.VariableRefs); + Assert.Equal("width", linear.VariableRefs["X"]); + Assert.False(linear.VariableRefs.ContainsKey("Y")); + } + + [Fact] + public void Parse_VariableExpression_WithReference() + { + var pgm = Parse("diameter = 0.6\nradius = $diameter / 2\nG90\nG02X1Y0I$radiusJ0"); + Assert.Equal(0.3, pgm.Variables["radius"].Value, 10); + var arc = (ArcMove)pgm.Codes[0]; + Assert.Equal(0.3, arc.CenterPoint.X, 10); + Assert.Equal("radius", arc.VariableRefs["I"]); + } + + [Fact] + public void Parse_FeedVariable_TrackedOnFeedrate() + { + var pgm = Parse("speed = 100\nG90\nF$speed\nG01X1Y0"); + var feedrate = (Feedrate)pgm.Codes[0]; + Assert.Equal(100.0, feedrate.Value); + Assert.Equal("speed", feedrate.VariableRef); + } + + [Fact] + public void Parse_VariablesCollectedInPrepass_OrderIndependent() + { + var pgm = Parse("radius = $diameter / 2\ndiameter = 0.6\nG90\nG01X$radiusY0"); + Assert.Equal(0.3, pgm.Variables["radius"].Value, 10); + var linear = (LinearMove)pgm.Codes[0]; + Assert.Equal(0.3, linear.EndPoint.X, 10); + } + + [Fact] + public void Parse_NoVariables_WorksAsNormal() + { + var pgm = Parse("G90\nG01X1.5Y2.5"); + Assert.Empty(pgm.Variables); + var linear = (LinearMove)pgm.Codes[0]; + Assert.Equal(1.5, linear.EndPoint.X); + Assert.Null(linear.VariableRefs); + } + + [Fact] + public void Parse_RapidMove_WithVariableRef() + { + var pgm = Parse("start_x = 5.0\nG90\nG00X$start_xY0"); + var rapid = (RapidMove)pgm.Codes[0]; + Assert.Equal(5.0, rapid.EndPoint.X); + Assert.Equal("start_x", rapid.VariableRefs["X"]); + } + + [Fact] + public void Parse_ArcMove_VariableOnMultipleAxes() + { + var pgm = Parse("r = 0.5\nG90\nG03X1Y0I$rJ$r"); + var arc = (ArcMove)pgm.Codes[0]; + Assert.Equal(0.5, arc.CenterPoint.X); + Assert.Equal(0.5, arc.CenterPoint.Y); + Assert.Equal("r", arc.VariableRefs["I"]); + Assert.Equal("r", arc.VariableRefs["J"]); + } + + [Fact] + public void Parse_CaseInsensitive_VariableReference() + { + var pgm = Parse("Diameter = 0.3\nG90\nG01X$diameterY0"); + var linear = (LinearMove)pgm.Codes[0]; + Assert.Equal(0.3, linear.EndPoint.X); + } +}