Skip to content

Commit 45083dd

Browse files
committed
feat(scripting): add built-in math functions (abs, sqrt, pow, trig, min/max, clamp)
Extend the scripting language with callable math functions, addressing #1141. The tokenizer now emits a Comma token and the Pratt parser recognizes an identifier followed by a parenthesized argument list as a function call. Arity and unknown-function checks happen at parse time so ValidateScript reports them early. Evaluation goes through a new Ast::ExprFunction node that coerces each argument to double and dispatches to <cmath>. Supported: abs, sqrt, pow, sin, cos, tan, asin, acos, atan, atan2, exp, log, log10, floor, ceil, round, min, max, clamp. A variable/enum still wins when the name is not followed by '('.
1 parent ca4edbf commit 45083dd

5 files changed

Lines changed: 204 additions & 2 deletions

File tree

‎include/behaviortree_cpp/scripting/any_types.hpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ enum class TokenType
6666
// Delimiters
6767
LeftParen,
6868
RightParen,
69+
Comma,
6970
Semicolon,
7071
// Control
7172
EndOfInput,

‎include/behaviortree_cpp/scripting/operators.hpp‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,87 @@ inline double StringToDouble(const Any& value, const Environment& env)
5050
return value.cast<double>();
5151
}
5252

53+
// Number of arguments expected by a built-in math function, or -1 if the name
54+
// is not a known function. Used both by the parser (fail fast on unknown
55+
// functions / wrong arity) and by the evaluator.
56+
inline int BuiltinFunctionArity(const std::string& name)
57+
{
58+
if(name == "abs" || name == "sqrt" || name == "sin" || name == "cos" ||
59+
name == "tan" || name == "asin" || name == "acos" || name == "atan" ||
60+
name == "exp" || name == "log" || name == "log10" || name == "floor" ||
61+
name == "ceil" || name == "round")
62+
{
63+
return 1;
64+
}
65+
if(name == "pow" || name == "atan2" || name == "min" || name == "max")
66+
{
67+
return 2;
68+
}
69+
if(name == "clamp")
70+
{
71+
return 3;
72+
}
73+
return -1;
74+
}
75+
76+
// Apply a built-in math function to already-evaluated numeric arguments.
77+
// Throws RuntimeError for unknown functions (defensive; the parser should
78+
// already have rejected them).
79+
inline double ApplyMathFunction(const std::string& name, const std::vector<double>& args)
80+
{
81+
const int arity = BuiltinFunctionArity(name);
82+
if(arity < 0)
83+
{
84+
throw RuntimeError(StrCat("Unknown function [", name, "]"));
85+
}
86+
if(static_cast<int>(args.size()) != arity)
87+
{
88+
throw RuntimeError(StrCat("Function [", name, "] expects ", std::to_string(arity),
89+
" argument(s), got ", std::to_string(args.size())));
90+
}
91+
92+
if(name == "abs")
93+
return std::abs(args[0]);
94+
if(name == "sqrt")
95+
return std::sqrt(args[0]);
96+
if(name == "pow")
97+
return std::pow(args[0], args[1]);
98+
if(name == "sin")
99+
return std::sin(args[0]);
100+
if(name == "cos")
101+
return std::cos(args[0]);
102+
if(name == "tan")
103+
return std::tan(args[0]);
104+
if(name == "asin")
105+
return std::asin(args[0]);
106+
if(name == "acos")
107+
return std::acos(args[0]);
108+
if(name == "atan")
109+
return std::atan(args[0]);
110+
if(name == "atan2")
111+
return std::atan2(args[0], args[1]);
112+
if(name == "exp")
113+
return std::exp(args[0]);
114+
if(name == "log")
115+
return std::log(args[0]);
116+
if(name == "log10")
117+
return std::log10(args[0]);
118+
if(name == "floor")
119+
return std::floor(args[0]);
120+
if(name == "ceil")
121+
return std::ceil(args[0]);
122+
if(name == "round")
123+
return std::round(args[0]);
124+
if(name == "min")
125+
return std::min(args[0], args[1]);
126+
if(name == "max")
127+
return std::max(args[0], args[1]);
128+
if(name == "clamp")
129+
return std::min(std::max(args[0], args[1]), args[2]);
130+
131+
return 0.0; // unreachable
132+
}
133+
53134
struct ExprBase
54135
{
55136
using Ptr = std::shared_ptr<ExprBase>;
@@ -105,6 +186,38 @@ struct ExprName : ExprBase
105186
}
106187
};
107188

189+
struct ExprFunction : ExprBase
190+
{
191+
std::string name;
192+
std::vector<expr_ptr> args;
193+
194+
explicit ExprFunction(std::string n, std::vector<expr_ptr> a)
195+
: name(std::move(n)), args(std::move(a))
196+
{}
197+
198+
Any evaluate(Environment& env) const override
199+
{
200+
std::vector<double> values;
201+
values.reserve(args.size());
202+
for(const auto& arg : args)
203+
{
204+
auto v = arg->evaluate(env);
205+
if(v.empty())
206+
{
207+
throw RuntimeError(StrCat("Argument of function [", name,
208+
"] is not initialized"));
209+
}
210+
if(!v.isNumber())
211+
{
212+
throw RuntimeError(StrCat("Function [", name,
213+
"] expects numeric arguments"));
214+
}
215+
values.push_back(v.cast<double>());
216+
}
217+
return Any(ApplyMathFunction(name, values));
218+
}
219+
};
220+
108221
struct ExprUnaryArithmetic : ExprBase
109222
{
110223
enum op_t

‎src/script_parser.cpp‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,46 @@ class ScriptParser
242242
advance();
243243
return std::make_shared<Ast::ExprLiteral>(Any(std::string(tok.text)));
244244
}
245-
// Identifier
245+
// Identifier (variable reference or function call)
246246
if(tok.type == TokenType::Identifier)
247247
{
248+
std::string name(tok.text);
248249
advance();
249-
return std::make_shared<Ast::ExprName>(std::string(tok.text));
250+
251+
// Function call: identifier followed by a parenthesized argument list.
252+
if(check(TokenType::LeftParen))
253+
{
254+
advance(); // consume '('
255+
std::vector<Ast::expr_ptr> args;
256+
if(!check(TokenType::RightParen))
257+
{
258+
args.push_back(parseExpr(0));
259+
while(check(TokenType::Comma))
260+
{
261+
advance(); // consume ','
262+
args.push_back(parseExpr(0));
263+
}
264+
}
265+
expect(TokenType::RightParen, "expected ')' after function arguments");
266+
267+
// Fail fast on unknown functions and wrong arity so ValidateScript
268+
// reports the problem at parse time instead of at evaluation time.
269+
const int arity = Ast::BuiltinFunctionArity(name);
270+
if(arity < 0)
271+
{
272+
throw RuntimeError(StrCat("Unknown function [", name, "] at position ",
273+
std::to_string(tok.pos)));
274+
}
275+
if(static_cast<int>(args.size()) != arity)
276+
{
277+
throw RuntimeError(StrCat("Function [", name, "] expects ",
278+
std::to_string(arity), " argument(s), got ",
279+
std::to_string(args.size()), " at position ",
280+
std::to_string(tok.pos)));
281+
}
282+
return std::make_shared<Ast::ExprFunction>(std::move(name), std::move(args));
283+
}
284+
return std::make_shared<Ast::ExprName>(std::move(name));
250285
}
251286
// Error token from tokenizer
252287
if(tok.type == TokenType::Error)

‎src/script_tokenizer.cpp‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ TokenType matchSingleCharOp(char c)
211211
return TokenType::LeftParen;
212212
case ')':
213213
return TokenType::RightParen;
214+
case ',':
215+
return TokenType::Comma;
214216
case ';':
215217
return TokenType::Semicolon;
216218
default:

‎tests/script_parser_test.cpp‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,57 @@ TEST(ParserTest, UnaryOperators)
647647
EXPECT_EQ(Parse("10 + -3").value().cast<double>(), 7.0);
648648
}
649649

650+
// https://github.com/BehaviorTree/BehaviorTree.CPP/issues/1141
651+
TEST(ParserTest, MathFunctions)
652+
{
653+
BT::Ast::Environment env = { BT::Blackboard::create(), {} };
654+
auto Parse = [&env](const char* str) { return BT::ParseScriptAndExecute(env, str); };
655+
656+
constexpr double EPS = 1e-9;
657+
658+
// Unary functions
659+
EXPECT_NEAR(Parse("abs(-3)").value().cast<double>(), 3.0, EPS);
660+
EXPECT_NEAR(Parse("abs(3)").value().cast<double>(), 3.0, EPS);
661+
EXPECT_NEAR(Parse("sqrt(9)").value().cast<double>(), 3.0, EPS);
662+
EXPECT_NEAR(Parse("sin(0)").value().cast<double>(), 0.0, EPS);
663+
EXPECT_NEAR(Parse("cos(0)").value().cast<double>(), 1.0, EPS);
664+
EXPECT_NEAR(Parse("tan(0)").value().cast<double>(), 0.0, EPS);
665+
EXPECT_NEAR(Parse("asin(1)").value().cast<double>(), std::asin(1.0), EPS);
666+
EXPECT_NEAR(Parse("acos(1)").value().cast<double>(), std::acos(1.0), EPS);
667+
EXPECT_NEAR(Parse("atan(1)").value().cast<double>(), std::atan(1.0), EPS);
668+
EXPECT_NEAR(Parse("exp(0)").value().cast<double>(), 1.0, EPS);
669+
EXPECT_NEAR(Parse("log(1)").value().cast<double>(), 0.0, EPS);
670+
EXPECT_NEAR(Parse("log10(100)").value().cast<double>(), 2.0, EPS);
671+
EXPECT_NEAR(Parse("floor(3.7)").value().cast<double>(), 3.0, EPS);
672+
EXPECT_NEAR(Parse("ceil(3.2)").value().cast<double>(), 4.0, EPS);
673+
EXPECT_NEAR(Parse("round(3.5)").value().cast<double>(), 4.0, EPS);
674+
675+
// Binary / ternary functions
676+
EXPECT_NEAR(Parse("pow(2, 10)").value().cast<double>(), 1024.0, EPS);
677+
EXPECT_NEAR(Parse("atan2(1, 1)").value().cast<double>(), std::atan2(1.0, 1.0), EPS);
678+
EXPECT_NEAR(Parse("min(3, 5)").value().cast<double>(), 3.0, EPS);
679+
EXPECT_NEAR(Parse("max(3, 5)").value().cast<double>(), 5.0, EPS);
680+
EXPECT_NEAR(Parse("clamp(10, 0, 5)").value().cast<double>(), 5.0, EPS);
681+
EXPECT_NEAR(Parse("clamp(-1, 0, 5)").value().cast<double>(), 0.0, EPS);
682+
EXPECT_NEAR(Parse("clamp(3, 0, 5)").value().cast<double>(), 3.0, EPS);
683+
684+
// Composition with operators and variables
685+
EXPECT_NEAR(Parse("sqrt(9) + 1").value().cast<double>(), 4.0, EPS);
686+
EXPECT_NEAR(Parse("-sqrt(9)").value().cast<double>(), -3.0, EPS);
687+
EXPECT_NEAR(Parse("pow(abs(-2), 3)").value().cast<double>(), 8.0, EPS);
688+
EXPECT_NEAR(Parse("x:=3; abs(x - 5)").value().cast<double>(), 2.0, EPS);
689+
EXPECT_EQ(Parse("min(3, 5) == 3 ? 'yes' : 'no'").value().cast<std::string>(), "yes");
690+
691+
// Unknown function and wrong arity are rejected at parse time
692+
EXPECT_FALSE(BT::ValidateScript("nosuchfunc(1)"));
693+
EXPECT_FALSE(BT::ValidateScript("abs(1, 2)"));
694+
EXPECT_FALSE(BT::ValidateScript("pow(2)"));
695+
EXPECT_FALSE(BT::ValidateScript("clamp(1, 2)"));
696+
697+
// Function names remain usable as plain variable references
698+
EXPECT_EQ(Parse("abs:=7; abs").value().cast<double>(), 7.0);
699+
}
700+
650701
TEST(ParserTest, TernaryExpressions)
651702
{
652703
BT::Ast::Environment env = { BT::Blackboard::create(), {} };

0 commit comments

Comments
 (0)