Editor/Effigy/Expression.cs
using System;
using System.Collections.Generic;
using System.Globalization;
namespace Effigy;
/// <summary>
/// The evaluator behind Onshape's numeric fields.
///
/// Onshape's numeric fields "accept integers, decimals, parameter expressions, and trigonometric
/// functions", with the documented operator set ^ * / + - and the documented function set ceil,
/// floor, round, exp, sqrt, abs, max, min and log. Typing `1/8` and getting 0.125 is not a
/// nicety - a slider cannot express a fraction, a tapped hole size, or "half of the last one",
/// and those are most of what a dimension actually is.
///
/// Deliberately hand-written rather than pulled from a package: the kernel's whole point is
/// that it has no dependencies. It lives in the kernel rather than the editor because it has no
/// engine surface whatsoever - which also means it can be compiled and exercised directly, and
/// ExpressionTests does exactly that.
///
/// UNITS. Effigy's lengths are dimensionless (see EffigyViewport.FrameCamera - a default Box is
/// one unit on a side), so length fields take bare numbers and reject unit suffixes rather than
/// inventing a millimetre that nothing downstream honours. Angle fields are real, because
/// FloatParam.Unit already carries "deg", so those accept `deg`, `rad` and `°`.
///
/// TRIG IS IN DEGREES. sin(30) is 0.5. Onshape's trig takes a unit-carrying angle and cannot be
/// ambiguous; with no unit system here, degrees is what someone typing into a CAD field means.
/// </summary>
public static class Expression
{
/// <summary>
/// Evaluate an expression. False when it is not a well-formed expression at all, which is the
/// signal for the field to hold its last good value rather than clobbering the parameter with
/// a NaN halfway through someone typing "1/".
/// </summary>
/// <param name="text">What the user typed.</param>
/// <param name="unit">The parameter's unit - "deg" for an angle, null for a bare number.</param>
/// <param name="value">The result, in the parameter's own unit.</param>
public static bool TryEvaluate( string text, string unit, out float value )
{
value = 0f;
if ( string.IsNullOrWhiteSpace( text ) )
return false;
try
{
var parser = new Parser( text, unit );
var result = parser.ParseExpression();
parser.SkipSpace();
// Trailing junk means the whole string was not an expression. "2 3" is a typo, not a 2.
if ( !parser.AtEnd )
return false;
if ( float.IsNaN( result ) || float.IsInfinity( result ) )
return false;
value = result;
return true;
}
catch ( FormatException )
{
return false;
}
}
/// <summary>How a committed value is written back into the field. Onshape shows the evaluated
/// result once a numeric field is accepted; trailing zeros on an integral value read as noise
/// in a dimension, so 4 stays 4 rather than becoming 4.000.</summary>
public static string Format( float value )
{
if ( MathF.Abs( value - MathF.Round( value ) ) < 1e-6f )
return ((int)MathF.Round( value )).ToString( CultureInfo.InvariantCulture );
return value.ToString( "0.####", CultureInfo.InvariantCulture );
}
// --- parser -------------------------------------------------------------------------------
/// <summary>
/// Recursive descent over
///
/// expression := term (('+'|'-') term)*
/// term := unary (('*'|'/') unary)*
/// unary := ('-'|'+') unary | power
/// power := atom ('^' unary)?
/// atom := number unit? | name '(' args ')' | name | '(' expression ')'
///
/// The unary/power split is what makes -2^2 evaluate to -4 and 2^-1 to 0.5, which is the
/// convention every calculator and every CAD field uses. Getting it wrong is silent: the
/// expression still evaluates, to the wrong number.
/// </summary>
sealed class Parser
{
readonly string _text;
readonly string _unit;
int _pos;
public Parser( string text, string unit )
{
_text = text;
_unit = unit;
}
public bool AtEnd => _pos >= _text.Length;
public void SkipSpace()
{
while ( _pos < _text.Length && char.IsWhiteSpace( _text[_pos] ) )
_pos++;
}
char Peek()
{
SkipSpace();
return _pos < _text.Length ? _text[_pos] : '\0';
}
bool Take( char c )
{
if ( Peek() != c )
return false;
_pos++;
return true;
}
public float ParseExpression()
{
var left = ParseTerm();
while ( true )
{
if ( Take( '+' ) ) left += ParseTerm();
else if ( Take( '-' ) ) left -= ParseTerm();
else return left;
}
}
float ParseTerm()
{
var left = ParseUnary();
while ( true )
{
if ( Take( '*' ) )
{
left *= ParseUnary();
}
else if ( Take( '/' ) )
{
var divisor = ParseUnary();
// Not an exception: a field reading "1/0" mid-type is a half-finished thought,
// and NaN out is how TryEvaluate knows to keep the previous value.
if ( divisor == 0f )
return float.NaN;
left /= divisor;
}
else
{
return left;
}
}
}
float ParseUnary()
{
if ( Take( '-' ) )
return -ParseUnary();
if ( Take( '+' ) )
return ParseUnary();
return ParsePower();
}
float ParsePower()
{
var b = ParseAtom();
// Right-associative, and the exponent goes through unary so 2^-1 parses.
if ( Take( '^' ) )
return MathF.Pow( b, ParseUnary() );
return b;
}
float ParseAtom()
{
SkipSpace();
if ( AtEnd )
throw new FormatException( "unexpected end of expression" );
if ( Take( '(' ) )
{
var inner = ParseExpression();
if ( !Take( ')' ) )
throw new FormatException( "unclosed bracket" );
return inner;
}
var c = _text[_pos];
if ( char.IsDigit( c ) || c == '.' )
return ParseNumber();
if ( char.IsLetter( c ) || c == '_' )
return ParseName();
throw new FormatException( $"unexpected '{c}'" );
}
float ParseNumber()
{
var start = _pos;
while ( _pos < _text.Length && (char.IsDigit( _text[_pos] ) || _text[_pos] == '.') )
_pos++;
// Exponent form, but only when it really is one - `2e` is a 2 times the constant e,
// and `2early` is a typo. Both have to stay out of the number.
if ( _pos < _text.Length && (_text[_pos] == 'e' || _text[_pos] == 'E') )
{
var save = _pos;
var probe = _pos + 1;
if ( probe < _text.Length && (_text[probe] == '+' || _text[probe] == '-') )
probe++;
if ( probe < _text.Length && char.IsDigit( _text[probe] ) )
{
_pos = probe;
while ( _pos < _text.Length && char.IsDigit( _text[_pos] ) )
_pos++;
}
else
{
_pos = save;
}
}
var span = _text.Substring( start, _pos - start );
if ( !float.TryParse( span, NumberStyles.Float, CultureInfo.InvariantCulture, out var number ) )
throw new FormatException( $"'{span}' is not a number" );
return ApplyUnitSuffix( number );
}
/// <summary>
/// A unit written straight after a number, converted into the field's own unit.
///
/// Length fields reject suffixes outright rather than silently ignoring them: the kernel is
/// dimensionless, so accepting `5mm` and storing 5 would be a worse lie than refusing it.
/// </summary>
float ApplyUnitSuffix( float number )
{
if ( _pos < _text.Length && _text[_pos] == '°' )
{
_pos++;
if ( _unit != "deg" )
throw new FormatException( "this field has no angle unit" );
return number;
}
var start = _pos;
while ( _pos < _text.Length && char.IsLetter( _text[_pos] ) )
_pos++;
if ( _pos == start )
return number;
var suffix = _text.Substring( start, _pos - start ).ToLowerInvariant();
// Not a unit - put the letters back and let the trailing-junk check in TryEvaluate
// reject the whole string. There is no implicit multiplication, so `2pi` is a typo
// rather than 2*pi; failing loudly beats guessing which one was meant.
if ( !IsUnit( suffix ) )
{
_pos = start;
return number;
}
if ( _unit != "deg" )
throw new FormatException( $"'{suffix}' is an angle unit and this field is a plain number" );
return suffix == "rad" ? number * (180f / MathF.PI) : number;
}
static bool IsUnit( string s ) => s is "deg" or "degree" or "degrees" or "rad" or "radian" or "radians";
float ParseName()
{
var start = _pos;
while ( _pos < _text.Length && (char.IsLetterOrDigit( _text[_pos] ) || _text[_pos] == '_') )
_pos++;
var name = _text.Substring( start, _pos - start ).ToLowerInvariant();
if ( Peek() == '(' )
{
_pos++; // the '(' that Peek found
var args = new List<float>();
if ( Peek() != ')' )
{
args.Add( ParseExpression() );
while ( Take( ',' ) )
args.Add( ParseExpression() );
}
if ( !Take( ')' ) )
throw new FormatException( $"unclosed bracket after {name}" );
return Call( name, args );
}
return name switch
{
"pi" => MathF.PI,
"tau" => MathF.Tau,
"e" => MathF.E,
_ => throw new FormatException( $"unknown name '{name}'" ),
};
}
static float Call( string name, List<float> a )
{
float One() => a.Count == 1 ? a[0] : throw new FormatException( $"{name} takes one argument" );
float Two( int i ) => a.Count == 2 ? a[i] : throw new FormatException( $"{name} takes two arguments" );
const float ToRad = MathF.PI / 180f;
const float ToDeg = 180f / MathF.PI;
return name switch
{
"sqrt" => MathF.Sqrt( One() ),
"abs" => MathF.Abs( One() ),
"floor" => MathF.Floor( One() ),
"ceil" => MathF.Ceiling( One() ),
"round" => MathF.Round( One() ),
"sign" => MathF.Sign( One() ),
"exp" => MathF.Exp( One() ),
"log" => MathF.Log( One() ),
"log10" => MathF.Log10( One() ),
// Degrees in, degrees out - see the class remarks.
"sin" => MathF.Sin( One() * ToRad ),
"cos" => MathF.Cos( One() * ToRad ),
"tan" => MathF.Tan( One() * ToRad ),
"asin" => MathF.Asin( One() ) * ToDeg,
"acos" => MathF.Acos( One() ) * ToDeg,
"atan" => MathF.Atan( One() ) * ToDeg,
"min" => MathF.Min( Two( 0 ), Two( 1 ) ),
"max" => MathF.Max( Two( 0 ), Two( 1 ) ),
"pow" => MathF.Pow( Two( 0 ), Two( 1 ) ),
"atan2" => MathF.Atan2( Two( 0 ), Two( 1 ) ) * ToDeg,
_ => throw new FormatException( $"unknown function '{name}'" ),
};
}
}
}