feat: add ExpressionEvaluator for G-code variable expressions
Also set ContinueOnError=true on Cincinnati's post-build copy to prevent the running WinForms app from blocking test builds via a file lock. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
158
OpenNest.Core/Math/ExpressionEvaluator.cs
Normal file
158
OpenNest.Core/Math/ExpressionEvaluator.cs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace OpenNest.Math
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Recursive descent parser for simple arithmetic expressions supporting
|
||||||
|
/// +, -, *, /, parentheses, unary minus/plus, and $variable references.
|
||||||
|
/// </summary>
|
||||||
|
public static class ExpressionEvaluator
|
||||||
|
{
|
||||||
|
public static double Evaluate(string expression, IReadOnlyDictionary<string, double> variables)
|
||||||
|
{
|
||||||
|
var parser = new Parser(expression, variables);
|
||||||
|
var result = parser.ParseExpression();
|
||||||
|
parser.SkipWhitespace();
|
||||||
|
if (!parser.IsEnd)
|
||||||
|
throw new FormatException($"Unexpected character at position {parser.Position}: '{parser.Current}'");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ref struct Parser
|
||||||
|
{
|
||||||
|
private readonly ReadOnlySpan<char> _input;
|
||||||
|
private readonly IReadOnlyDictionary<string, double> _variables;
|
||||||
|
private int _pos;
|
||||||
|
|
||||||
|
public Parser(string input, IReadOnlyDictionary<string, double> variables)
|
||||||
|
{
|
||||||
|
_input = input.AsSpan();
|
||||||
|
_variables = variables;
|
||||||
|
_pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Position => _pos;
|
||||||
|
public bool IsEnd => _pos >= _input.Length;
|
||||||
|
public char Current => _input[_pos];
|
||||||
|
|
||||||
|
public void SkipWhitespace()
|
||||||
|
{
|
||||||
|
while (_pos < _input.Length && _input[_pos] == ' ')
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expression = Term (('+' | '-') Term)*
|
||||||
|
public double ParseExpression()
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
var left = ParseTerm();
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
if (IsEnd) break;
|
||||||
|
|
||||||
|
var op = Current;
|
||||||
|
if (op != '+' && op != '-') break;
|
||||||
|
|
||||||
|
_pos++;
|
||||||
|
SkipWhitespace();
|
||||||
|
var right = ParseTerm();
|
||||||
|
|
||||||
|
left = op == '+' ? left + right : left - right;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Term = Unary (('*' | '/') Unary)*
|
||||||
|
private double ParseTerm()
|
||||||
|
{
|
||||||
|
var left = ParseUnary();
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
if (IsEnd) break;
|
||||||
|
|
||||||
|
var op = Current;
|
||||||
|
if (op != '*' && op != '/') break;
|
||||||
|
|
||||||
|
_pos++;
|
||||||
|
SkipWhitespace();
|
||||||
|
var right = ParseUnary();
|
||||||
|
|
||||||
|
left = op == '*' ? left * right : left / right;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unary = ('-' | '+')? Primary
|
||||||
|
private double ParseUnary()
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
if (!IsEnd && Current == '-')
|
||||||
|
{
|
||||||
|
_pos++;
|
||||||
|
return -ParsePrimary();
|
||||||
|
}
|
||||||
|
if (!IsEnd && Current == '+')
|
||||||
|
{
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
return ParsePrimary();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary = '(' Expression ')' | '$' Identifier | Number
|
||||||
|
private double ParsePrimary()
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
|
||||||
|
if (IsEnd)
|
||||||
|
throw new FormatException("Unexpected end of expression.");
|
||||||
|
|
||||||
|
if (Current == '(')
|
||||||
|
{
|
||||||
|
_pos++; // consume '('
|
||||||
|
var value = ParseExpression();
|
||||||
|
SkipWhitespace();
|
||||||
|
if (IsEnd || Current != ')')
|
||||||
|
throw new FormatException("Expected closing parenthesis.");
|
||||||
|
_pos++; // consume ')'
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Current == '$')
|
||||||
|
{
|
||||||
|
_pos++; // consume '$'
|
||||||
|
var start = _pos;
|
||||||
|
while (_pos < _input.Length && (char.IsLetterOrDigit(_input[_pos]) || _input[_pos] == '_'))
|
||||||
|
_pos++;
|
||||||
|
if (_pos == start)
|
||||||
|
throw new FormatException("Expected variable name after '$'.");
|
||||||
|
var name = _input.Slice(start, _pos - start).ToString();
|
||||||
|
if (!_variables.TryGetValue(name, out var varValue))
|
||||||
|
throw new KeyNotFoundException($"Undefined variable: ${name}");
|
||||||
|
return varValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number
|
||||||
|
var numStart = _pos;
|
||||||
|
while (_pos < _input.Length && (char.IsDigit(_input[_pos]) || _input[_pos] == '.'))
|
||||||
|
_pos++;
|
||||||
|
|
||||||
|
if (_pos == numStart)
|
||||||
|
throw new FormatException($"Unexpected character '{Current}' at position {_pos}.");
|
||||||
|
|
||||||
|
var numSpan = _input.Slice(numStart, _pos - numStart).ToString();
|
||||||
|
if (!double.TryParse(numSpan, NumberStyles.Float, CultureInfo.InvariantCulture, out var number))
|
||||||
|
throw new FormatException($"Invalid number: '{numSpan}'");
|
||||||
|
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
<PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir>
|
<PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<MakeDir Directories="$(PostsDir)" />
|
<MakeDir Directories="$(PostsDir)" />
|
||||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" />
|
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
|
||||||
</Target>
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
119
OpenNest.Tests/Math/ExpressionEvaluatorTests.cs
Normal file
119
OpenNest.Tests/Math/ExpressionEvaluatorTests.cs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Math;
|
||||||
|
|
||||||
|
public class ExpressionEvaluatorTests
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, double> Empty = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_NumericLiteral()
|
||||||
|
{
|
||||||
|
Assert.Equal(42.0, ExpressionEvaluator.Evaluate("42", Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_DecimalLiteral()
|
||||||
|
{
|
||||||
|
Assert.Equal(0.3, ExpressionEvaluator.Evaluate("0.3", Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_NegativeLiteral()
|
||||||
|
{
|
||||||
|
Assert.Equal(-5.0, ExpressionEvaluator.Evaluate("-5", Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_Addition()
|
||||||
|
{
|
||||||
|
Assert.Equal(5.0, ExpressionEvaluator.Evaluate("2 + 3", Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_Subtraction()
|
||||||
|
{
|
||||||
|
Assert.Equal(1.0, ExpressionEvaluator.Evaluate("3 - 2", Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_Multiplication()
|
||||||
|
{
|
||||||
|
Assert.Equal(6.0, ExpressionEvaluator.Evaluate("2 * 3", Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_Division()
|
||||||
|
{
|
||||||
|
Assert.Equal(2.5, ExpressionEvaluator.Evaluate("5 / 2", Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_OperatorPrecedence()
|
||||||
|
{
|
||||||
|
Assert.Equal(7.0, ExpressionEvaluator.Evaluate("1 + 2 * 3", Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_Parentheses()
|
||||||
|
{
|
||||||
|
Assert.Equal(9.0, ExpressionEvaluator.Evaluate("(1 + 2) * 3", Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_VariableReference()
|
||||||
|
{
|
||||||
|
var vars = new Dictionary<string, double> { { "diameter", 0.3 } };
|
||||||
|
Assert.Equal(0.3, ExpressionEvaluator.Evaluate("$diameter", vars));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_VariableInExpression()
|
||||||
|
{
|
||||||
|
var vars = new Dictionary<string, double> { { "diameter", 0.6 } };
|
||||||
|
Assert.Equal(0.3, ExpressionEvaluator.Evaluate("$diameter / 2", vars));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_MultipleVariables()
|
||||||
|
{
|
||||||
|
var vars = new Dictionary<string, double> { { "x", 2.0 }, { "y", 3.0 } };
|
||||||
|
Assert.Equal(5.0, ExpressionEvaluator.Evaluate("$x + $y", vars));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_CaseInsensitiveVariables()
|
||||||
|
{
|
||||||
|
var vars = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
{ "Diameter", 0.3 }
|
||||||
|
};
|
||||||
|
Assert.Equal(0.3, ExpressionEvaluator.Evaluate("$diameter", vars));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_UndefinedVariable_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<KeyNotFoundException>(() =>
|
||||||
|
ExpressionEvaluator.Evaluate("$missing", Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_NestedParentheses()
|
||||||
|
{
|
||||||
|
Assert.Equal(20.0, ExpressionEvaluator.Evaluate("((2 + 3) * (1 + 1)) * 2", Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_UnaryMinusBeforeParens()
|
||||||
|
{
|
||||||
|
Assert.Equal(-6.0, ExpressionEvaluator.Evaluate("-(2 + 4)", Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_WhitespaceHandling()
|
||||||
|
{
|
||||||
|
Assert.Equal(5.0, ExpressionEvaluator.Evaluate(" 2 + 3 ", Empty));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user