From 45083ddc58f02a9add1c342fa11d1a15d4d33625 Mon Sep 17 00:00:00 2001 From: webzuweb Date: Mon, 28 Sep 2026 16:07:30 +0300 Subject: [PATCH] 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 . 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 '('. --- .../behaviortree_cpp/scripting/any_types.hpp | 1 + .../behaviortree_cpp/scripting/operators.hpp | 113 ++++++++++++++++++ src/script_parser.cpp | 39 +++++- src/script_tokenizer.cpp | 2 + tests/script_parser_test.cpp | 51 ++++++++ 5 files changed, 204 insertions(+), 2 deletions(-) diff --git a/include/behaviortree_cpp/scripting/any_types.hpp b/include/behaviortree_cpp/scripting/any_types.hpp index 1ddb23592..4f292c073 100644 --- a/include/behaviortree_cpp/scripting/any_types.hpp +++ b/include/behaviortree_cpp/scripting/any_types.hpp @@ -66,6 +66,7 @@ enum class TokenType // Delimiters LeftParen, RightParen, + Comma, Semicolon, // Control EndOfInput, diff --git a/include/behaviortree_cpp/scripting/operators.hpp b/include/behaviortree_cpp/scripting/operators.hpp index 285b5fc3d..08121cdbb 100644 --- a/include/behaviortree_cpp/scripting/operators.hpp +++ b/include/behaviortree_cpp/scripting/operators.hpp @@ -50,6 +50,87 @@ inline double StringToDouble(const Any& value, const Environment& env) return value.cast(); } +// Number of arguments expected by a built-in math function, or -1 if the name +// is not a known function. Used both by the parser (fail fast on unknown +// functions / wrong arity) and by the evaluator. +inline int BuiltinFunctionArity(const std::string& name) +{ + if(name == "abs" || name == "sqrt" || name == "sin" || name == "cos" || + name == "tan" || name == "asin" || name == "acos" || name == "atan" || + name == "exp" || name == "log" || name == "log10" || name == "floor" || + name == "ceil" || name == "round") + { + return 1; + } + if(name == "pow" || name == "atan2" || name == "min" || name == "max") + { + return 2; + } + if(name == "clamp") + { + return 3; + } + return -1; +} + +// Apply a built-in math function to already-evaluated numeric arguments. +// Throws RuntimeError for unknown functions (defensive; the parser should +// already have rejected them). +inline double ApplyMathFunction(const std::string& name, const std::vector& args) +{ + const int arity = BuiltinFunctionArity(name); + if(arity < 0) + { + throw RuntimeError(StrCat("Unknown function [", name, "]")); + } + if(static_cast(args.size()) != arity) + { + throw RuntimeError(StrCat("Function [", name, "] expects ", std::to_string(arity), + " argument(s), got ", std::to_string(args.size()))); + } + + if(name == "abs") + return std::abs(args[0]); + if(name == "sqrt") + return std::sqrt(args[0]); + if(name == "pow") + return std::pow(args[0], args[1]); + if(name == "sin") + return std::sin(args[0]); + if(name == "cos") + return std::cos(args[0]); + if(name == "tan") + return std::tan(args[0]); + if(name == "asin") + return std::asin(args[0]); + if(name == "acos") + return std::acos(args[0]); + if(name == "atan") + return std::atan(args[0]); + if(name == "atan2") + return std::atan2(args[0], args[1]); + if(name == "exp") + return std::exp(args[0]); + if(name == "log") + return std::log(args[0]); + if(name == "log10") + return std::log10(args[0]); + if(name == "floor") + return std::floor(args[0]); + if(name == "ceil") + return std::ceil(args[0]); + if(name == "round") + return std::round(args[0]); + if(name == "min") + return std::min(args[0], args[1]); + if(name == "max") + return std::max(args[0], args[1]); + if(name == "clamp") + return std::min(std::max(args[0], args[1]), args[2]); + + return 0.0; // unreachable +} + struct ExprBase { using Ptr = std::shared_ptr; @@ -105,6 +186,38 @@ struct ExprName : ExprBase } }; +struct ExprFunction : ExprBase +{ + std::string name; + std::vector args; + + explicit ExprFunction(std::string n, std::vector a) + : name(std::move(n)), args(std::move(a)) + {} + + Any evaluate(Environment& env) const override + { + std::vector values; + values.reserve(args.size()); + for(const auto& arg : args) + { + auto v = arg->evaluate(env); + if(v.empty()) + { + throw RuntimeError(StrCat("Argument of function [", name, + "] is not initialized")); + } + if(!v.isNumber()) + { + throw RuntimeError(StrCat("Function [", name, + "] expects numeric arguments")); + } + values.push_back(v.cast()); + } + return Any(ApplyMathFunction(name, values)); + } +}; + struct ExprUnaryArithmetic : ExprBase { enum op_t diff --git a/src/script_parser.cpp b/src/script_parser.cpp index bc237963b..1ef5a9762 100644 --- a/src/script_parser.cpp +++ b/src/script_parser.cpp @@ -242,11 +242,46 @@ class ScriptParser advance(); return std::make_shared(Any(std::string(tok.text))); } - // Identifier + // Identifier (variable reference or function call) if(tok.type == TokenType::Identifier) { + std::string name(tok.text); advance(); - return std::make_shared(std::string(tok.text)); + + // Function call: identifier followed by a parenthesized argument list. + if(check(TokenType::LeftParen)) + { + advance(); // consume '(' + std::vector args; + if(!check(TokenType::RightParen)) + { + args.push_back(parseExpr(0)); + while(check(TokenType::Comma)) + { + advance(); // consume ',' + args.push_back(parseExpr(0)); + } + } + expect(TokenType::RightParen, "expected ')' after function arguments"); + + // Fail fast on unknown functions and wrong arity so ValidateScript + // reports the problem at parse time instead of at evaluation time. + const int arity = Ast::BuiltinFunctionArity(name); + if(arity < 0) + { + throw RuntimeError(StrCat("Unknown function [", name, "] at position ", + std::to_string(tok.pos))); + } + if(static_cast(args.size()) != arity) + { + throw RuntimeError(StrCat("Function [", name, "] expects ", + std::to_string(arity), " argument(s), got ", + std::to_string(args.size()), " at position ", + std::to_string(tok.pos))); + } + return std::make_shared(std::move(name), std::move(args)); + } + return std::make_shared(std::move(name)); } // Error token from tokenizer if(tok.type == TokenType::Error) diff --git a/src/script_tokenizer.cpp b/src/script_tokenizer.cpp index 591f1e4ee..9105e48ec 100644 --- a/src/script_tokenizer.cpp +++ b/src/script_tokenizer.cpp @@ -211,6 +211,8 @@ TokenType matchSingleCharOp(char c) return TokenType::LeftParen; case ')': return TokenType::RightParen; + case ',': + return TokenType::Comma; case ';': return TokenType::Semicolon; default: diff --git a/tests/script_parser_test.cpp b/tests/script_parser_test.cpp index 775b506ec..74941fac8 100644 --- a/tests/script_parser_test.cpp +++ b/tests/script_parser_test.cpp @@ -647,6 +647,57 @@ TEST(ParserTest, UnaryOperators) EXPECT_EQ(Parse("10 + -3").value().cast(), 7.0); } +// https://github.com/BehaviorTree/BehaviorTree.CPP/issues/1141 +TEST(ParserTest, MathFunctions) +{ + BT::Ast::Environment env = { BT::Blackboard::create(), {} }; + auto Parse = [&env](const char* str) { return BT::ParseScriptAndExecute(env, str); }; + + constexpr double EPS = 1e-9; + + // Unary functions + EXPECT_NEAR(Parse("abs(-3)").value().cast(), 3.0, EPS); + EXPECT_NEAR(Parse("abs(3)").value().cast(), 3.0, EPS); + EXPECT_NEAR(Parse("sqrt(9)").value().cast(), 3.0, EPS); + EXPECT_NEAR(Parse("sin(0)").value().cast(), 0.0, EPS); + EXPECT_NEAR(Parse("cos(0)").value().cast(), 1.0, EPS); + EXPECT_NEAR(Parse("tan(0)").value().cast(), 0.0, EPS); + EXPECT_NEAR(Parse("asin(1)").value().cast(), std::asin(1.0), EPS); + EXPECT_NEAR(Parse("acos(1)").value().cast(), std::acos(1.0), EPS); + EXPECT_NEAR(Parse("atan(1)").value().cast(), std::atan(1.0), EPS); + EXPECT_NEAR(Parse("exp(0)").value().cast(), 1.0, EPS); + EXPECT_NEAR(Parse("log(1)").value().cast(), 0.0, EPS); + EXPECT_NEAR(Parse("log10(100)").value().cast(), 2.0, EPS); + EXPECT_NEAR(Parse("floor(3.7)").value().cast(), 3.0, EPS); + EXPECT_NEAR(Parse("ceil(3.2)").value().cast(), 4.0, EPS); + EXPECT_NEAR(Parse("round(3.5)").value().cast(), 4.0, EPS); + + // Binary / ternary functions + EXPECT_NEAR(Parse("pow(2, 10)").value().cast(), 1024.0, EPS); + EXPECT_NEAR(Parse("atan2(1, 1)").value().cast(), std::atan2(1.0, 1.0), EPS); + EXPECT_NEAR(Parse("min(3, 5)").value().cast(), 3.0, EPS); + EXPECT_NEAR(Parse("max(3, 5)").value().cast(), 5.0, EPS); + EXPECT_NEAR(Parse("clamp(10, 0, 5)").value().cast(), 5.0, EPS); + EXPECT_NEAR(Parse("clamp(-1, 0, 5)").value().cast(), 0.0, EPS); + EXPECT_NEAR(Parse("clamp(3, 0, 5)").value().cast(), 3.0, EPS); + + // Composition with operators and variables + EXPECT_NEAR(Parse("sqrt(9) + 1").value().cast(), 4.0, EPS); + EXPECT_NEAR(Parse("-sqrt(9)").value().cast(), -3.0, EPS); + EXPECT_NEAR(Parse("pow(abs(-2), 3)").value().cast(), 8.0, EPS); + EXPECT_NEAR(Parse("x:=3; abs(x - 5)").value().cast(), 2.0, EPS); + EXPECT_EQ(Parse("min(3, 5) == 3 ? 'yes' : 'no'").value().cast(), "yes"); + + // Unknown function and wrong arity are rejected at parse time + EXPECT_FALSE(BT::ValidateScript("nosuchfunc(1)")); + EXPECT_FALSE(BT::ValidateScript("abs(1, 2)")); + EXPECT_FALSE(BT::ValidateScript("pow(2)")); + EXPECT_FALSE(BT::ValidateScript("clamp(1, 2)")); + + // Function names remain usable as plain variable references + EXPECT_EQ(Parse("abs:=7; abs").value().cast(), 7.0); +} + TEST(ParserTest, TernaryExpressions) { BT::Ast::Environment env = { BT::Blackboard::create(), {} };