diff --git a/data b/data index 8ac544afdc..e0857e915d 160000 --- a/data +++ b/data @@ -1 +1 @@ -Subproject commit 8ac544afdc0d75e9cfe0681f9eaa8f2150534dea +Subproject commit e0857e915d695a936e16ec4742b928400855ced0 diff --git a/src/axom/inlet/Container.cpp b/src/axom/inlet/Container.cpp index 7fc8f8d623..1c98d3b959 100644 --- a/src/axom/inlet/Container.cpp +++ b/src/axom/inlet/Container.cpp @@ -310,16 +310,16 @@ Field& Container::addField(axom::sidre::Group* sidreGroup, return *(emplace_result.first->second); } -Function& Container::addFunctionInternal(axom::sidre::Group* sidreGroup, - FunctionVariant&& func, - const std::string& fullName, - const std::string& name) +Function& Container::storeFunction(axom::sidre::Group* sidreGroup, + FunctionVariant&& func, + const std::string& fullName, + const std::string& name) { const size_t found = name.find_last_of("/"); auto currContainer = this; if(found != std::string::npos) { - // This will add any intermediate Containers (if not present) before adding the field + // This will add any intermediate Containers (if not present) before storing the function currContainer = &addContainer(name.substr(0, found)); } const auto& emplace_result = currContainer->m_functionChildren.emplace( @@ -376,7 +376,8 @@ VerifiableScalar& Container::addPrimitive(const std::string& name, lookupPath = utilities::string::removeAllInstances(lookupPath, detail::COLLECTION_GROUP_NAME + "/"); detail::updateUnexpectedNames(lookupPath, m_unexpectedNames); - auto typeId = addPrimitiveHelper(sidreGroup, lookupPath, forArray, val); + auto typeId = + addPrimitiveHelper(sidreGroup, lookupPath, forArray, val, containsFunctionValueAlternative(name)); return addField(sidreGroup, typeId, fullName, name); } } @@ -385,9 +386,16 @@ template <> axom::sidre::DataTypeId Container::addPrimitiveHelper(axom::sidre::Group* sidreGroup, const std::string& lookupPath, bool forArray, - bool val) + bool val, + bool hasFunctionAlternative) { - const auto result = m_reader.getBool(lookupPath, val); + auto result = m_reader.getBool(lookupPath, val); + if(hasFunctionAlternative && result == ReaderResult::WrongType) + { + // The input supplied the function representation of this value, so the + // concrete entry is absent rather than of the wrong type. + result = ReaderResult::NotFound; + } if(forArray || result == ReaderResult::Success) { sidreGroup->createViewScalar("value", val ? std::int8_t(1) : std::int8_t(0)); @@ -403,9 +411,16 @@ template <> axom::sidre::DataTypeId Container::addPrimitiveHelper(axom::sidre::Group* sidreGroup, const std::string& lookupPath, bool forArray, - int val) + int val, + bool hasFunctionAlternative) { - const auto result = m_reader.getInt(lookupPath, val); + auto result = m_reader.getInt(lookupPath, val); + if(hasFunctionAlternative && result == ReaderResult::WrongType) + { + // The input supplied the function representation of this value, so the + // concrete entry is absent rather than of the wrong type. + result = ReaderResult::NotFound; + } if(forArray || result == ReaderResult::Success) { sidreGroup->createViewScalar("value", val); @@ -421,9 +436,16 @@ template <> axom::sidre::DataTypeId Container::addPrimitiveHelper(axom::sidre::Group* sidreGroup, const std::string& lookupPath, bool forArray, - double val) + double val, + bool hasFunctionAlternative) { - const auto result = m_reader.getDouble(lookupPath, val); + auto result = m_reader.getDouble(lookupPath, val); + if(hasFunctionAlternative && result == ReaderResult::WrongType) + { + // The input supplied the function representation of this value, so the + // concrete entry is absent rather than of the wrong type. + result = ReaderResult::NotFound; + } if(forArray || result == ReaderResult::Success) { sidreGroup->createViewScalar("value", val); @@ -439,9 +461,16 @@ template <> axom::sidre::DataTypeId Container::addPrimitiveHelper(axom::sidre::Group* sidreGroup, const std::string& lookupPath, bool forArray, - std::string val) + std::string val, + bool hasFunctionAlternative) { - const auto result = m_reader.getString(lookupPath, val); + auto result = m_reader.getString(lookupPath, val); + if(hasFunctionAlternative && result == ReaderResult::WrongType) + { + // The input supplied the function representation of this value, + // so the concrete entry is absent rather than of the wrong type. + result = ReaderResult::NotFound; + } if(forArray || result == ReaderResult::Success) { sidreGroup->createViewString("value", val); @@ -854,7 +883,13 @@ Verifiable& Container::addPrimitiveArray(const std::string& name, utilities::string::removeAllInstances(lookupPath, detail::COLLECTION_GROUP_NAME + "/"); detail::updateUnexpectedNames(lookupPath, m_unexpectedNames); std::vector indices; - if(isDict) + if(containsFunctionValueAlternative(name)) + { + // The input supplied the function representation of this collection, + // so the concrete entry is absent rather than of the wrong type. + markRetrievalStatus(*container.sidreGroup(), ReaderResult::NotFound); + } + else if(isDict) { indices = detail::PrimitiveArrayHelper::add(container, m_reader, lookupPath); } @@ -897,17 +932,54 @@ Verifiable& Container::addFunction(const std::string& name, const std::string& description, const std::string& pathOverride) { - // If it has indices, we're adding a function to an array - // of structs, so we need to iterate over the subcontainers - // corresponding to elements of the array + return addFunctionInternal(name, name, ret_type, arg_types, description, pathOverride); +} + +Verifiable& Container::addFunctionAsValueAlternative(const std::string& valueName, + const FunctionTag ret_type, + const std::vector& arg_types, + const std::string& description) +{ + SLIC_ERROR_IF(valueName.empty(), + "[Inlet] A function value alternative requires a non-empty value name"); + SLIC_ERROR_IF(ret_type == FunctionTag::Void, + "[Inlet] A function value alternative requires a non-void return type"); + // Declaring the concrete entry first would already have marked it as being of + // the wrong type, which surfaces later as a confusing verification failure + // blaming the input rather than the schema. Reject it here instead. + SLIC_ERROR_IF( + getChildInternal(valueName) != nullptr || getChildInternal(valueName) != nullptr, + fmt::format("[Inlet] The function value alternative for '{0}' must be declared before '{0}'", + valueName)); + // The alternative is read from the concrete value's input path but is stored + // under a distinct schema name so the two entries do not collide. + return addFunctionInternal(detail::functionAlternativeName(valueName), + valueName, + ret_type, + arg_types, + description, + ""); +} + +Verifiable& Container::addFunctionInternal(const std::string& schemaName, + const std::string& inputName, + const FunctionTag ret_type, + const std::vector& arg_types, + const std::string& description, + const std::string& pathOverride) +{ + // If it has indices, we're adding a function to an array of structs, + // so we need to iterate over the subcontainers corresponding to elements of the array std::vector>> funcs; const bool is_nested = transformFromNestedElements( std::back_inserter(funcs), - name, - [&name, &ret_type, &arg_types, &description](Container& subcontainer, - const std::string& path) -> Verifiable& { - return subcontainer.addFunction(name, ret_type, arg_types, description, path); + inputName, + [&schemaName, &inputName, &ret_type, &arg_types, &description]( + Container& subcontainer, + const std::string& path) -> Verifiable& { + return subcontainer + .addFunctionInternal(schemaName, inputName, ret_type, arg_types, description, path); }); if(is_nested) { @@ -921,7 +993,7 @@ Verifiable& Container::addFunction(const std::string& name, else { // Otherwise actually add a Function - std::string fullName = utilities::string::appendPrefix(m_name, name); + std::string fullName = utilities::string::appendPrefix(m_name, schemaName); // First check if the function already exists auto iter = m_functionChildren.find(fullName); if(iter != m_functionChildren.end()) @@ -933,13 +1005,14 @@ Verifiable& Container::addFunction(const std::string& name, fmt::format("Failed to create Sidre group with name '{0}'", fullName)); detail::addSignatureToGroup(ret_type, arg_types, sidreGroup); // If a pathOverride is specified, needed when Inlet-internal groups - // are part of fullName - std::string lookupPath = (pathOverride.empty()) ? fullName : pathOverride; + // are part of the input path + std::string lookupPath = + (pathOverride.empty()) ? utilities::string::appendPrefix(m_name, inputName) : pathOverride; lookupPath = utilities::string::removeAllInstances(lookupPath, detail::COLLECTION_GROUP_NAME + "/"); detail::updateUnexpectedNames(lookupPath, m_unexpectedNames); auto func = m_reader.getFunction(lookupPath, ret_type, arg_types); - return addFunctionInternal(sidreGroup, std::move(func), fullName, name); + return storeFunction(sidreGroup, std::move(func), fullName, schemaName); } } @@ -1303,6 +1376,13 @@ bool Container::isUserProvided() const bool Container::isUserProvided(const std::string& name) const { + // A function value alternative is stored under a different schema name than + // the value it applies to, so it is not found by the child lookups below + if(containsFunctionValueAlternative(name)) + { + return true; + } + if(auto container = getChildInternal(name)) { // Check if the container itself was provided by the user @@ -1336,5 +1416,33 @@ const std::unordered_map>& Container::get return m_functionChildren; } +bool Container::containsFunctionValueAlternative(const std::string& valueName) const +{ + auto function = getChildInternal(detail::functionAlternativeName(valueName)); + return function != nullptr && static_cast(*function); +} + +const Function& Container::getFunctionValueAlternative(const std::string& valueName) const +{ + return getFunction(detail::functionAlternativeName(valueName)); +} + +std::vector Container::getFunctionValueAlternativeNames() const +{ + std::vector names; + for(const auto& entry : m_functionChildren) + { + const std::string childName = Path(entry.first).baseName(); + if(detail::isFunctionAlternativeName(childName) && static_cast(*entry.second)) + { + names.push_back( + childName.substr(0, childName.size() - detail::FUNCTION_ALTERNATIVE_SUFFIX.size())); + } + } + // m_functionChildren is unordered, so sort for a reproducible result + std::sort(names.begin(), names.end()); + return names; +} + } // namespace inlet } // namespace axom diff --git a/src/axom/inlet/Container.hpp b/src/axom/inlet/Container.hpp index d2387e40c4..29b3538e81 100644 --- a/src/axom/inlet/Container.hpp +++ b/src/axom/inlet/Container.hpp @@ -18,10 +18,11 @@ #include #include #include +#include #include #include -#include #include +#include #include #include "axom/fmt.hpp" @@ -348,6 +349,33 @@ std::vector> collectionIndicesWithPaths(cons void updateUnexpectedNames(const std::string& accessedName, std::vector& unexpectedNames); +/*! + ******************************************************************************* + * \brief Returns the schema name of the function alternative for a value + * + * \param [in] valueName The name of the concrete value or collection + * + * \note The alternative is read from \a valueName but stored under a distinct + * schema name so that it does not collide with the concrete entry + ******************************************************************************* + */ +inline std::string functionAlternativeName(const std::string& valueName) +{ + return valueName + detail::FUNCTION_ALTERNATIVE_SUFFIX; +} + +/*! + ******************************************************************************* + * \brief Returns whether a schema name belongs to a function value alternative + * + * \param [in] schemaName The name of a schema entry + ******************************************************************************* + */ +inline bool isFunctionAlternativeName(const std::string& schemaName) +{ + return axom::utilities::string::endsWith(schemaName, detail::FUNCTION_ALTERNATIVE_SUFFIX); +} + } // namespace detail class Proxy; @@ -734,8 +762,8 @@ class Container : public Verifiable * \param [in] ret_type The return type of the function * \param [in] arg_types The argument types of the function * \param [in] description Description of the function - * \param [in] pathOverride The path within the input file to read from, if - * different than the structure of the Sidre datastore + * \param [in] pathOverride The path within the input file to read from, + * if different than the structure of the Sidre datastore * * \return Reference to the created Function ***************************************************************************** @@ -746,6 +774,33 @@ class Container : public Verifiable const std::string& description = "", const std::string& pathOverride = ""); + /*! + ***************************************************************************** + * \brief Add a function that is an alternative representation of a primitive + * value or collection in the input deck. + * + * The function is read from the same public value name as the concrete field or collection. + * If a function exists there, the concrete schema entry is treated as absent + * rather than as having the wrong type. + * + * \param [in] valueName Path of the concrete value or collection, + * relative to this Container + * \param [in] ret_type The return type. Must not be FunctionTag::Void + * \param [in] arg_types The argument types of the function + * \param [in] description Description of the function + * + * \return Reference to the created Function + * + * \note The alternative must be declared before the concrete schema entry it applies to, + * so that the concrete entry can be suppressed when the input supplies a function. + * Declaring it afterwards is an error. + ***************************************************************************** + */ + Verifiable& addFunctionAsValueAlternative(const std::string& valueName, + FunctionTag ret_type, + const std::vector& arg_types, + const std::string& description = ""); + /*! ******************************************************************************* * \brief Returns a stored value of primitive type. @@ -967,36 +1022,40 @@ class Container : public Verifiable /*! ***************************************************************************** - * \brief Return whether a Container or Field with the given name is present in - * this Container's subtree. + * \brief Return whether a Container, Field, or ordinary Function with the + * given name is present in this Container's subtree. + * + * A function value alternative at \a name is not reported by this method. + * Use \a containsFunctionValueAlternative() to query that representation. + * An ancestor Container is present when an alternative exists below it. * - * \return Boolean value indicating whether this Container's subtree contains a - * Field or Container with the given name. + * \param [in] name Path relative to this Container + * + * \return Whether the named schema entry is present ***************************************************************************** */ bool contains(const std::string& name) const; /*! ***************************************************************************** - * \brief Returns whether this container or any of its subcontainers exist, - * i.e., if they contain a Field or Function that exists + * \brief Return whether this Container contains a present Field, ordinary + * Function, function value alternative, or subcontainer containing one. ***************************************************************************** */ bool exists() const; /*! ***************************************************************************** - * \brief Returns whether this container or any of its subcontainers were - * provided in the input file, i.e., if they contain a Field or Function that - * was provided in the input file + * \brief Return whether this Container contains a user-provided Field, + * ordinary Function, function value alternative, or subcontainer containing one. ***************************************************************************** */ bool isUserProvided() const; /*! ***************************************************************************** - * \brief Return whether a Container or Field with the given name were - * provided in the input file + * \brief Return whether the named Container, Field, ordinary Function, + * or function value alternative was provided in the input file. * * \param [in] name The path relative to the calling Container to search ***************************************************************************** @@ -1027,6 +1086,43 @@ class Container : public Verifiable */ const std::unordered_map>& getChildFunctions() const; + /*! + ***************************************************************************** + * \brief Return whether a function value alternative was supplied for the + * given public value name. + * + * \param [in] valueName Value path relative to this Container + * + * \return True when the input supplied a function for \a valueName + ***************************************************************************** + */ + bool containsFunctionValueAlternative(const std::string& valueName) const; + + /*! + ***************************************************************************** + * \brief Retrieve the function value alternative associated with a public + * value name. + * + * Call containsFunctionValueAlternative() first to determine whether the + * function representation was supplied. + * + * \param [in] valueName Value path relative to this Container + * + * \return The Function declared as the alternative for \a valueName + ***************************************************************************** + */ + const Function& getFunctionValueAlternative(const std::string& valueName) const; + + /*! + ***************************************************************************** + * \brief Return the public value names of supplied function alternatives. + * + * \return Sorted names of direct child values whose function representation + * was supplied + ***************************************************************************** + */ + std::vector getFunctionValueAlternativeNames() const; + /*! ***************************************************************************** * \return The full name of this Container. @@ -1192,6 +1288,9 @@ class Container : public Verifiable * the input file * \param [in] val A provided value, will be overwritten if found at specified * path in input file + * \param [in] hasFunctionAlternative Whether a function value alternative was + * supplied at \a lookupPath, in which case a wrong-type read is reported as + * absent instead * * \return Type ID for the inserted view ***************************************************************************** @@ -1201,7 +1300,8 @@ class Container : public Verifiable axom::sidre::DataTypeId addPrimitiveHelper(axom::sidre::Group* sidreGroup, const std::string& lookupPath, bool forArray, - T val); + T val, + bool hasFunctionAlternative); /*! ***************************************************************************** @@ -1251,21 +1351,47 @@ class Container : public Verifiable /*! ***************************************************************************** - * \brief Adds the Function. + * \brief Stores an already-read Function in this Container's schema. * - * \param [in] The Sidre Group corresponding to the Function that will be added. - * \param [in] func The actual callable to store - * \param [in] The complete Container sequence for the Container this Function will be added to. - * \param [in] The Container sequence for the Container this Function will be added to, - * relative to this Container. + * \param [in] sidreGroup The Sidre Group corresponding to the Function + * \param [in] func The callable to store + * \param [in] fullName Complete Container path for the Function + * \param [in] name Function path relative to this Container * * \return The child Function matching the target name. ***************************************************************************** */ - Function& addFunctionInternal(axom::sidre::Group* sidreGroup, - FunctionVariant&& func, - const std::string& fullName, - const std::string& name); + Function& storeFunction(axom::sidre::Group* sidreGroup, + FunctionVariant&& func, + const std::string& fullName, + const std::string& name); + + /*! + ***************************************************************************** + * \brief Adds a Function whose schema name may differ from the input path it + * is read from. + * + * This backs both addFunction(), where the two names are identical, and + * addFunctionAsValueAlternative(), where the function is read from a concrete + * value's input path but stored under a distinct schema name. + * + * \param [in] schemaName The name of the Function within this Container + * \param [in] inputName The name to read from in the input file + * \param [in] ret_type The return type of the function + * \param [in] arg_types The argument types of the function + * \param [in] description Description of the function + * \param [in] pathOverride The path within the input file to read from, if + * different than the structure of the Sidre datastore + * + * \return Reference to the created Function + ***************************************************************************** + */ + Verifiable& addFunctionInternal(const std::string& schemaName, + const std::string& inputName, + FunctionTag ret_type, + const std::vector& arg_types, + const std::string& description, + const std::string& pathOverride); axom::sidre::View* baseGet(const std::string& name) const; diff --git a/src/axom/inlet/Function.hpp b/src/axom/inlet/Function.hpp index bd7dd9b4e7..b1dfaf28ca 100644 --- a/src/axom/inlet/Function.hpp +++ b/src/axom/inlet/Function.hpp @@ -206,6 +206,8 @@ class FunctionWrapper * \tparam Args The types of the user-specified arguments, deduced automatically * * \return The function's result + * \throws InletError if an input function fails during evaluation + * or its return value cannot be converted to the declared return type ******************************************************************************* */ template @@ -298,6 +300,20 @@ class Function : public Verifiable return m_func.get(); } + /*! + ***************************************************************************** + * \brief Calls the function + * + * \param [in] args The parameter pack for the function's arguments + * \tparam Ret The user-specified return type, needed to fully disambiguate the + * function to call + * \tparam Args The types of the user-specified arguments, deduced automatically + * + * \return The function's result + * \throws InletError if an input function fails during evaluation + * or its return value cannot be converted to the declared return type + ***************************************************************************** + */ template Ret call(Args&&... args) const { diff --git a/src/axom/inlet/Inlet.hpp b/src/axom/inlet/Inlet.hpp index 7168e9c177..f81bcd7216 100644 --- a/src/axom/inlet/Inlet.hpp +++ b/src/axom/inlet/Inlet.hpp @@ -430,6 +430,31 @@ class Inlet { return m_globalContainer.addFunction(name, ret_type, arg_types, description); } + + /*! + ***************************************************************************** + * \brief Add a function that is an alternative representation of a primitive + * value or collection in the input deck. + * + * \param [in] valueName Path of the concrete value or collection, + * relative to the root Container + * \param [in] ret_type The return type. Must not be FunctionTag::Void + * \param [in] arg_types The argument types of the function + * \param [in] description Description of the function + * + * \return Reference to the created Function + * + * \see Container::addFunctionAsValueAlternative + ***************************************************************************** + */ + Verifiable& addFunctionAsValueAlternative(const std::string& valueName, + FunctionTag ret_type, + const std::vector& arg_types, + const std::string& description = "") + { + return m_globalContainer.addFunctionAsValueAlternative(valueName, ret_type, arg_types, description); + } + /*! ***************************************************************************** * \brief Add a dictionary of Boolean Fields to the input file schema. diff --git a/src/axom/inlet/LuaReader.cpp b/src/axom/inlet/LuaReader.cpp index 9c6fd9e2e8..0353f1b026 100644 --- a/src/axom/inlet/LuaReader.cpp +++ b/src/axom/inlet/LuaReader.cpp @@ -12,7 +12,10 @@ ******************************************************************************* */ +#include #include +#include +#include #include "axom/inlet/LuaReader.hpp" @@ -312,44 +315,36 @@ ReaderResult LuaReader::getVariantMap(const std::string& id, return getVariantMapInternal(id, values); } -template -bool LuaReader::traverseToTable(Iter begin, Iter end, axom::sol::table& table) +axom::sol::object LuaReader::getObject(const std::string& id) { - // Nothing to traverse - if(begin == end) + const auto tokens = axom::utilities::string::split(id, SCOPE_DELIMITER); + if(tokens.empty()) { - return true; - } - - if(!(*m_lua)[*begin].valid()) - { - return false; + return {}; } - // Use the first one to index into the global lua state - table = (*m_lua)[*begin]; - ++begin; - - // Then use the remaining keys to walk down to the requested table - for(auto curr = begin; curr != end; ++curr) + axom::sol::object object = (*m_lua)[tokens.front()]; + for(std::size_t i = 1; i < tokens.size(); ++i) { - auto key = *curr; - bool is_int = conduit::utils::string_is_integer(key); - int key_as_int = conduit::utils::string_to_value(key); - if(is_int && table[key_as_int].valid()) + if(!object.valid() || object.get_type() != axom::sol::type::table) { - table = table[key_as_int]; + return {}; } - else if(table[key].valid()) + + const auto table = object.as(); + const auto& key = tokens[i]; + axom::sol::object child; + if(conduit::utils::string_is_integer(key)) { - table = table[key]; + child = table[conduit::utils::string_to_value(key)]; } - else + if(!child.valid()) { - return false; + child = table[key]; } + object = std::move(child); } - return true; + return object; } ReaderResult LuaReader::getIndices(const std::string& id, std::vector& indices) @@ -371,18 +366,26 @@ namespace detail * \brief Templated function for calling a sol function * * \param [in] func The sol function of unknown concrete type + * \param [in] args Arguments forwarded to the Lua function * \tparam Args The argument types of the function * * \return A checkable version of the function's result + * \throws InletError if the Lua function reports an execution error ***************************************************************************** */ template axom::sol::protected_function_result callWith(const axom::sol::protected_function& func, Args&&... args) { + // Lua functions are exposed to clients as std::functions that can be invoked + // after schema verification. Use a catchable failure here so those clients can + // add context; SLIC errors may abort or only log and continue. auto tentative_result = func(std::forward(args)...); - SLIC_ERROR_IF(!tentative_result.valid(), - "[Inlet] Lua function call failed, argument types possibly incorrect"); + if(!tentative_result.valid()) + { + axom::sol::error err = tentative_result; + throw InletError(fmt::format("[Inlet] Lua function call failed: {0}", err.what())); + } return tentative_result; } @@ -395,13 +398,19 @@ axom::sol::protected_function_result callWith(const axom::sol::protected_functio * \tparam Ret The return type of the function * * \return The function's result + * \throws InletError if the result cannot be converted to \a Ret ***************************************************************************** */ template Ret extractResult(axom::sol::protected_function_result&& res) { axom::sol::optional option = res; - SLIC_ERROR_IF(!option, "[Inlet] Lua function call failed, return types possibly incorrect"); + if(!option) + { + // A failed result conversion is a runtime input error for this function + // call. Throwing avoids dereferencing an empty optional after a SLIC log. + throw InletError("[Inlet] Lua function call failed, return types possibly incorrect"); + } return option.value(); } @@ -409,12 +418,79 @@ template <> FunctionType::Void extractResult(axom::sol::protected_function_result&&) { } +template <> +FunctionType::Vector extractResult(axom::sol::protected_function_result&& res) +{ + // Keep Vector.new(...) returns supported, but also accept raw numeric Lua + // tables so input decks can write idiomatic vector callbacks such as + // function() return {1.0, 2.0, 3.0} end. + axom::sol::optional vector_option = res; + if(vector_option) + { + return vector_option.value(); + } + + axom::sol::optional table_option = res; + if(table_option) + { + axom::sol::table table = table_option.value(); + std::array values {{0., 0., 0.}}; + std::array seen {{false, false, false}}; + int count = 0; + + for(const auto& entry : table) + { + if(entry.first.get_type() != axom::sol::type::number) + { + throw InletError("[Inlet] Lua vector function return must only contain numeric indices"); + } + + const double numeric_index = entry.first.as(); + const int index = entry.first.as(); + if(static_cast(index) != numeric_index || index < 1 || index > 3) + { + throw InletError( + "[Inlet] Lua vector function return indices must be integers between 1 and 3"); + } + if(entry.second.get_type() != axom::sol::type::number) + { + throw InletError("[Inlet] Lua vector function return components must be numeric"); + } + + values[index - 1] = entry.second.as(); + seen[index - 1] = true; + ++count; + } + + if(count < 1 || count > 3) + { + throw InletError( + fmt::format("[Inlet] Lua vector function returned a table with {0} entries; " + "expected 1 to 3 numeric entries", + count)); + } + for(int i = 0; i < count; ++i) + { + if(!seen[i]) + { + throw InletError( + "[Inlet] Lua vector function return indices must be contiguous starting at 1"); + } + } + + return FunctionType::Vector {values.data(), count}; + } + + throw InletError("[Inlet] Lua function call failed, return types possibly incorrect"); +} + /*! ***************************************************************************** * \brief Creates a std::function given a Lua function and template parameters * corresponding to the function signature * * \param [in] func The sol object containing the lua function of unknown signature + * \param [in] lua_state Shared ownership of the Lua state used by \a func * \tparam Ret The return type of the function * \tparam Args... The argument types of the function * @@ -426,10 +502,13 @@ FunctionType::Void extractResult(axom::sol::protected_functi */ template std::function::type...)> buildStdFunction( - axom::sol::protected_function&& func) + axom::sol::protected_function&& func, + std::shared_ptr lua_state) { - // Generalized lambda capture needed to move into lambda - return [func(std::move(func))](typename detail::inlet_function_arg_type::type... args) { + // Keep the Lua state alive for the lifetime of callbacks returned to callers. + return [lua_state(std::move(lua_state)), + func(std::move(func))](typename detail::inlet_function_arg_type::type... args) { + SLIC_ASSERT(lua_state); return extractResult(callWith(func, args...)); }; } @@ -441,6 +520,7 @@ std::function::type...)> buil * * \param [in] func The sol object containing the lua function of unknown signature * \param [in] arg_types The vector of argument types + * \param [in] lua_state Shared ownership of the Lua state used by \a func * * \tparam I The number of arguments processed, or "stack size", used to mitigate * infinite compile-time recursion @@ -454,7 +534,8 @@ std::function::type...)> buil template typename std::enable_if<(I > MAX_NUM_ARGS), FunctionVariant>::type bindArgType( axom::sol::protected_function&&, - const std::vector&) + const std::vector&, + std::shared_ptr) { SLIC_ERROR("[Inlet] Maximum number of function arguments exceeded: " << I); return {}; @@ -463,22 +544,29 @@ typename std::enable_if<(I > MAX_NUM_ARGS), FunctionVariant>::type bindArgType( template typename std::enable_if::type bindArgType( axom::sol::protected_function&& func, - const std::vector& arg_types) + const std::vector& arg_types, + std::shared_ptr lua_state) { if(arg_types.size() == I) { - return buildStdFunction(std::move(func)); + return buildStdFunction(std::move(func), std::move(lua_state)); } else { switch(arg_types[I]) { case FunctionTag::Vector: - return bindArgType(std::move(func), arg_types); + return bindArgType(std::move(func), + arg_types, + std::move(lua_state)); case FunctionTag::Double: - return bindArgType(std::move(func), arg_types); + return bindArgType(std::move(func), + arg_types, + std::move(lua_state)); case FunctionTag::String: - return bindArgType(std::move(func), arg_types); + return bindArgType(std::move(func), + arg_types, + std::move(lua_state)); default: SLIC_ERROR("[Inlet] Unexpected function argument type"); } @@ -488,22 +576,21 @@ typename std::enable_if::type bindArgType( /*! ***************************************************************************** - * \brief Performs a type-checked access to a Lua table + * \brief Performs a type-checked access to a Lua object * - * \param [in] proxy The axom::sol::proxy object to retrieve from + * \param [in] object The Lua object to retrieve from * \param [out] val The value to write to, if it is of the correct type * * \return ReaderResult::Success if the object was of the correct type, * ReaderResult::WrongType otherwise ***************************************************************************** */ -template -ReaderResult checkedGet(const Proxy& proxy, Value& val) +template +ReaderResult checkedGet(const axom::sol::object& object, Value& val) { - axom::sol::optional option = proxy; - if(option) + if(object.template is()) { - val = option.value(); + val = object.template as(); return ReaderResult::Success; } return ReaderResult::WrongType; @@ -521,13 +608,13 @@ FunctionVariant LuaReader::getFunction(const std::string& id, switch(ret_type) { case FunctionTag::Vector: - return detail::bindArgType<0u, FunctionType::Vector>(std::move(lua_func), arg_types); + return detail::bindArgType<0u, FunctionType::Vector>(std::move(lua_func), arg_types, m_lua); case FunctionTag::Double: - return detail::bindArgType<0u, double>(std::move(lua_func), arg_types); + return detail::bindArgType<0u, double>(std::move(lua_func), arg_types, m_lua); case FunctionTag::Void: - return detail::bindArgType<0u, void>(std::move(lua_func), arg_types); + return detail::bindArgType<0u, void>(std::move(lua_func), arg_types, m_lua); case FunctionTag::String: - return detail::bindArgType<0u, std::string>(std::move(lua_func), arg_types); + return detail::bindArgType<0u, std::string>(std::move(lua_func), arg_types, m_lua); default: SLIC_ERROR("[Inlet] Unexpected function return type"); } @@ -538,28 +625,13 @@ FunctionVariant LuaReader::getFunction(const std::string& id, template ReaderResult LuaReader::getValue(const std::string& id, T& value) { - std::vector tokens = axom::utilities::string::split(id, SCOPE_DELIMITER); - - if(tokens.size() == 1) + const auto object = getObject(id); + if(!object.valid()) { - if((*m_lua)[tokens[0]].valid()) - { - return detail::checkedGet((*m_lua)[tokens[0]], value); - } return ReaderResult::NotFound; } - axom::sol::table t; - // Don't traverse through the last token as it doesn't contain a table - if(traverseToTable(tokens.begin(), tokens.end() - 1, t)) - { - if(t[tokens.back()].valid()) - { - return detail::checkedGet(t[tokens.back()], value); - } - } - - return ReaderResult::NotFound; + return detail::checkedGet(object, value); } std::vector LuaReader::getAllNames() @@ -575,13 +647,17 @@ ReaderResult LuaReader::getMap(const std::string& id, axom::sol::type type) { values.clear(); - std::vector tokens = axom::utilities::string::split(id, SCOPE_DELIMITER); - - axom::sol::table t; - if(tokens.empty() || !traverseToTable(tokens.begin(), tokens.end(), t)) + const auto object = getObject(id); + if(!object.valid()) { return ReaderResult::NotFound; } + if(object.get_type() != axom::sol::type::table) + { + return ReaderResult::WrongType; + } + + const auto table = object.as(); // Allows for filtering out keys of incorrect type const auto is_correct_key_type = [](const axom::sol::type type) { @@ -598,7 +674,7 @@ ReaderResult LuaReader::getMap(const std::string& id, } }; bool contains_other_type = false; - for(const auto& entry : t) + for(const auto& entry : table) { // Gets only indexed items in the table. if(is_correct_key_type(entry.first.get_type()) && entry.second.get_type() == type) @@ -618,13 +694,17 @@ ReaderResult LuaReader::getVariantMapInternal(const std::string& id, std::unordered_map& values) { values.clear(); - std::vector tokens = axom::utilities::string::split(id, SCOPE_DELIMITER); - - axom::sol::table t; - if(tokens.empty() || !traverseToTable(tokens.begin(), tokens.end(), t)) + const auto object = getObject(id); + if(!object.valid()) { return ReaderResult::NotFound; } + if(object.get_type() != axom::sol::type::table) + { + return ReaderResult::WrongType; + } + + const auto table = object.as(); const auto is_correct_key_type = [](const axom::sol::type type) { const bool is_number = type == axom::sol::type::number; @@ -639,7 +719,7 @@ ReaderResult LuaReader::getVariantMapInternal(const std::string& id, }; bool contains_other_type = false; - for(const auto& entry : t) + for(const auto& entry : table) { VariantValue value; if(is_correct_key_type(entry.first.get_type()) && detail::extractVariantValue(entry.second, value)) @@ -657,19 +737,20 @@ ReaderResult LuaReader::getVariantMapInternal(const std::string& id, template ReaderResult LuaReader::getIndicesInternal(const std::string& id, std::vector& indices) { - std::vector tokens = axom::utilities::string::split(id, SCOPE_DELIMITER); - - axom::sol::table t; - - if(tokens.empty() || !traverseToTable(tokens.begin(), tokens.end(), t)) + indices.clear(); + const auto object = getObject(id); + if(!object.valid()) { return ReaderResult::NotFound; } + if(object.get_type() != axom::sol::type::table) + { + return ReaderResult::WrongType; + } - indices.clear(); - + const auto table = object.as(); // std::transform ends up being messier here - for(const auto& entry : t) + for(const auto& entry : table) { indices.push_back(detail::extractAs(entry.first)); } @@ -678,25 +759,11 @@ ReaderResult LuaReader::getIndicesInternal(const std::string& id, std::vector axom::sol::protected_function LuaReader::getFunctionInternal(const std::string& id) { - std::vector tokens = axom::utilities::string::split(id, SCOPE_DELIMITER); axom::sol::protected_function lua_func; - - if(tokens.size() == 1) + const auto object = getObject(id); + if(object.valid()) { - if((*m_lua)[tokens[0]].valid()) - { - lua_func = (*m_lua)[tokens[0]]; - detail::checkedGet((*m_lua)[tokens[0]], lua_func); - } - } - else - { - axom::sol::table t; - // Don't traverse through the last token as it doesn't contain a table - if(traverseToTable(tokens.begin(), tokens.end() - 1, t) && t[tokens.back()].valid()) - { - detail::checkedGet(t[tokens.back()], lua_func); - } + detail::checkedGet(object, lua_func); } return lua_func; } diff --git a/src/axom/inlet/LuaReader.hpp b/src/axom/inlet/LuaReader.hpp index a9f88418d9..b1eb0dab3f 100644 --- a/src/axom/inlet/LuaReader.hpp +++ b/src/axom/inlet/LuaReader.hpp @@ -111,43 +111,78 @@ class LuaReader : public Reader std::shared_ptr solState() { return m_lua; } private: - // Expect this to be called for only Inlet-supported types. + /*! + ***************************************************************************** + * \brief Read a scalar value of an Inlet-supported type. + * + * \tparam T Inlet-supported destination type + * \param [in] id Input path to read + * \param [out] value Receives the converted value on success + * + * \return Success, NotFound, or WrongType for the resolved Lua object + ***************************************************************************** + */ template ReaderResult getValue(const std::string& id, T& value); - // Expect this to be called for only Inlet-supported types. + /*! + ***************************************************************************** + * \brief Read a typed Lua table into an Inlet map. + * + * \tparam Key Supported map key type + * \tparam Val Inlet-supported map value type + * \param [in] id Input path to read + * \param [out] values Receives matching table entries; cleared before reading + * \param [in] type Expected Lua type for each mapped value + * + * \return The aggregate retrieval status for the table and its entries + ***************************************************************************** + */ template ReaderResult getMap(const std::string& id, std::unordered_map& values, axom::sol::type type); + /*! + ***************************************************************************** + * \brief Read a Lua table whose values may have different primitive types. + * + * \tparam Key Supported map key type + * \param [in] id Input path to read + * \param [out] values Receives supported table entries; cleared before reading + * + * \return The aggregate retrieval status for the table and its entries + ***************************************************************************** + */ template ReaderResult getVariantMapInternal(const std::string& id, std::unordered_map& values); + /*! + ***************************************************************************** + * \brief Read the keys from a Lua table. + * + * \tparam T Supported index type + * \param [in] id Input path to read + * \param [out] indices Receives the table keys; cleared before reading + * + * \return Success, NotFound, or WrongType for the resolved Lua object + ***************************************************************************** + */ template ReaderResult getIndicesInternal(const std::string& id, std::vector& indices); /*! ***************************************************************************** - * \brief Obtains the Lua table reached by successive indexing through the - * range of keys described by a pair of iterators - * - * \note For a set of keys {key1, key2, key3, ...}, this function - * is equivalent to - * \code{.cpp} - * table = m_lua[key1][key2][key3][...]; - * \endcode - * - * \param [in] begin Iterator to the beginning of the range of keys - * \param [in] end Iterator to one-past-the-end of the range - * \param [out] t The table to traverse - * - * \return Whether the traversal was successful + * \brief Resolve an input path to its Lua object. + * + * \param [in] id The path to resolve + * + * \return The object at \a id, or an invalid object if the path was not found + * or an intermediate path component was not a table ***************************************************************************** */ - template - bool traverseToTable(Iter begin, Iter end, axom::sol::table& table); + axom::sol::object getObject(const std::string& id); /*! ***************************************************************************** diff --git a/src/axom/inlet/Proxy.hpp b/src/axom/inlet/Proxy.hpp index 9d12714165..78079d05f0 100644 --- a/src/axom/inlet/Proxy.hpp +++ b/src/axom/inlet/Proxy.hpp @@ -226,6 +226,8 @@ class Proxy * \tparam Args The types of the user-specified arguments, deduced automatically * * \return The function's result + * \throws InletError if an input function fails during evaluation + * or its return value cannot be converted to the declared return type ******************************************************************************* */ template diff --git a/src/axom/inlet/SphinxWriter.cpp b/src/axom/inlet/SphinxWriter.cpp index 298c172153..471f9d5e30 100644 --- a/src/axom/inlet/SphinxWriter.cpp +++ b/src/axom/inlet/SphinxWriter.cpp @@ -112,6 +112,12 @@ void SphinxWriter::documentContainer(const Container& container) for(const auto& function_entry : container.getChildFunctions()) { + // A function value alternative is documented through the concrete entry + // that shares its input path, not under its internal schema name + if(detail::isFunctionAlternativeName(Path(function_entry.first).baseName())) + { + continue; + } extractFunctionMetadata(function_entry.second->sidreGroup(), currContainer); } } diff --git a/src/axom/inlet/docs/sphinx/functions.rst b/src/axom/inlet/docs/sphinx/functions.rst index f250667b09..ea8ac73edb 100644 --- a/src/axom/inlet/docs/sphinx/functions.rst +++ b/src/axom/inlet/docs/sphinx/functions.rst @@ -37,12 +37,17 @@ The return type and argument types are described with the ``inlet::FunctionTag`` * ``Void`` - corresponds to C++ ``void``, should only be used for functions that don't return a value Note that a single type tag is passed for the return type, while a vector of tags is passed -for the argument types. Currently a maximum of two arguments are supported. +for the argument types. Currently a maximum of two arguments are supported. To declare a function with no arguments, simply leave the list of argument types empty. .. note:: The ``InletVector`` type (and its Lua representation) are statically-sized vectors with a maximum dimension of three. That is, they can also be used to represent two-dimensional vectors. +A Lua callback declared with a ``Vector`` return type may return either a ``Vector.new(...)`` +value or an ordinary Lua table containing one to three numeric components. Ordinary table +returns must use contiguous integer indices starting at one; sparse tables, named entries, +and non-numeric components are rejected when the callback is called. + In Lua, the following operations on the ``Vector`` type are supported (for ``Vector`` s ``u``, ``v``, and ``w``): 1. Construction of a 3D vector: ``u = Vector.new(1, 2, 3)`` @@ -58,11 +63,57 @@ In Lua, the following operations on the ``Vector`` type are supported (for ``Vec #. Dimension retrieval: ``d = u.dim`` #. Component retrieval: ``d = u.x``, ``d = u.y``, ``d = u.z`` +Functions as value alternatives +------------------------------- + +Some schemas allow an input to be either a concrete value or a function that computes it. +Declare the concrete entry normally, then associate a function with the same input name: + +.. literalinclude:: ../../examples/functions.cpp + :start-after: _inlet_function_value_alternative_schema_start + :end-before: _inlet_function_value_alternative_schema_end + :language: C++ + :dedent: 2 + +Note that you must declare the alternative before the concrete entry it applies to, +and declaring it afterwards is an error, since the concrete entry has already been read by then. +Both use the same relative and slash-delimited paths as other ``Container`` methods, +and either may be declared through a parent or a child ``Container``. +The following example accepts either of these Lua inputs: + +.. code-block:: Lua + + scale = 2.0 + + -- or + scale = function() return 3.0 end + +After verification, query which representation was supplied before retrieving it: + +.. literalinclude:: ../../examples/functions.cpp + :start-after: _inlet_function_value_alternative_access_start + :end-before: _inlet_function_value_alternative_access_end + :language: C++ + :dedent: 2 + +For the shared input name, ``contains`` reports only the concrete representation and +``containsFunctionValueAlternative`` reports only the function representation. +Either form counts as user-provided input, and both are recognized by strict Containers. +An unrelated input type fails verification. + +An alternative is an ordinary ``Function``, so it is returned by ``addFunctionAsValueAlternative`` +as a ``Verifiable`` and can carry the usual schema constraints, such as ``required()``. +Validation attached to the *concrete* schema entry does not apply to the function result, +so applications that constrain both forms should validate after resolving the representation. + +Generated Sphinx and JSON Schema documentation show only the concrete entry. +Application documentation should describe the function form when it is part of the Lua interface. + Accessing --------- -To retrieve a function, both the implicit conversion and ``get`` syntax is supported. For example, -a function can be retrieved as follows: +To retrieve a function, both the implicit conversion and ``get`` syntax is supported. +For example, a function can be retrieved as follows: .. literalinclude:: ../../examples/mfem_coefficient.cpp :start-after: _inlet_mfem_coef_simple_retrieve_start @@ -83,6 +134,40 @@ by calling it directly: double result = inlet["coef"].call(axom::inlet::FunctionType::Vector{3, 5, 7}); .. note:: Using ``call(ArgType1, ArgType2, ...)`` requires both that the return type - be explicitly specified and that argument types be passed with the exact type as used in the + be explicitly specified and that argument types be passed with the exact type as used in the signature defined as part of the schema. This is because the arguments do not participate in overload resolution. + +Callbacks retrieved from Inlet keep their Lua state alive, so they remain callable after the +Inlet and Reader are destroyed. Callbacks from one ``LuaReader`` share mutable interpreter +state and must not be invoked concurrently without synchronization. + +Lua execution errors and invalid callback return values throw ``axom::inlet::InletError`` +at the call site. This is the one place Inlet throws; everywhere else it reports through +SLIC or through ``verify()``: + +.. list-table:: + :header-rows: 1 + :widths: 30 30 40 + + * - Kind of problem + - Reported through + - Examples + * - API or schema misuse + - ``SLIC_ERROR`` + - an empty or malformed key, a lookup for an entry that was never defined, + a name that is ambiguous between a container, field, and function + * - Contents of the input file + - ``verify()`` and ``VerificationError`` + - a required entry is missing, a value has the wrong type or fails a + registered verifier + * - Failure while calling an input function + - ``InletError`` (derived from ``std::runtime_error``) + - the Lua function raises an error, or returns something that cannot be + converted to the declared return type + +The distinction is when the failure happens. A callback runs after verification, +at the point the application asks for its value, so the failure has to be recoverable: +the caller is the only one that knows which of its own concepts the function belonged to. +Klee, for example, catches ``InletError`` and re-reports it as a ``KleeError`` +naming the shape, operator, and field. diff --git a/src/axom/inlet/docs/sphinx/readers.rst b/src/axom/inlet/docs/sphinx/readers.rst index fb5984a074..9a6a7c88ac 100644 --- a/src/axom/inlet/docs/sphinx/readers.rst +++ b/src/axom/inlet/docs/sphinx/readers.rst @@ -1,3 +1,5 @@ +.. _inlet_readers_label: + ####### Readers ####### @@ -44,7 +46,7 @@ Extra Lua Functionality *********************** The `LuaReader` class has the ability to access the entire Lua State via the protected member function -``LuaReader::solState()``. This allows you fully utilize the Sol library, documented in +``LuaReader::solState()``. This allows you to fully utilize the Sol library, documented in `Sol's documentation `_. This is an advanced feature and not recommended unless there is a good reason. We provide an example on how to create a derived reader class here: @@ -54,8 +56,16 @@ reader class here: :end-before: _inlet_sol_state_end :language: C++ -Inlet opens four Lua libraries by default: ``base``, ``math``, ``string``, ``package``. All libraries are documented -in `Sol's open_library documentation `_. +Inlet opens four Lua libraries by default: ``base``, ``math``, ``string``, ``package``. +All libraries are documented in `Sol's open_library documentation `_. + +.. warning:: + + Lua input is executable code, and Inlet does not sandbox it. The ``package`` library can + load additional Lua or native modules, and Inlet imposes no CPU, memory, recursion, or + execution-time limits. Only parse Lua input from trusted sources. Exposing more libraries + or modifying ``solState()`` can grant the input more capabilities and change values after + Inlet has read or verified them. For example, you can add the ``io`` library by doing this: diff --git a/src/axom/inlet/examples/CMakeLists.txt b/src/axom/inlet/examples/CMakeLists.txt index 593ce27bf1..93b6c79f25 100644 --- a/src/axom/inlet/examples/CMakeLists.txt +++ b/src/axom/inlet/examples/CMakeLists.txt @@ -21,6 +21,7 @@ blt_list_append( arrays.cpp documentation_generation.cpp fields.cpp + functions.cpp homogeneous_collections.cpp lua_library.cpp user_defined_variant.cpp @@ -72,6 +73,9 @@ if (SOL_FOUND) axom_add_test( NAME inlet_fields_ex COMMAND inlet_fields_ex) + axom_add_test( NAME inlet_functions_ex + COMMAND inlet_functions_ex ) + axom_add_test( NAME inlet_homogeneous_collections_ex COMMAND inlet_homogeneous_collections_ex ) diff --git a/src/axom/inlet/examples/functions.cpp b/src/axom/inlet/examples/functions.cpp new file mode 100644 index 0000000000..7473165791 --- /dev/null +++ b/src/axom/inlet/examples/functions.cpp @@ -0,0 +1,55 @@ +// Copyright (c) Lawrence Livermore National Security, LLC and other +// Axom Project Contributors. See top-level LICENSE and COPYRIGHT +// files for dates and other details. +// +// SPDX-License-Identifier: (BSD-3-Clause) + +#include "axom/fmt.hpp" +#include "axom/inlet.hpp" +#include "axom/slic.hpp" +#include "axom/slic/core/SimpleLogger.hpp" + +#include +#include +#include + +namespace inlet = axom::inlet; + +double readScale(const std::string& luaInput) +{ + auto reader = std::make_unique(); + reader->parseString(luaInput); + inlet::Inlet input(std::move(reader)); + + // _inlet_function_value_alternative_schema_start + // The alternative is declared before the concrete entry it applies to + input.addFunctionAsValueAlternative("scale", inlet::FunctionTag::Double, {}); + input.addDouble("scale"); + // _inlet_function_value_alternative_schema_end + + if(!input.verify()) + { + return 0.0; + } + + // _inlet_function_value_alternative_access_start + const auto& root = input.getGlobalContainer(); + return root.containsFunctionValueAlternative("scale") + ? root.getFunctionValueAlternative("scale").call() + : input["scale"].get(); + // _inlet_function_value_alternative_access_end +} + +int main() +{ + axom::slic::SimpleLogger logger; + + const double concrete = readScale("scale = 2.0"); + const double callback = readScale("scale = function() return 3.0 end"); + + SLIC_ERROR_IF(concrete != 2.0, + axom::fmt::format("Expected a concrete scale of 2.0, got {0}", concrete)); + SLIC_ERROR_IF(callback != 3.0, + axom::fmt::format("Expected a callback scale of 3.0, got {0}", callback)); + return 0; +} diff --git a/src/axom/inlet/inlet_utils.hpp b/src/axom/inlet/inlet_utils.hpp index 83e510166f..78f15e7028 100644 --- a/src/axom/inlet/inlet_utils.hpp +++ b/src/axom/inlet/inlet_utils.hpp @@ -7,6 +7,7 @@ #pragma once #include +#include #include #include "axom/sidre.hpp" @@ -26,6 +27,23 @@ enum class ReaderResult WrongType // Found, but item at specified path was not of requested type }; +/*! + ***************************************************************************** + * \brief Exception thrown when evaluating an input function fails + * + * Inlet reports API and schema misuse -- a bad key, a missing entry, an + * ambiguous lookup -- through SLIC, and reports problems with the *contents* of + * an input file through verify() and VerificationError. Neither applies to a + * failure that happens while *calling* a function read from the input, which + * occurs after verification and must be recoverable so the caller can report it + * with its own context. Those failures throw this type. + ***************************************************************************** + */ +struct InletError : public std::runtime_error +{ + using std::runtime_error::runtime_error; +}; + /*! ***************************************************************************** * \brief Information on an Inlet verification error @@ -136,8 +154,10 @@ const std::string COLLECTION_GROUP_NAME = "_inlet_collection"; const std::string COLLECTION_INDICES_NAME = "_inlet_collection_indices"; const std::string STRUCT_COLLECTION_FLAG = "_inlet_struct_collection"; const std::string VARIANT_STRUCT_COLLECTION_FLAG = "_inlet_variant_struct_collection"; +const std::string FUNCTION_ALTERNATIVE_SUFFIX = "_inlet_function_alternative"; const std::string REQUIRED_FLAG = "required"; const std::string STRICT_FLAG = "strict"; + } // namespace detail /*! diff --git a/src/axom/inlet/tests/inlet_Reader.cpp b/src/axom/inlet/tests/inlet_Reader.cpp index 6dbc99e4da..e49ee7fb0f 100644 --- a/src/axom/inlet/tests/inlet_Reader.cpp +++ b/src/axom/inlet/tests/inlet_Reader.cpp @@ -11,6 +11,7 @@ #include "gtest/gtest.h" +#include #include #include #include @@ -468,6 +469,42 @@ TEST(inlet_Reader_lua, getDiscontiguousMap) std::unordered_map expectedStrs {{33, "hello"}, {200, "bye"}}; EXPECT_EQ(expectedStrs, strs); } + +TEST(inlet_Reader_lua, objectLookupReportsConsistentReaderResults) +{ + axom::inlet::LuaReader reader; + reader.parseString(R"( + callback = function() return {1, 2} end + nested = {[7] = {values = {[2] = 42, [5] = "five"}}} + )"); + + double scalar = 0.0; + EXPECT_EQ(ReaderResult::WrongType, reader.getDouble("callback", scalar)); + EXPECT_EQ(ReaderResult::NotFound, reader.getDouble("callback/value", scalar)); + + std::unordered_map typedValues {{99, 99.0}}; + EXPECT_EQ(ReaderResult::WrongType, reader.getDoubleMap("callback", typedValues)); + EXPECT_TRUE(typedValues.empty()); + + std::unordered_map values {{99, axom::inlet::VariantValue {99}}}; + EXPECT_EQ(ReaderResult::WrongType, reader.getVariantMap("callback", values)); + EXPECT_TRUE(values.empty()); + EXPECT_EQ(ReaderResult::NotFound, reader.getVariantMap("missing", values)); + EXPECT_TRUE(values.empty()); + + EXPECT_EQ(ReaderResult::Success, reader.getVariantMap("nested/7/values", values)); + const std::unordered_map expectedValues { + {2, axom::inlet::VariantValue {42}}, + {5, axom::inlet::VariantValue {std::string {"five"}}}}; + EXPECT_EQ(expectedValues, values); + + std::vector indices {99}; + EXPECT_EQ(ReaderResult::WrongType, reader.getIndices("callback", indices)); + EXPECT_TRUE(indices.empty()); + EXPECT_EQ(ReaderResult::Success, reader.getIndices("nested/7/values", indices)); + std::sort(indices.begin(), indices.end()); + EXPECT_EQ((std::vector {2, 5}), indices); +} #endif //------------------------------------------------------------------------------ diff --git a/src/axom/inlet/tests/inlet_function.cpp b/src/axom/inlet/tests/inlet_function.cpp index c8dd19562a..fb56f472b5 100644 --- a/src/axom/inlet/tests/inlet_function.cpp +++ b/src/axom/inlet/tests/inlet_function.cpp @@ -5,14 +5,17 @@ // SPDX-License-Identifier: (BSD-3-Clause) #include "axom/slic.hpp" -#include "axom/sidre.hpp" #include "axom/inlet/LuaReader.hpp" #include "axom/inlet/Inlet.hpp" +#include "axom/inlet/SphinxWriter.hpp" #include "gtest/gtest.h" #include +#include +#include +#include #include #include #include @@ -66,6 +69,76 @@ TEST(inlet_function, simple_vec3_to_vec3_raw) EXPECT_FLOAT_EQ(result[2], 6); } +TEST(inlet_function, vector_function_accepts_lua_table_returns) +{ + auto inlet = createBasicInlet(R"( + function make_vector (dim) + local result = {} + for i = 1, dim do result[i] = i end + return result + end + )"); + + auto function = + inlet.reader().getFunction("make_vector", FunctionTag::Vector, {FunctionTag::Double}); + ASSERT_TRUE(function); + for(int dim = 1; dim <= 3; ++dim) + { + const auto result = function.call(static_cast(dim)); + ASSERT_EQ(result.dim, dim); + for(int component = 0; component < result.dim; ++component) + { + EXPECT_DOUBLE_EQ(result[component], component + 1.0); + } + } +} + +TEST(inlet_function, vector_function_rejects_malformed_table_returns) +{ + // Lua vectors must be dense numeric sequences with a supported dimension. + const std::array results {{ + "2.0", + "{}", + "{1, 2, 3, 4}", + "{[1] = 1, [3] = 3}", + "{1, 'two'}", + "{1, 2, label = 3}", + }}; + + for(const auto& result : results) + { + auto inlet = createBasicInlet("function foo () return " + result + " end"); + auto func = inlet.reader().getFunction("foo", FunctionTag::Vector, {}); + ASSERT_TRUE(func); + EXPECT_THROW(func.call(), axom::inlet::InletError); + } +} + +TEST(inlet_function, lua_callback_failures_are_catchable) +{ + auto inlet = createBasicInlet(R"( + function runtime_error () error('callback failed') end + function wrong_type () return 'not a number' end + )"); + + auto wrongType = inlet.reader().getFunction("wrong_type", FunctionTag::Double, {}); + ASSERT_TRUE(wrongType); + EXPECT_THROW(wrongType.call(), axom::inlet::InletError); + + auto runtimeError = inlet.reader().getFunction("runtime_error", FunctionTag::Double, {}); + ASSERT_TRUE(runtimeError); + + try + { + runtimeError.call(); + FAIL() << "Expected the Lua callback to throw"; + } + catch(const axom::inlet::InletError& error) + { + EXPECT_NE(std::string(error.what()).find("callback failed"), std::string::npos); + } +} + TEST(inlet_function, simple_vec3_to_vec3_raw_partial_init) { std::string testString = "function foo (v) return 2*v end"; @@ -125,6 +198,225 @@ TEST(inlet_function, simple_double_to_double_through_container) EXPECT_FLOAT_EQ(result, (arg * 3.4) + 9.64); } +TEST(inlet_function, function_value_alternative_selects_supplied_representation) +{ + auto inlet = createBasicInlet(R"( + function label () return 'computed' end + scale = 4.0 + )"); + + inlet.addFunctionAsValueAlternative("label", FunctionTag::String, {}); + inlet.addString("label"); + inlet.addFunctionAsValueAlternative("scale", FunctionTag::Double, {}); + inlet.addDouble("scale"); + + EXPECT_TRUE(inlet.verify()); + auto& container = inlet.getGlobalContainer(); + EXPECT_FALSE(inlet.contains("label")); + ASSERT_TRUE(container.containsFunctionValueAlternative("label")); + EXPECT_EQ(container.getFunctionValueAlternative("label").call(), "computed"); + + EXPECT_TRUE(inlet.contains("scale")); + EXPECT_FALSE(container.containsFunctionValueAlternative("scale")); + EXPECT_DOUBLE_EQ(inlet["scale"].get(), 4.0); +} + +TEST(inlet_function, function_value_alternative_rejects_unrelated_wrong_type) +{ + auto inlet = createBasicInlet("foo = 'not a number or function'"); + inlet.addFunctionAsValueAlternative("foo", FunctionTag::Double, {}); + inlet.addDouble("foo"); + + EXPECT_FALSE(inlet.verify()); + EXPECT_FALSE(inlet.contains("foo")); + EXPECT_FALSE(inlet.getGlobalContainer().containsFunctionValueAlternative("foo")); + // The input exists even though neither schema entry accepts its type. + EXPECT_TRUE(inlet.isUserProvided("foo")); + EXPECT_FALSE(inlet.getGlobalContainer().exists()); +} + +TEST(inlet_function, function_value_alternative_is_container_independent) +{ + for(const bool functionOnRoot : {true, false}) + { + auto inlet = createBasicInlet("group = { value = function() return 2.0 end }"); + inlet.getGlobalContainer().strict(); + auto& group = inlet.addStruct("group"); + + // The alternative and the concrete entry may be declared through different + // Containers, so long as the alternative comes first. + if(functionOnRoot) + { + inlet.addFunctionAsValueAlternative("group/value", FunctionTag::Double, {}); + group.addDouble("value"); + } + else + { + group.addFunctionAsValueAlternative("value", FunctionTag::Double, {}); + inlet.addDouble("group/value"); + } + + EXPECT_TRUE(inlet.verify()); + EXPECT_TRUE(inlet.unexpectedNames().empty()); + EXPECT_TRUE(inlet.getGlobalContainer().containsFunctionValueAlternative("group/value")); + EXPECT_TRUE(group.containsFunctionValueAlternative("value")); + EXPECT_FALSE(inlet.contains("group/value")); + EXPECT_TRUE(inlet.isUserProvided("group/value")); + EXPECT_TRUE(group.isUserProvided("value")); + EXPECT_TRUE(group.exists()); + EXPECT_DOUBLE_EQ(group.getFunctionValueAlternative("value").call(), 2.0); + EXPECT_EQ(group.getFunctionValueAlternativeNames(), std::vector {"value"}); + EXPECT_TRUE(inlet.getGlobalContainer().getFunctionValueAlternativeNames().empty()); + } +} + +TEST(inlet_function, function_value_alternative_names_are_sorted) +{ + auto inlet = createBasicInlet(R"( + zeta = function() return 1.0 end + alpha = function() return 2.0 end + plain = 3.0 + )"); + + inlet.addFunctionAsValueAlternative("zeta", FunctionTag::Double, {}); + inlet.addFunctionAsValueAlternative("alpha", FunctionTag::Double, {}); + // Declared but not supplied by the input, so it is not reported + inlet.addFunctionAsValueAlternative("plain", FunctionTag::Double, {}); + inlet.addDouble("plain"); + + EXPECT_TRUE(inlet.verify()); + EXPECT_EQ(inlet.getGlobalContainer().getFunctionValueAlternativeNames(), + (std::vector {"alpha", "zeta"})); +} + +TEST(inlet_function, function_value_alternative_array_is_container_independent) +{ + for(const bool functionOnRoot : {true, false}) + { + auto inlet = createBasicInlet("group = { values = function() return {1.0, 2.0, 3.0} end }"); + auto& group = inlet.addStruct("group"); + + if(functionOnRoot) + { + inlet.addFunctionAsValueAlternative("group/values", FunctionTag::Vector, {}); + } + else + { + group.addFunctionAsValueAlternative("values", FunctionTag::Vector, {}); + } + inlet.addDoubleArray("group/values"); + + EXPECT_TRUE(inlet.verify()); + EXPECT_FALSE(inlet.contains("group/values")); + EXPECT_TRUE(group.containsFunctionValueAlternative("values")); + const auto result = group.getFunctionValueAlternative("values").call(); + EXPECT_DOUBLE_EQ(result[0], 1.0); + EXPECT_DOUBLE_EQ(result[1], 2.0); + EXPECT_DOUBLE_EQ(result[2], 3.0); + } +} + +TEST(inlet_function, function_value_alternative_declaration_is_idempotent) +{ + // Matches addFunction's behavior: redeclaring returns the existing entry + auto inlet = createBasicInlet("group = { scale = function() return 2.0 end }"); + auto& group = inlet.addStruct("group"); + + auto& first = inlet.addFunctionAsValueAlternative("group/scale", FunctionTag::Double, {}); + auto& second = group.addFunctionAsValueAlternative("scale", FunctionTag::Double, {}); + EXPECT_EQ(&first, &second); + + EXPECT_EQ(1u, group.getChildFunctions().size()); + EXPECT_TRUE(inlet.getGlobalContainer().getChildFunctions().empty()); + EXPECT_DOUBLE_EQ(group.getFunctionValueAlternative("scale").call(), 2.0); +} + +TEST(inlet_function, function_value_alternative_is_not_documented_under_its_schema_name) +{ + // The alternative is stored under an internal schema name, + // which must not leak into generated documentation + const std::string docFile = "inlet_function_value_alternative_docs.rst"; + { + auto inlet = createBasicInlet("scale = function() return 2.0 end"); + inlet.addFunctionAsValueAlternative("scale", FunctionTag::Double, {}, "a scale callback"); + inlet.addDouble("scale", "a scale"); + EXPECT_TRUE(inlet.verify()); + // The alternative is a real schema entry, just under an internal name + EXPECT_EQ(1u, inlet.getGlobalContainer().getChildFunctions().size()); + inlet.write(axom::inlet::SphinxWriter(docFile)); + } + + std::ifstream stream(docFile); + ASSERT_TRUE(stream.good()); + const std::string contents {std::istreambuf_iterator {stream}, + std::istreambuf_iterator {}}; + EXPECT_EQ(std::string::npos, contents.find("_inlet_function_alternative")); + EXPECT_NE(std::string::npos, contents.find("scale")); +} + +TEST(inlet_function, function_value_alternative_can_be_required) +{ + // The alternative is an ordinary Function, so schema constraints apply to it + auto missing = createBasicInlet("scale = 2.0"); + missing.addFunctionAsValueAlternative("scale", FunctionTag::Double, {}).required(); + missing.addDouble("scale"); + EXPECT_FALSE(missing.verify()); + + auto supplied = createBasicInlet("scale = function() return 2.0 end"); + supplied.addFunctionAsValueAlternative("scale", FunctionTag::Double, {}).required(); + supplied.addDouble("scale"); + EXPECT_TRUE(supplied.verify()); +} + +TEST(inlet_function, function_value_alternative_rejects_invalid_declaration) +{ + auto inlet = createBasicInlet(""); + axom::slic::ScopedAbortToThrow abortGuard; + + EXPECT_THROW(inlet.addFunctionAsValueAlternative("", FunctionTag::Double, {}), + axom::slic::SlicAbortException); + EXPECT_THROW(inlet.addFunctionAsValueAlternative("value", FunctionTag::Void, {}), + axom::slic::SlicAbortException); + EXPECT_TRUE(inlet.getGlobalContainer().getFunctionValueAlternativeNames().empty()); +} + +TEST(inlet_function, function_value_alternative_rejects_declaration_after_the_value) +{ + // Declaring the concrete entry first cannot work: it has already been read and + // marked as being of the wrong type. Without this check the schema author sees + // a verification failure blaming the input instead of the schema. + axom::slic::ScopedAbortToThrow abortGuard; + + auto scalar = createBasicInlet("scale = function() return 2.0 end"); + scalar.addDouble("scale"); + EXPECT_THROW(scalar.addFunctionAsValueAlternative("scale", FunctionTag::Double, {}), + axom::slic::SlicAbortException); + + auto collection = createBasicInlet("values = function() return {1.0, 2.0} end"); + collection.addDoubleArray("values"); + EXPECT_THROW(collection.addFunctionAsValueAlternative("values", FunctionTag::Vector, {}), + axom::slic::SlicAbortException); + + // Declaring through a parent Container is rejected the same way + auto nested = createBasicInlet("group = { value = function() return 2.0 end }"); + nested.addStruct("group").addDouble("value"); + EXPECT_THROW(nested.addFunctionAsValueAlternative("group/value", FunctionTag::Double, {}), + axom::slic::SlicAbortException); +} + +TEST(inlet_function, returned_function_keeps_lua_state_alive) +{ + // An extracted callback must retain its Lua state after Inlet is destroyed. + std::function callback; + { + auto inlet = createBasicInlet("offset = 3.0; function foo (value) return value + offset end"); + inlet.addFunction("foo", FunctionTag::Double, {FunctionTag::Double}); + callback = inlet["foo"].get>(); + } + + EXPECT_DOUBLE_EQ(callback(4.0), 7.0); +} + TEST(inlet_function, simple_void_to_double_through_container) { std::string testString = "function foo () return 9.64 end"; @@ -311,6 +603,38 @@ struct FromInlet } }; +struct FooWithValueAlternative +{ + double bar; +}; + +template <> +struct FromInlet +{ + FooWithValueAlternative operator()(const axom::inlet::Container& base) + { + if(base.containsFunctionValueAlternative("bar")) + { + return {base.getFunctionValueAlternative("bar").call()}; + } + return {base["bar"].get()}; + } +}; + +struct FooWithValueAlternativeDictionary +{ + std::unordered_map values; +}; + +template <> +struct FromInlet +{ + FooWithValueAlternativeDictionary operator()(const axom::inlet::Container& base) + { + return {base["foo"].get>()}; + } +}; + TEST(inlet_function, simple_vec3_to_vec3_struct) { std::string testString = "foo = { bar = true; baz = function (v) return 2*v end }"; @@ -359,6 +683,33 @@ TEST(inlet_function, simple_vec3_to_vec3_array_of_struct) EXPECT_FLOAT_EQ(second_result[2], 18); } +TEST(inlet_function, function_value_alternative_in_nested_dictionary_of_struct) +{ + // Both representations appear in the same collection, so the alternative must + // be expanded across the collection's elements exactly as the concrete entry is + auto inlet = createBasicInlet(R"( + groups = { + [0] = { + foo = { + first = {bar = 2}, + second = {bar = function () return 3 end} + } + } + } + )"); + + auto& groups = inlet.addStructArray("groups"); + auto& foos = groups.addStructDictionary("foo"); + foos.addFunctionAsValueAlternative("bar", FunctionTag::Double, {}); + foos.addDouble("bar"); + + EXPECT_TRUE(inlet.verify()); + const auto values = + inlet["groups"].get>(); + EXPECT_DOUBLE_EQ(values.at(0).values.at("first").bar, 2.0); + EXPECT_DOUBLE_EQ(values.at(0).values.at("second").bar, 3.0); +} + TEST(inlet_function, dimension_dependent_result) { std::string testString = diff --git a/src/axom/klee/KleeError.hpp b/src/axom/klee/KleeError.hpp index 52087f4988..3fc1c5781b 100644 --- a/src/axom/klee/KleeError.hpp +++ b/src/axom/klee/KleeError.hpp @@ -16,7 +16,7 @@ namespace axom namespace klee { /** - * Describes an error that occurred while parsing a Klee file. + * Describes an error that occurred while parsing Klee input. * * Klee throws this exception for user input validation failures so callers * can report path-aware feedback from Inlet and Klee semantic checks. diff --git a/src/axom/klee/Units.hpp b/src/axom/klee/Units.hpp index fb4794ef30..8ad19745d4 100644 --- a/src/axom/klee/Units.hpp +++ b/src/axom/klee/Units.hpp @@ -24,6 +24,17 @@ using utilities::LengthUnit; namespace internal { +/*! + * \brief Parse a unit string, reporting an invalid value at the supplied input path. + * + * \param [in] unitsAsString the unit string to parse + * \param [in] path the input path to report on failure + * + * \return A LengthUnit containing the unit type. + * \throws KleeError if the unit string is invalid + */ +LengthUnit parseLengthUnits(const std::string& unitsAsString, const std::string& path); + /*! * \brief This function parses a string and returns a LengthUnit. It is a compatibility * function that throws a KleeError if the unit is invalid. diff --git a/src/axom/klee/docs/sphinx/specifying_shapes.rst b/src/axom/klee/docs/sphinx/specifying_shapes.rst index 81613793cd..468cc792f1 100644 --- a/src/axom/klee/docs/sphinx/specifying_shapes.rst +++ b/src/axom/klee/docs/sphinx/specifying_shapes.rst @@ -123,11 +123,133 @@ ordinary table values can be generated programmatically: } } +An application can supply a Lua initialization chunk that runs before the deck is parsed. +This lets one deck select between 2D and 3D geometry, dimensions, or operator values +at run time, and lets host code provide helper functions and closures without recompiling. +The chunk must return a table; its entries become Lua globals that the deck may use, +while unrelated globals in the deck remain errors: + +.. code-block:: c++ + + axom::klee::LuaInputOptions options; + options.initialization = axom::klee::LuaInitializationChunk { + R"( + local dim = 2 + local lift = 3.0 + + local function offset(y) + return function() + return {0.0, y} + end + end + + return { + dimensions = dim, + lift = lift, + offset = offset + } + )", + "runtime_initialization" + }; + auto shapeSet = axom::klee::readShapeSet("shape.lua", options); + +.. code-block:: lua + + shapes = { + { + name = "part", + material = "steel", + geometry = { + format = "stl", + path = "part.stl", + units = "cm", + operators = { + { translate = offset(lift) } + } + } + } + } + +Exported keys must be non-keyword ASCII Lua identifiers that do not collide with +standard Lua globals such as :code:`math` or :code:`package`. +Exported values may be booleans, numbers, strings, tables, or functions, +and keep their original Lua representation. + +Klee evaluates the chunk in a separate Lua environment, so names that the chunk +assigns reach the deck only if they are returned in the export table, +and exported functions keep access to the chunk's private environment. +Exported names are ordinary mutable globals and can be modified. + +.. note:: + + The environment separation is not a sandbox. Inherited objects such as + :code:`math` and :code:`package` are shared with the deck, :code:`package` can + load additional code, and Klee imposes no CPU, memory, or recursion limits. + See :ref:`Inlet's reader documentation ` for the general + warning that applies to all Lua input. + + Klee does not coordinate Lua evaluation across MPI ranks. If an application + calls :code:`readShapeSet` on every rank, each rank reads and evaluates the + deck and the initialization chunk independently, so both must be deterministic. + Use :code:`local` helper functions and constants for intermediate values so the global namespace contains only the Klee schema fields that Inlet should read. For Lua input, a one-value scale is written as a one-entry table, for example :code:`{ scale = {2.0} }`. +Selected operator fields may also be written as zero-argument Lua callbacks. +Klee evaluates each callback exactly once while reading the deck; the resulting +shape contains ordinary affine or slice operators, not runtime Lua functions. + +Write callbacks as pure functions of local deck variables. Klee does not define +the order in which it evaluates the callbacks within an operator, so a callback +must not depend on another having run. +Klee constructs :code:`named_operators` before :code:`shapes`: +a :code:`ref` reuses the concrete operator built for that named operator +rather than evaluating its callbacks again. + +.. code-block:: lua + + local dim = 2 + local r = 4.0 + local z = 8.0 + local x = 1.0 + local y = 2.0 + + dimensions = dim + + shapes = { + { + name = "part", + material = "steel", + geometry = { + format = "stl", + path = "part.stl", + units = "cm", + operators = { + { + translate = function() + if dim == 2 then + return {r, z} + end + return {x, y, z} + end + } + } + } + } + } + +Vector-valued callbacks return raw numeric Lua tables such as :code:`{x, y}` or :code:`{x, y, z}`. +The typed :code:`Vector.new(...)` object is also accepted. +Scalar-valued callbacks return a number. +String-valued callbacks return a string. +Supported callback fields are +:code:`translate`, :code:`axis`, :code:`center`, :code:`scale`, :code:`slice.origin`, +:code:`slice.normal`, :code:`slice.up`, :code:`rotate`, :code:`slice.x`, +:code:`slice.y`, :code:`slice.z`, :code:`convert_units_to`, and :code:`ref`. +For :code:`scale`, a one-entry table means uniform scaling and a multi-entry table means per-axis scaling. + Common Lua input errors are reported as Klee parsing errors. A Lua input file read without Lua support reports: @@ -153,6 +275,13 @@ or inspect :code:`getErrors()` when multiple verification errors are available. Klee may still throw standard exceptions such as :code:`std::logic_error` or :code:`std::invalid_argument` for programming errors or inconsistent manually constructed objects. +Callback failures include the field, owning shape or named operator, +and operator location, for example: + +.. code-block:: text + + Error evaluating callback for 'translate' in shape 'part' operator 1: [Inlet] Lua function call failed: ... + Paths ***** The paths specified in shapes are specified either as absolute paths @@ -225,10 +354,10 @@ will match that of the (global or per-shape) `dimensions`. Overlay Rules ------------- -Shapes are added to meshes in the order in which they appear in the YAML -file. By default, each one replaces all materials that occupy the space -specified by its geometry file. This can be overridden by using the -:code:`replaces` and :code:`does_not_replace` properties. +Shapes are added to meshes in the order in which they appear in the input file. +By default, each one replaces all materials that occupy the space specified by +its geometry file. This can be overridden by using the :code:`replaces` +and :code:`does_not_replace` properties. .. code-block:: yaml @@ -333,7 +462,7 @@ Supported Operators The supported operators are listed below. Unless otherwise specified, the only difference between the 2D and 3D versions are that whenever points or vectors are expected, the points and vectors must be of the dimensionality -specified by the shape file. +specified by the Klee input. Operators take the form of :code:`operator_name: value`, where :code:`operator_name` is the name of the operator, and @@ -358,7 +487,7 @@ Operators may also have additional required or optional parameters. :value: an angle, in degrees by which the shape will be rotated counterclockwise. :additional required parameters: - :axis: (3D only) the axis of rotation + :axis: (3D only) the nonzero axis of rotation :optional arguments: :center: a point specifying the center of rotation :example: @@ -480,6 +609,12 @@ object. This is a list where each entry has the following values: last operator is specified. It is an error if the units aren't properly converted to `end_units` after applying all operations. +For Lua input, named-operator values support the same callback-capable fields +as shape operators. Klee constructs named operators before shapes, evaluates +each of their callbacks once, and shares that concrete result through every :code:`ref`. +A named-operator callback therefore cannot depend on the identity of a shape +that later refers to it. + The example below demonstrates how to create and then use a named operator. Notice how we can use multiple :code:`ref` entries in the list of operators and we can intermix these with other operators as needed. @@ -545,7 +680,8 @@ the transformation was defined when you use it. In addition to using :code:`ref` in an individual shape's operators, you can also use it in other named operators. The only restriction is that it -be defined in the list before it is used. +be defined in the list before it is used. For Lua input, this restriction also +applies when a :code:`ref` callback returns the operator name. .. code-block:: yaml diff --git a/src/axom/klee/io/GeometryOperatorsIO.cpp b/src/axom/klee/io/GeometryOperatorsIO.cpp index 19b4baca26..f5dd5f4845 100644 --- a/src/axom/klee/io/GeometryOperatorsIO.cpp +++ b/src/axom/klee/io/GeometryOperatorsIO.cpp @@ -19,6 +19,7 @@ #include #include #include +#include namespace axom { @@ -30,7 +31,7 @@ namespace { using OpPtr = CompositeOperator::OpPtr; using OperatorParser = - std::function; + std::function; using internal::toDoubleVector; using primal::Point3D; using primal::Vector3D; @@ -46,6 +47,321 @@ std::string childName(const inlet::Container& container, const std::string& name return result; } +/** + * Determine whether an operator field was supplied as a callback. + * + * \param container the operator container + * \param fieldName the public operator field name + * \return true when \a fieldName has a supplied function alternative + */ +bool hasCallback(const inlet::Container& container, char const* fieldName) +{ + return container.containsFunctionValueAlternative(fieldName); +} + +/** + * Determine whether an operator field was supplied directly or as a callback. + * + * \param container the operator container + * \param fieldName the public operator field name + * \return true when either supported representation was supplied + */ +bool containsFieldOrCallback(const inlet::Container& container, char const* fieldName) +{ + return container.contains(fieldName) || hasCallback(container, fieldName); +} + +/** + * Construct the input path for an operator field. + * + * \param container the operator container + * \param fieldName the public operator field name + * \return the full path to \a fieldName + */ +Path fieldPath(const inlet::Container& container, char const* fieldName) +{ + return Path::join({Path {container.name()}, Path {std::string {fieldName}}}); +} + +/** + * Add callback context to a message when a field was supplied as a callback. + * + * \param container the operator or slice container + * \param fieldName the field the message is about + * \param ownerLabel description of the owning shape or named operator + * \param message the message to report + * \return \a message, prefixed with the callback, owner and operator when + * \a fieldName was supplied as a callback, and unchanged otherwise + */ +std::string fieldMessage(const inlet::Container& container, + char const* fieldName, + const std::string& ownerLabel, + const std::string& message) +{ + if(!hasCallback(container, fieldName)) + { + return message; + } + + Path path {container.name()}; + std::string operatorIndex = path.baseName(); + if(operatorIndex == "slice") + { + operatorIndex = path.parent().baseName(); + } + + const auto operatorLabel = operatorIndex.empty() ? std::string {"operator at "} + container.name() + : std::string {"operator "} + operatorIndex; + const auto owner = ownerLabel.empty() ? operatorLabel : ownerLabel + " " + operatorLabel; + return axom::fmt::format("Error evaluating callback for '{}' in {}: {}", fieldName, owner, message); +} + +/** + * Invoke a callback and translate its failures to contextual Klee errors. + * + * \tparam Result expected callback result type + * \tparam Func callback invocation type + * \param container the operator or slice container + * \param fieldName the callback field name + * \param ownerLabel description of the owning shape or named operator + * \param func callable that invokes the Inlet callback + * \return the callback result + * \throws KleeError if callback invocation or result conversion fails + */ +template +Result wrapCallbackErrors(const inlet::Container& container, + char const* fieldName, + const std::string& ownerLabel, + Func&& func) +{ + // Convert Inlet callback failures into Klee diagnostics at the boundary + // where the shape, operator, and field context are all available. + try + { + return func(); + } + catch(const inlet::InletError& ex) + { + throw KleeError( + {fieldPath(container, fieldName), fieldMessage(container, fieldName, ownerLabel, ex.what())}); + } +} + +/** + * Read a scalar operator field from its direct or callback representation. + * + * \param container the operator container + * \param fieldName the public operator field name + * \param ownerLabel description of the owning shape or named operator + * \return the resolved scalar value + * \throws KleeError if callback evaluation fails + */ +double getScalar(const inlet::Container& container, char const* fieldName, const std::string& ownerLabel) +{ + if(hasCallback(container, fieldName)) + { + return wrapCallbackErrors(container, fieldName, ownerLabel, [&]() { + return container.getFunctionValueAlternative(fieldName).call(); + }); + } + return container[fieldName].get(); +} + +/** + * Read a string operator field from its direct or callback representation. + * + * \param container the operator container + * \param fieldName the public operator field name + * \param ownerLabel description of the owning shape or named operator + * \return the resolved string value + * \throws KleeError if callback evaluation fails + */ +std::string getString(const inlet::Container& container, + char const* fieldName, + const std::string& ownerLabel) +{ + if(hasCallback(container, fieldName)) + { + return wrapCallbackErrors(container, fieldName, ownerLabel, [&]() { + return container.getFunctionValueAlternative(fieldName).call(); + }); + } + return container[fieldName].get(); +} + +/** + * Convert an Inlet callback vector to ordinary doubles. + * + * \param value the callback vector + * \return the active components of \a value + */ +std::vector callbackVectorToDoubleVector(const inlet::FunctionType::Vector& value) +{ + std::vector result; + result.reserve(value.dim); + for(int i = 0; i < value.dim; ++i) + { + result.push_back(value.vec[i]); + } + return result; +} + +/** + * Read and validate a vector operator field. + * + * \param container the operator container + * \param fieldName the public operator field name + * \param expectedDims required vector dimension + * \param ownerLabel description of the owning shape or named operator + * \return the resolved vector components + * \throws KleeError if callback evaluation or dimension validation fails + */ +std::vector getDoubleVector(const inlet::Container& container, + char const* fieldName, + Dimensions expectedDims, + const std::string& ownerLabel) +{ + if(hasCallback(container, fieldName)) + { + auto values = wrapCallbackErrors>(container, fieldName, ownerLabel, [&]() { + return callbackVectorToDoubleVector( + container.getFunctionValueAlternative(fieldName).call()); + }); + auto actualSize = values.size(); + auto expectedSize = static_cast(expectedDims); + if(actualSize != expectedSize) + { + throw KleeError({fieldPath(container, fieldName), + fieldMessage(container, + fieldName, + ownerLabel, + fmt::format("Wrong size for {}. Expected {}. Got {}.", + fieldName, + expectedSize, + actualSize))}); + } + return values; + } + return toDoubleVector(container[fieldName], expectedDims, fieldName); +} + +/** + * Read a point- or vector-like operator field. + * + * \tparam T destination point or vector type + * \param parent the operator container + * \param fieldName the public operator field name + * \param expectedDims required dimension + * \param ownerLabel description of the owning shape or named operator + * \return the resolved value converted to \a T + */ +template +T toArrayLike(const inlet::Container& parent, + char const* fieldName, + Dimensions expectedDims, + const std::string& ownerLabel) +{ + auto values = getDoubleVector(parent, fieldName, expectedDims, ownerLabel); + return T {values.data(), static_cast(expectedDims)}; +} + +/** + * Read an optional point- or vector-like operator field. + * + * \tparam T destination point or vector type + * \param parent the operator container + * \param fieldName the public operator field name + * \param expectedDims required dimension + * \param defaultValue value returned when the field is absent + * \param ownerLabel description of the owning shape or named operator + * \return the resolved value, or \a defaultValue when absent + */ +template +T toArrayLike(const inlet::Container& parent, + char const* fieldName, + Dimensions expectedDims, + const T& defaultValue, + const std::string& ownerLabel) +{ + if(containsFieldOrCallback(parent, fieldName)) + { + return toArrayLike(parent, fieldName, expectedDims, ownerLabel); + } + return defaultValue; +} + +/** + * Read a required point field. + * + * \param parent the operator container + * \param fieldName the public operator field name + * \param expectedDims required dimension + * \param ownerLabel description of the owning shape or named operator + * \return the resolved point + */ +Point3D getPoint(const inlet::Container& parent, + char const* fieldName, + Dimensions expectedDims, + const std::string& ownerLabel) +{ + return toArrayLike(parent, fieldName, expectedDims, ownerLabel); +} + +/** + * Read an optional point field. + * + * \param parent the operator container + * \param fieldName the public operator field name + * \param expectedDims required dimension + * \param defaultValue value returned when the field is absent + * \param ownerLabel description of the owning shape or named operator + * \return the resolved point, or \a defaultValue when absent + */ +Point3D getPoint(const inlet::Container& parent, + char const* fieldName, + Dimensions expectedDims, + const Point3D& defaultValue, + const std::string& ownerLabel) +{ + return toArrayLike(parent, fieldName, expectedDims, defaultValue, ownerLabel); +} + +/** + * Read a required vector field. + * + * \param parent the operator container + * \param fieldName the public operator field name + * \param expectedDims required dimension + * \param ownerLabel description of the owning shape or named operator + * \return the resolved vector + */ +Vector3D getVector(const inlet::Container& parent, + char const* fieldName, + Dimensions expectedDims, + const std::string& ownerLabel) +{ + return toArrayLike(parent, fieldName, expectedDims, ownerLabel); +} + +/** + * Read an optional vector field. + * + * \param parent the operator container + * \param fieldName the public operator field name + * \param expectedDims required dimension + * \param defaultValue value returned when the field is absent + * \param ownerLabel description of the owning shape or named operator + * \return the resolved vector, or \a defaultValue when absent + */ +Vector3D getVector(const inlet::Container& parent, + char const* fieldName, + Dimensions expectedDims, + const Vector3D& defaultValue, + const std::string& ownerLabel) +{ + return toArrayLike(parent, fieldName, expectedDims, defaultValue, ownerLabel); +} + /** * Get the names of all the children in the given container. * @@ -75,12 +391,9 @@ std::unordered_set getChildNames(const inlet::Container& container) } } - for(auto& child : container.getChildFunctions()) + for(const auto& name : container.getFunctionValueAlternativeNames()) { - if(*child.second) - { - allChildren.insert(childName(container, child.first)); - } + allChildren.insert(name); } return allChildren; @@ -102,10 +415,9 @@ std::unordered_set getChildNames(const inlet::Container& container) * * \endcode * - * In the above, "translate", "rotate", "center", and "axis" are all valid - * entries, but not in arbitrary combinations. You can't specify both - * "translate" and "axis", for example, or "translate" and "rotate" within - * the same entry. + * In the above, "translate", "rotate", "center", and "axis" are all valid entries, + * but not in arbitrary combinations. You can't specify both "translate" and "axis", + * for example, or "translate" and "rotate" within the same entry. * * This function can be used to handle cases like the above. * @@ -125,7 +437,7 @@ void verifyObjectFields(const inlet::Container& containerToTest, for(auto& requiredField : requiredFields) { - if(!containerToTest.contains(requiredField)) + if(!containsFieldOrCallback(containerToTest, requiredField.c_str())) { throw KleeError( {containerToTest.name(), @@ -145,7 +457,7 @@ void verifyObjectFields(const inlet::Container& containerToTest, } throw KleeError({containerToTest.name(), - axom::fmt::format("Unexpected parameter '{}' for operator '{}'", name, child)}); + axom::fmt::format("Unexpected parameter '{}' for operator '{}'", child, name)}); } } @@ -154,15 +466,18 @@ void verifyObjectFields(const inlet::Container& containerToTest, * * \param opContainer the Container from which to read the operator * \param startProperties the properties prior to this operator + * \param ownerLabel description of the owning shape or named operator * \return the created operator * \throws KleeError if the operator fields or vector dimensions are invalid */ OpPtr parseTranslate(const inlet::Container& opContainer, - const TransformableGeometryProperties& startProperties) + const TransformableGeometryProperties& startProperties, + const std::string& ownerLabel) { verifyObjectFields(opContainer, "translate", FieldSet {}, FieldSet {}); - return std::make_shared(toVector(opContainer, "translate", startProperties.dimensions), - startProperties); + return std::make_shared( + getVector(opContainer, "translate", startProperties.dimensions, ownerLabel), + startProperties); } /** @@ -170,33 +485,40 @@ OpPtr parseTranslate(const inlet::Container& opContainer, * * \param opContainer the Container from which to read the operator * \param startProperties the properties prior to this operator + * \param ownerLabel description of the owning shape or named operator * \return the created operator * \throws KleeError if the rotation is invalid for the start dimensions or operator fields */ OpPtr parseRotate(const inlet::Container& opContainer, - const TransformableGeometryProperties& startProperties) + const TransformableGeometryProperties& startProperties, + const std::string& ownerLabel) { switch(startProperties.dimensions) { case Dimensions::Two: { verifyObjectFields(opContainer, "rotate", FieldSet {}, {"center"}); + auto angle = getScalar(opContainer, "rotate", ownerLabel); + auto center = getPoint(opContainer, "center", Dimensions::Two, Point3D {0, 0, 0}, ownerLabel); Vector3D axis {0, 0, 1}; - return std::make_shared( - opContainer["rotate"].get(), - toPoint(opContainer, "center", Dimensions::Two, Point3D {0, 0, 0}), - axis, - startProperties); + return std::make_shared(angle, center, axis, startProperties); } break; case Dimensions::Three: { verifyObjectFields(opContainer, "rotate", {"axis"}, {"center"}); - return std::make_shared( - opContainer["rotate"].get(), - toPoint(opContainer, "center", Dimensions::Three, Point3D {0, 0, 0}), - toVector(opContainer, "axis", Dimensions::Three), - startProperties); + auto angle = getScalar(opContainer, "rotate", ownerLabel); + auto center = getPoint(opContainer, "center", Dimensions::Three, Point3D {0, 0, 0}, ownerLabel); + auto axis = getVector(opContainer, "axis", Dimensions::Three, ownerLabel); + if(axis.is_zero()) + { + throw KleeError({fieldPath(opContainer, "axis"), + fieldMessage(opContainer, + "axis", + ownerLabel, + "The 'axis' vector must not be a zero vector")}); + } + return std::make_shared(angle, center, axis, startProperties); } break; default: @@ -212,7 +534,8 @@ OpPtr parseRotate(const inlet::Container& opContainer, * \param normal a vector normal to the plane * \param up a vector which defines the positive Y direction * \param startProperties the properties before the slice - * \param path the path where the slice is specified, for error reporting + * \param sliceContainer the Inlet container describing the slice + * \param ownerLabel a description of the owning shape or named operator * \return the created operator * \throws KleeError if any value is invalid */ @@ -220,15 +543,24 @@ OpPtr makeCheckedSlice(Point3D origin, Vector3D normal, Vector3D up, const TransformableGeometryProperties& startProperties, - const Path& path) + const inlet::Container& sliceContainer, + const std::string& ownerLabel) { if(normal.is_zero()) { - throw KleeError({path, "The 'normal' vector must not be a zero vector"}); + throw KleeError({Path {sliceContainer.name()}, + fieldMessage(sliceContainer, + "normal", + ownerLabel, + "The 'normal' vector must not be a zero vector")}); } if(!utilities::isNearlyEqual(normal.dot(up), 0.)) { - throw KleeError({path, "The 'normal' and 'up' vectors must be perpendicular"}); + // Either vector may have come from a callback; report the first one that did + const std::string message = "The 'normal' and 'up' vectors must be perpendicular"; + char const* reported = hasCallback(sliceContainer, "up") ? "up" : "normal"; + throw KleeError( + {Path {sliceContainer.name()}, fieldMessage(sliceContainer, reported, ownerLabel, message)}); } return std::make_shared(origin, normal, up, startProperties); } @@ -239,14 +571,16 @@ OpPtr makeCheckedSlice(Point3D origin, * \param sliceContainer the Container describing the slice * \param planeName the name of the plane ("x", "y", or "z") * \param defaultNormal the default normal vector + * \param ownerLabel description of the owning shape or named operator * \return the point to use as the origin * \throws KleeError if the specified origin is not on the slice plane */ primal::Point3D getPerpendicularSliceOrigin(const inlet::Container& sliceContainer, char const* planeName, - const primal::Vector3D& defaultNormal) + const primal::Vector3D& defaultNormal, + const std::string& ownerLabel) { - double axisIntercept = sliceContainer[planeName]; + double axisIntercept = getScalar(sliceContainer, planeName, ownerLabel); primal::Point3D defaultOrigin; int nonZeroIndex = -1; @@ -259,15 +593,17 @@ primal::Point3D getPerpendicularSliceOrigin(const inlet::Container& sliceContain } } - if(!sliceContainer.contains("origin")) + if(!containsFieldOrCallback(sliceContainer, "origin")) { return defaultOrigin; } - primal::Point3D givenOrigin = toPoint(sliceContainer, "origin", Dimensions::Three); + primal::Point3D givenOrigin = getPoint(sliceContainer, "origin", Dimensions::Three, ownerLabel); if(givenOrigin[nonZeroIndex] != axisIntercept) { - throw KleeError({sliceContainer["origin"].name(), "The origin must be on the slice plane"}); + throw KleeError( + {Path {sliceContainer["origin"].name()}, + fieldMessage(sliceContainer, "origin", ownerLabel, "The origin must be on the slice plane")}); } return givenOrigin; } @@ -277,23 +613,26 @@ primal::Point3D getPerpendicularSliceOrigin(const inlet::Container& sliceContain * * \param sliceContainer the Container describing the slice * \param defaultNormal the default normal vector + * \param ownerLabel description of the owning shape or named operator * \return the vector to use as the normal * \throws KleeError if the specified normal is not parallel to the slice plane normal */ primal::Vector3D getPerpendicularSliceNormal(const inlet::Container& sliceContainer, - const primal::Vector3D& defaultNormal) + const primal::Vector3D& defaultNormal, + const std::string& ownerLabel) { - if(!sliceContainer.contains("normal")) + if(!containsFieldOrCallback(sliceContainer, "normal")) { return defaultNormal; } - primal::Vector3D givenNormal = toVector(sliceContainer, "normal", Dimensions::Three); + primal::Vector3D givenNormal = getVector(sliceContainer, "normal", Dimensions::Three, ownerLabel); auto cross = primal::Vector3D::cross_product(givenNormal, defaultNormal); bool parallel = cross.is_zero(); if(!parallel) { - throw KleeError({sliceContainer["normal"].name(), "Invalid normal"}); + throw KleeError({Path {sliceContainer["normal"].name()}, + fieldMessage(sliceContainer, "normal", ownerLabel, "Invalid normal")}); } return givenNormal; } @@ -306,6 +645,7 @@ primal::Vector3D getPerpendicularSliceNormal(const inlet::Container& sliceContai * \param defaultNormal the default normal vector for the type of plane being parsed * \param defaultUp the default up vector for the plane being parsed * \param startProperties the properties prior to this operator + * \param ownerLabel description of the owning shape or named operator * \return the parsed plane * \throws KleeError if the slice fields or values are invalid */ @@ -313,16 +653,17 @@ OpPtr readPerpendicularSlice(const inlet::Container& sliceContainer, char const* planeName, Vector3D const& defaultNormal, Vector3D const& defaultUp, - const TransformableGeometryProperties& startProperties) + const TransformableGeometryProperties& startProperties, + const std::string& ownerLabel) { verifyObjectFields(sliceContainer, planeName, FieldSet {}, {"origin", "normal", "up"}); const primal::Vector3D defaultNormalVec {defaultNormal.data()}; - auto origin = getPerpendicularSliceOrigin(sliceContainer, planeName, defaultNormalVec); - auto normal = getPerpendicularSliceNormal(sliceContainer, defaultNormalVec); - auto up = toVector(sliceContainer, "up", Dimensions::Three, defaultUp); + auto origin = getPerpendicularSliceOrigin(sliceContainer, planeName, defaultNormalVec, ownerLabel); + auto normal = getPerpendicularSliceNormal(sliceContainer, defaultNormalVec, ownerLabel); + auto up = getVector(sliceContainer, "up", Dimensions::Three, defaultUp, ownerLabel); - return makeCheckedSlice(origin, normal, up, startProperties, sliceContainer.name()); + return makeCheckedSlice(origin, normal, up, startProperties, sliceContainer, ownerLabel); } /** @@ -330,11 +671,13 @@ OpPtr readPerpendicularSlice(const inlet::Container& sliceContainer, * * \param opContainer the Container from which to read the operator * \param startProperties the properties prior to this operator + * \param ownerLabel description of the owning shape or named operator * \return the created operator * \throws KleeError if the slice fields or values are invalid */ OpPtr parseSlice(const inlet::Container& opContainer, - const TransformableGeometryProperties& startProperties) + const TransformableGeometryProperties& startProperties, + const std::string& ownerLabel) { if(startProperties.dimensions != Dimensions::Three) { @@ -342,26 +685,25 @@ OpPtr parseSlice(const inlet::Container& opContainer, } verifyObjectFields(opContainer, "slice", FieldSet {}, FieldSet {}); auto& sliceContainer = *opContainer.getChildContainers().at(opContainer.name() + "/slice").get(); - if(sliceContainer.contains("x")) + if(containsFieldOrCallback(sliceContainer, "x")) { - return readPerpendicularSlice(sliceContainer, "x", {1, 0, 0}, {0, 0, 1}, startProperties); + return readPerpendicularSlice(sliceContainer, "x", {1, 0, 0}, {0, 0, 1}, startProperties, ownerLabel); } - else if(sliceContainer.contains("y")) + else if(containsFieldOrCallback(sliceContainer, "y")) { - return readPerpendicularSlice(sliceContainer, "y", {0, 1, 0}, {1, 0, 0}, startProperties); + return readPerpendicularSlice(sliceContainer, "y", {0, 1, 0}, {1, 0, 0}, startProperties, ownerLabel); } - else if(sliceContainer.contains("z")) + else if(containsFieldOrCallback(sliceContainer, "z")) { - return readPerpendicularSlice(sliceContainer, "z", {0, 0, 1}, {0, 1, 0}, startProperties); + return readPerpendicularSlice(sliceContainer, "z", {0, 0, 1}, {0, 1, 0}, startProperties, ownerLabel); } verifyObjectFields(sliceContainer, "origin", {"normal", "up"}, FieldSet {}); - return makeCheckedSlice(toPoint(sliceContainer, "origin", Dimensions::Three), - toVector(sliceContainer, "normal", Dimensions::Three), - toVector(sliceContainer, "up", Dimensions::Three), - startProperties, - sliceContainer.name()); + auto origin = getPoint(sliceContainer, "origin", Dimensions::Three, ownerLabel); + auto normal = getVector(sliceContainer, "normal", Dimensions::Three, ownerLabel); + auto up = getVector(sliceContainer, "up", Dimensions::Three, ownerLabel); + return makeCheckedSlice(origin, normal, up, startProperties, sliceContainer, ownerLabel); } /** @@ -369,27 +711,61 @@ OpPtr parseSlice(const inlet::Container& opContainer, * * \param opContainer the Container from which to read the operator * \param startProperties the properties prior to this operator + * \param ownerLabel description of the owning shape or named operator * \return the created operator * \throws KleeError if the scale fields or vector dimensions are invalid */ OpPtr parseScale(const inlet::Container& opContainer, - const TransformableGeometryProperties& startProperties) + const TransformableGeometryProperties& startProperties, + const std::string& ownerLabel) { verifyObjectFields(opContainer, "scale", FieldSet {}, FieldSet {"center"}); - auto factors = opContainer["scale"].get>(); - if(factors.size() == 1) + auto factors = hasCallback(opContainer, "scale") + ? wrapCallbackErrors>( + opContainer, + "scale", + ownerLabel, + [&]() { + return callbackVectorToDoubleVector( + opContainer.getFunctionValueAlternative("scale").call()); + }) + : opContainer["scale"].get>(); + + const bool isUniform = factors.size() == 1; + if(!isUniform && hasCallback(opContainer, "scale")) { - return std::make_shared(factors[0], factors[0], factors[0], startProperties); + auto actualSize = factors.size(); + auto expectedSize = static_cast(startProperties.dimensions); + if(actualSize != expectedSize) + { + throw KleeError( + {fieldPath(opContainer, "scale"), + fieldMessage( + opContainer, + "scale", + ownerLabel, + fmt::format("Wrong size for scale. Expected {}. Got {}.", expectedSize, actualSize))}); + } } - factors = toDoubleVector(opContainer["scale"], startProperties.dimensions, "scale"); - if(startProperties.dimensions == Dimensions::Two) + else if(!isUniform) + { + factors = toDoubleVector(opContainer["scale"], startProperties.dimensions, "scale"); + } + if(!isUniform && startProperties.dimensions == Dimensions::Two) { factors.emplace_back(1.0); } + Point3D center {0., 0., 0.}; - if(opContainer.contains("center")) + if(containsFieldOrCallback(opContainer, "center")) { - center = toPoint(opContainer, "center", startProperties.dimensions, Point3D {0, 0, 0}); + center = + getPoint(opContainer, "center", startProperties.dimensions, Point3D {0, 0, 0}, ownerLabel); + } + + if(isUniform) + { + return std::make_shared(factors[0], factors[0], factors[0], center, startProperties); } return std::make_shared(factors[0], factors[1], factors[2], center, startProperties); @@ -400,14 +776,30 @@ OpPtr parseScale(const inlet::Container& opContainer, * * \param opContainer the Container from which to read the operator * \param startProperties the properties prior to this operator + * \param ownerLabel description of the owning shape or named operator * \return the created operator * \throws KleeError if the unit string or operator fields are invalid */ OpPtr parseConvertUnits(const inlet::Container& opContainer, - const TransformableGeometryProperties& startProperties) + const TransformableGeometryProperties& startProperties, + const std::string& ownerLabel) { verifyObjectFields(opContainer, "convert_units_to", FieldSet {}, FieldSet {}); - auto endUnits = internal::parseLengthUnits(opContainer["convert_units_to"]); + const auto unitName = getString(opContainer, "convert_units_to", ownerLabel); + const auto path = fieldPath(opContainer, "convert_units_to"); + LengthUnit endUnits; + try + { + endUnits = internal::parseLengthUnits(unitName, static_cast(path)); + } + catch(const KleeError& err) + { + if(!hasCallback(opContainer, "convert_units_to")) + { + throw; + } + throw KleeError({path, fieldMessage(opContainer, "convert_units_to", ownerLabel, err.what())}); + } return std::make_shared(endUnits, startProperties); } @@ -417,22 +809,23 @@ OpPtr parseConvertUnits(const inlet::Container& opContainer, * \param opContainer the Container from which to read the operator * \param startProperties the properties before the "ref" command * \param namedOperators a map of named operators from which to get referenced operators + * \param ownerLabel description of the owning shape or named operator * \return the created operator * \throws KleeError if the reference is missing or the operator fields are invalid */ OpPtr parseRef(const inlet::Container& opContainer, const TransformableGeometryProperties& startProperties, - const NamedOperatorMap& namedOperators) + const NamedOperatorMap& namedOperators, + const std::string& ownerLabel) { verifyObjectFields(opContainer, "ref", FieldSet {}, FieldSet {}); - std::string const& operatorName = opContainer["ref"]; + const auto operatorName = getString(opContainer, "ref", ownerLabel); auto opIter = namedOperators.find(operatorName); if(opIter == namedOperators.end()) { - std::string message = "No operator named '"; - message += operatorName; - message += '\''; - throw KleeError({opContainer["ref"].name(), message}); + const auto message = axom::fmt::format("No operator named '{}'", operatorName); + throw KleeError( + {fieldPath(opContainer, "ref"), fieldMessage(opContainer, "ref", ownerLabel, message)}); } auto referencedOperator = opIter->second; bool startUnitsMatch = startProperties.units == referencedOperator->getStartProperties().units; @@ -465,12 +858,14 @@ OpPtr parseRef(const inlet::Container& opContainer, * \param data the data from which to convert the operator * \param startProperties the properties before the operator * \param namedOperators a map of named operators from which to get referenced operators + * \param ownerLabel description of the owning shape or named operator * \return the created operator * \throws KleeError if the operator type or fields are invalid */ OpPtr convertOperator(SingleOperatorData const& data, TransformableGeometryProperties startProperties, - const NamedOperatorMap& namedOperators) + const NamedOperatorMap& namedOperators, + const std::string& ownerLabel) { std::unordered_map parsers { {"translate", parseTranslate}, @@ -479,21 +874,33 @@ OpPtr convertOperator(SingleOperatorData const& data, {"scale", parseScale}, {"convert_units_to", parseConvertUnits}, {"ref", - [&namedOperators](const inlet::Container& opNode, - const TransformableGeometryProperties& startProperties) { - return parseRef(opNode, startProperties, namedOperators); + [&namedOperators](const inlet::Container& opContainer, + const TransformableGeometryProperties& startProperties, + const std::string& ownerLabel) { + return parseRef(opContainer, startProperties, namedOperators, ownerLabel); }}, }; for(auto& entry : parsers) { - if(data.m_container->contains(entry.first)) + if(containsFieldOrCallback(*data.m_container, entry.first.c_str())) { - return entry.second(*data.m_container, startProperties); + return entry.second(*data.m_container, startProperties, ownerLabel); } } - throw KleeError({data.m_container->name(), "Invalid transformation"}); + auto childNames = getChildNames(*data.m_container); + std::string message = axom::fmt::format("Invalid transformation at {}", data.m_container->name()); + if(!childNames.empty()) + { + message += ". Found parameters:"; + for(const auto& name : childNames) + { + message += " "; + message += name; + } + } + throw KleeError({data.m_container->name(), message}); } } // namespace @@ -506,40 +913,66 @@ GeometryOperatorData::GeometryOperatorData(const Path& path) GeometryOperatorData::GeometryOperatorData(const Path& path, std::vector&& singleOperatorData) : m_path {path} - , m_singleOperatorData {singleOperatorData} + , m_singleOperatorData {std::move(singleOperatorData)} { } inlet::Container& GeometryOperatorData::defineSchema(inlet::Container& parent, const std::string& fieldName, - const std::string& description) + const std::string& description, + bool enableLuaCallbacks) { auto& opContainer = parent.addStructArray(fieldName, description).strict(); + // A callback alternative must be declared before the concrete field it may + // stand in for, so each pair is declared together. + const auto addCallback = [enableLuaCallbacks](inlet::Container& container, + const char* name, + inlet::FunctionTag returnType) { + if(enableLuaCallbacks) + { + container.addFunctionAsValueAlternative(name, returnType, {}); + } + }; + + addCallback(opContainer, "translate", inlet::FunctionTag::Vector); opContainer.addDoubleArray("translate"); + addCallback(opContainer, "rotate", inlet::FunctionTag::Double); opContainer.addDouble("rotate"); + addCallback(opContainer, "center", inlet::FunctionTag::Vector); opContainer.addDoubleArray("center"); + addCallback(opContainer, "axis", inlet::FunctionTag::Vector); opContainer.addDoubleArray("axis"); + addCallback(opContainer, "scale", inlet::FunctionTag::Vector); opContainer.addDoubleArray("scale"); + addCallback(opContainer, "convert_units_to", inlet::FunctionTag::String); opContainer.addString("convert_units_to"); auto& slice = opContainer.addStruct("slice"); + addCallback(slice, "x", inlet::FunctionTag::Double); slice.addDouble("x"); + addCallback(slice, "y", inlet::FunctionTag::Double); slice.addDouble("y"); + addCallback(slice, "z", inlet::FunctionTag::Double); slice.addDouble("z"); + addCallback(slice, "origin", inlet::FunctionTag::Vector); slice.addDoubleArray("origin"); + addCallback(slice, "normal", inlet::FunctionTag::Vector); slice.addDoubleArray("normal"); + addCallback(slice, "up", inlet::FunctionTag::Vector); slice.addDoubleArray("up"); + addCallback(opContainer, "ref", inlet::FunctionTag::String); opContainer.addString("ref"); return opContainer; } std::shared_ptr GeometryOperatorData::makeOperator( const TransformableGeometryProperties& startProperties, - const NamedOperatorMap& namedOperators) const + const NamedOperatorMap& namedOperators, + const std::string& ownerLabel) const { if(m_singleOperatorData.empty()) { @@ -553,12 +986,13 @@ std::shared_ptr GeometryOperatorData::makeOperator( auto composite = std::make_shared(startProperties); for(auto& data : m_singleOperatorData) { - composite->addOperator(convertOperator(data, composite->getEndProperties(), namedOperators)); + composite->addOperator( + convertOperator(data, composite->getEndProperties(), namedOperators, ownerLabel)); } return composite; } -void NamedOperatorData::defineSchema(inlet::Container& container) +void NamedOperatorData::defineSchema(inlet::Container& container, bool enableLuaCallbacks) { container.addString("name").required(); defineDimensionsField(container, "start_dimensions", "The initial dimensions of the operator"); @@ -566,18 +1000,22 @@ void NamedOperatorData::defineSchema(inlet::Container& container) "The units (both start and end) of the operator", "The start units of the operator", "The end units of the operator"); - GeometryOperatorData::defineSchema(container, "value", - "The operation to apply"); //.required(); + GeometryOperatorData::defineSchema(container, + "value", + "The operation to apply", + enableLuaCallbacks); //.required(); } NamedOperatorMapData::NamedOperatorMapData(std::vector&& operatorData) : m_operatorData {operatorData} { } -void NamedOperatorMapData::defineSchema(inlet::Container& parent, const std::string& name) +void NamedOperatorMapData::defineSchema(inlet::Container& parent, + const std::string& name, + bool enableLuaCallbacks) { auto& container = parent.addStructArray(name); - NamedOperatorData::defineSchema(container); + NamedOperatorData::defineSchema(container, enableLuaCallbacks); } NamedOperatorMap NamedOperatorMapData::makeNamedOperatorMap(Dimensions fileDimensions) const @@ -596,7 +1034,8 @@ NamedOperatorMap NamedOperatorMapData::makeNamedOperatorMap(Dimensions fileDimen dimensions, opData.startUnits, }; - auto op = opData.value.makeOperator(startProperties, namedOperators); + const auto ownerLabel = axom::fmt::format("named operator '{}'", opData.name); + auto op = opData.value.makeOperator(startProperties, namedOperators, ownerLabel); if(op->getEndProperties().units != opData.endUnits) { diff --git a/src/axom/klee/io/GeometryOperatorsIO.hpp b/src/axom/klee/io/GeometryOperatorsIO.hpp index 7686ff752e..a1e6837339 100644 --- a/src/axom/klee/io/GeometryOperatorsIO.hpp +++ b/src/axom/klee/io/GeometryOperatorsIO.hpp @@ -57,22 +57,26 @@ class GeometryOperatorData * @param parent the parent container * @param fieldName the name of the field * @param description a description of the field + * @param enableLuaCallbacks whether operator fields may be supplied as Lua callbacks * @return the Container for the new item */ static inlet::Container& defineSchema(inlet::Container& parent, const std::string& fieldName, - const std::string& description); + const std::string& description, + bool enableLuaCallbacks = false); /** * Make a (possibly null) operator describing the transformation to apply to the geometry * * @param startProperties properties of the geometry before the first operator * @param namedOperators a map of any named operators + * @param ownerLabel a description of the owning shape or named operator, for callback errors * @return the (possibly null) operator * @throws KleeError if the operator data is invalid for the given properties */ std::shared_ptr makeOperator(const TransformableGeometryProperties& startProperties, - const NamedOperatorMap& namedOperators) const; + const NamedOperatorMap& namedOperators, + const std::string& ownerLabel) const; /** * Get the path of this operator in the source document @@ -99,8 +103,9 @@ struct NamedOperatorData * Define the schema for a named operator. * * @param container the container in which to describe a single named operator + * @param enableLuaCallbacks whether operator fields may be supplied as Lua callbacks */ - static void defineSchema(inlet::Container& container); + static void defineSchema(inlet::Container& container, bool enableLuaCallbacks = false); }; /// Data for all a collection of named operators @@ -131,8 +136,11 @@ struct NamedOperatorMapData * * @param parent the parent object in which to define the operator map * @param name the name of the map + * @param enableLuaCallbacks whether operator fields may be supplied as Lua callbacks */ - static void defineSchema(inlet::Container& parent, const std::string& name); + static void defineSchema(inlet::Container& parent, + const std::string& name, + bool enableLuaCallbacks = false); private: std::vector m_operatorData; diff --git a/src/axom/klee/io/IO.cpp b/src/axom/klee/io/IO.cpp index a00a0bccab..1602ebdabd 100644 --- a/src/axom/klee/io/IO.cpp +++ b/src/axom/klee/io/IO.cpp @@ -17,14 +17,18 @@ #include "axom/inlet.hpp" #ifdef AXOM_USE_LUA #include "axom/inlet/LuaReader.hpp" + #include "axom/sol.hpp" #endif +#include #include #include #include #include #include #include +#include +#include namespace axom { @@ -32,6 +36,163 @@ namespace klee { namespace { +bool isLuaKeyword(const std::string& name); +bool isLuaIdentifier(const std::string& name); + +#ifdef AXOM_USE_LUA +class KleeLuaReader : public inlet::LuaReader +{ +public: + /** + * Return the string keys currently installed in the Lua global environment. + * + * \return the current top-level Lua global names + */ + std::unordered_set topLevelGlobalNames() + { + std::unordered_set names; + auto lua = solState(); + for(const auto& entry : lua->globals()) + { + if(entry.first.get_type() == axom::sol::type::string) + { + names.insert(entry.first.as()); + } + } + return names; + } + + /** + * Evaluate an initialization chunk and install its exported values as globals. + * + * \param initialization the source and diagnostic label for the chunk + * \param reservedNames built-in Lua globals that exports may not replace + * \return the names exported by the chunk + * \throws KleeError if evaluation fails or the returned exports are invalid + */ + std::unordered_set applyInitializationChunk( + const LuaInitializationChunk& initialization, + const std::unordered_set& reservedNames) + { + auto lua = solState(); + const std::string chunkName = + initialization.label.empty() ? "" : initialization.label; + const auto chunkPath = Path {chunkName}; + const auto chunkMessage = [&](const std::string& message) { + return axom::fmt::format("Klee Lua initialization chunk '{}': {}", chunkName, message); + }; + if(initialization.source.empty()) + { + throw KleeError({chunkPath, chunkMessage("Chunk is empty.")}); + } + + try + { + // Evaluate initialization in its own environment so assignments made by the + // chunk do not mutate the input file's globals. The fallback keeps + // preloaded libraries and caller-provided initial globals visible. + axom::sol::environment initializationEnvironment {*lua, axom::sol::create, lua->globals()}; + initializationEnvironment["_G"] = initializationEnvironment; + auto result = lua->script(initialization.source, initializationEnvironment); + if(!result.valid()) + { + axom::sol::error err = result; + throw KleeError( + {chunkPath, chunkMessage(axom::fmt::format("Failed to evaluate chunk: {}", err.what()))}); + } + + axom::sol::optional tableOption = result; + if(!tableOption) + { + throw KleeError({chunkPath, chunkMessage("Chunk must return a table of exported globals.")}); + } + + std::unordered_set exportedNames; + auto exportPath = [&](const std::string& name) { return Path::join({chunkPath, Path {name}}); }; + auto typeName = [](axom::sol::type type) { + switch(type) + { + case axom::sol::type::boolean: + return "boolean"; + case axom::sol::type::number: + return "number"; + case axom::sol::type::string: + return "string"; + case axom::sol::type::table: + return "table"; + case axom::sol::type::function: + return "function"; + case axom::sol::type::nil: + return "nil"; + default: + return "unsupported"; + } + }; + + for(const auto& entry : tableOption.value()) + { + if(entry.first.get_type() != axom::sol::type::string) + { + throw KleeError({chunkPath, chunkMessage("Export table must contain only string keys.")}); + } + + const std::string name = entry.first.as(); + if(!isLuaIdentifier(name)) + { + const auto reason = isLuaKeyword(name) + ? "Reserved Lua keywords cannot be used as exported global names." + : "Exported global names must be Lua identifiers."; + throw KleeError( + {exportPath(name), + chunkMessage( + axom::fmt::format("Invalid exported Lua global name '{}'. {}", name, reason))}); + } + if(reservedNames.find(name) != reservedNames.end()) + { + throw KleeError({exportPath(name), + chunkMessage(axom::fmt::format( + "Exported Lua global name '{}' conflicts with an existing Lua global.", + name))}); + } + switch(entry.second.get_type()) + { + case axom::sol::type::boolean: + case axom::sol::type::number: + case axom::sol::type::string: + case axom::sol::type::function: + case axom::sol::type::table: + break; + default: + throw KleeError({exportPath(name), + chunkMessage(axom::fmt::format( + "Exported Lua global '{}' has unsupported value type '{}'. " + "Supported exported global value types are booleans, numbers, " + "strings, tables, and functions.", + name, + typeName(entry.second.get_type())))}); + } + + // Preserve the original Lua representation. In particular, copying a + // Lua integer through a C++ double can silently lose precision. + (*lua)[name] = entry.second; + exportedNames.insert(name); + } + + return exportedNames; + } + catch(const KleeError&) + { + throw; + } + catch(const std::exception& ex) + { + throw KleeError( + {chunkPath, chunkMessage(axom::fmt::format("Failed to evaluate chunk: {}", ex.what()))}); + } + } +}; +#endif + // Because we can't have context-aware validation when extracting the // data from Inlet, we need a set of structs that parallels the real // classes. These are used to do some basic validation, and then we convert @@ -113,8 +274,9 @@ namespace * Define the schema for the "geometry" member of shapes * * @param geometry the Container representing a "geometry" object. + * @param enableLuaCallbacks whether operator fields may be supplied as Lua callbacks */ -void defineGeometry(inlet::Container& geometry) +void defineGeometry(inlet::Container& geometry, bool enableLuaCallbacks) { geometry.addString("format", "The format of the input file").required(); geometry.addString("path", @@ -135,15 +297,17 @@ void defineGeometry(inlet::Container& geometry) "The end units of the shape"); internal::GeometryOperatorData::defineSchema(geometry, "operators", - "Operators to apply to this object"); + "Operators to apply to this object", + enableLuaCallbacks); } /** * Define the schema for the list of shapes * * @param document the Inlet document for which to define the schema + * @param enableLuaCallbacks whether operator fields may be supplied as Lua callbacks */ -void defineShapeList(inlet::Inlet& document) +void defineShapeList(inlet::Inlet& document, bool enableLuaCallbacks) { inlet::Container& shapeList = document.addStructArray("shapes", "The list of shapes"); @@ -154,7 +318,7 @@ void defineShapeList(inlet::Inlet& document) auto& geometry = shapeList.addStruct("geometry", "Contains information about the shape's geometry"); - defineGeometry(geometry); + defineGeometry(geometry, enableLuaCallbacks); // Verify syntax here, semantics later!!! shapeList.registerVerifier( @@ -190,12 +354,15 @@ void defineShapeList(inlet::Inlet& document) * Define the schema for Klee documents. * * @param document the Inlet document for which to define the schema + * @param enableLuaCallbacks whether operator fields may be supplied as Lua callbacks */ -void defineKleeSchema(inlet::Inlet& document) +void defineKleeSchema(inlet::Inlet& document, bool enableLuaCallbacks) { internal::defineDimensionsField(document.getGlobalContainer(), "dimensions").required(); - defineShapeList(document); - internal::NamedOperatorMapData::defineSchema(document.getGlobalContainer(), "named_operators"); + defineShapeList(document, enableLuaCallbacks); + internal::NamedOperatorMapData::defineSchema(document.getGlobalContainer(), + "named_operators", + enableLuaCallbacks); } /** @@ -204,12 +371,14 @@ void defineKleeSchema(inlet::Inlet& document) * \param data the data read from inlet * \param fileDimensions the number of dimensions the file expects shapes to have * \param namedOperators any named operators that were parsed from the file + * \param shapeName the owning shape name used in callback diagnostics * \return the geometry description for the shape * \throws KleeError if the converted geometry does not match the expected dimensions */ Geometry convert(GeometryData const& data, Dimensions fileDimensions, - internal::NamedOperatorMap const& namedOperators) + internal::NamedOperatorMap const& namedOperators, + const std::string& shapeName) { const bool has_start_dims = data.startDimensions != Dimensions::Unspecified; const bool has_explicit_dims = data.explicitDimensions != Dimensions::Unspecified; @@ -232,7 +401,9 @@ Geometry convert(GeometryData const& data, Geometry geometry {startProperties, data.format, data.path, - data.operatorData.makeOperator(startProperties, namedOperators)}; + data.operatorData.makeOperator(startProperties, + namedOperators, + axom::fmt::format("shape '{}'", shapeName))}; const auto computed_end_dims = geometry.getEndProperties().dimensions; const auto expected_end_dims = has_explicit_dims ? data.explicitDimensions : fileDimensions; @@ -266,7 +437,7 @@ Shape convert(ShapeData const& data, data.material, data.materialsReplaced, data.materialsNotReplaced, - convert(data.geometry, fileDimensions, namedOperators)}; + convert(data.geometry, fileDimensions, namedOperators, data.name)}; } /** @@ -342,22 +513,93 @@ InputFormat inferInputFormat(const std::string& filePath) extension)}); } +/** + * Determine whether a name is a reserved Lua keyword. + * + * \param name the candidate name + * \return true when \a name is a Lua keyword + */ +bool isLuaKeyword(const std::string& name) +{ + static const std::unordered_set keywords { + "and", "break", "do", "else", "elseif", "end", "false", "for", + "function", "goto", "if", "in", "local", "nil", "not", "or", + "repeat", "return", "then", "true", "until", "while", + }; + return keywords.find(name) != keywords.end(); +} + +/** + * Determine whether a name is an ASCII Lua identifier that is not a keyword. + * + * \param name the candidate name + * \return true when \a name may be used as a Lua identifier + */ +bool isLuaIdentifier(const std::string& name) +{ + if(name.empty()) + { + return false; + } + + const auto isAsciiLetter = [](unsigned char ch) { + return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); + }; + const auto isAsciiDigit = [](unsigned char ch) { return ch >= '0' && ch <= '9'; }; + const auto isNameStart = [&](unsigned char ch) { return isAsciiLetter(ch) || ch == '_'; }; + const auto isNameChar = [&](unsigned char ch) { + return isAsciiLetter(ch) || isAsciiDigit(ch) || ch == '_'; + }; + + if(!isNameStart(static_cast(name.front()))) + { + return false; + } + return std::all_of(name.begin() + 1, + name.end(), + [&](char ch) { return isNameChar(static_cast(ch)); }) && + !isLuaKeyword(name); +} + /** * Create an Inlet reader for a Klee input format. * * \param format the input file format to read + * \param options optional initialization for Lua input evaluation + * \param allowedGlobals receives external names permitted in the input * \return a reader for \a format - * \throws KleeError if \a format is unsupported or Lua support was not enabled + * \throws KleeError if \a format is unsupported, Lua support was not enabled, + * or the external Lua initialization is invalid for the selected format */ -std::unique_ptr createReader(InputFormat format) +std::unique_ptr createReader(InputFormat format, + const LuaInputOptions& options, + std::unordered_set& allowedGlobals) { + allowedGlobals.clear(); + if(format != InputFormat::Lua && options.initialization) + { + throw KleeError( + {Path {""}, "Klee Lua initialization is only supported for Lua input decks."}); + } + switch(format) { case InputFormat::YAML: return std::make_unique(); case InputFormat::Lua: #ifdef AXOM_USE_LUA - return std::make_unique(); + { + auto reader = std::make_unique(); + if(options.initialization) + { + // Exported values are ordinary Lua globals installed before deck parsing. + // allowedGlobals only prevents Klee's unexpected-global check from rejecting + // those names; it does not make them read-only inside the deck. + allowedGlobals = + reader->applyInitializationChunk(*options.initialization, reader->topLevelGlobalNames()); + } + return reader; + } #else throw KleeError( {Path {""}, @@ -418,13 +660,25 @@ void parseOrThrow(Parse&& parse, } } +/** + * Append errors for unexpected top-level Lua globals. + * + * \param doc the verified Inlet document + * \param errors receives errors for unexpected globals + * \param allowedGlobals caller-provided globals that are permitted in the deck + */ void appendUnexpectedGlobalErrors(const inlet::Inlet& doc, - std::vector& errors) + std::vector& errors, + const std::unordered_set& allowedGlobals) { for(const auto& name : doc.unexpectedNames()) { if(name.find('/') == std::string::npos) { + if(allowedGlobals.find(name) != allowedGlobals.end()) + { + continue; + } errors.push_back({Path {name}, axom::fmt::format("Unexpected global variable '{}' in Lua input file. " "Use 'local' for helper values and functions.", @@ -437,20 +691,24 @@ void appendUnexpectedGlobalErrors(const inlet::Inlet& doc, * Read a ShapeSet from a reader that has already parsed an input file. * * \param reader the parsed Inlet reader - * \param rejectUnexpectedGlobals true if unexpected top-level Lua globals should be rejected + * \param format the input format used by the reader + * \param allowedGlobals external Lua globals permitted in the input * \return the parsed and verified ShapeSet * \throws KleeError if schema verification or semantic validation fails */ -ShapeSet readShapeSetFromReader(std::unique_ptr reader, bool rejectUnexpectedGlobals) +ShapeSet readShapeSetFromReader(std::unique_ptr reader, + InputFormat format, + const std::unordered_set& allowedGlobals) { + const bool isLuaInput = format == InputFormat::Lua; sidre::DataStore dataStore; inlet::Inlet doc(std::move(reader), dataStore.getRoot()); - defineKleeSchema(doc); + defineKleeSchema(doc, isLuaInput); std::vector errors; bool verified = doc.verify(&errors); - if(rejectUnexpectedGlobals) + if(isLuaInput) { - appendUnexpectedGlobalErrors(doc, errors); + appendUnexpectedGlobalErrors(doc, errors, allowedGlobals); verified = verified && errors.empty(); } if(!verified) @@ -476,15 +734,21 @@ ShapeSet readShapeSetFromReader(std::unique_ptr reader, bool reje ShapeSet readShapeSet(std::istream& stream) { return readShapeSet(stream, InputFormat::YAML); } ShapeSet readShapeSet(std::istream& stream, InputFormat format) +{ + return readShapeSet(stream, format, LuaInputOptions {}); +} + +ShapeSet readShapeSet(std::istream& stream, InputFormat format, const LuaInputOptions& options) { std::string contents {std::istreambuf_iterator(stream), {}}; - auto reader = createReader(format); + std::unordered_set allowedGlobals; + auto reader = createReader(format, options, allowedGlobals); parseOrThrow([&]() { return reader->parseString(contents); }, format, Path {""}, "from stream"); - return readShapeSetFromReader(std::move(reader), format == InputFormat::Lua); + return readShapeSetFromReader(std::move(reader), format, allowedGlobals); } ShapeSet readShapeSet(const std::string& filePath) @@ -494,12 +758,23 @@ ShapeSet readShapeSet(const std::string& filePath) ShapeSet readShapeSet(const std::string& filePath, InputFormat format) { - auto reader = createReader(format); + return readShapeSet(filePath, format, LuaInputOptions {}); +} + +ShapeSet readShapeSet(const std::string& filePath, const LuaInputOptions& options) +{ + return readShapeSet(filePath, inferInputFormat(filePath), options); +} + +ShapeSet readShapeSet(const std::string& filePath, InputFormat format, const LuaInputOptions& options) +{ + std::unordered_set allowedGlobals; + auto reader = createReader(format, options, allowedGlobals); parseOrThrow([&]() { return reader->parseFile(filePath); }, format, Path {filePath}, axom::fmt::format("from file '{}'", filePath)); - auto shapeSet = readShapeSetFromReader(std::move(reader), format == InputFormat::Lua); + auto shapeSet = readShapeSetFromReader(std::move(reader), format, allowedGlobals); shapeSet.setPath(filePath); return shapeSet; } diff --git a/src/axom/klee/io/IO.hpp b/src/axom/klee/io/IO.hpp index 2cd6d5e373..76ed11df82 100644 --- a/src/axom/klee/io/IO.hpp +++ b/src/axom/klee/io/IO.hpp @@ -10,6 +10,7 @@ #include #include +#include namespace axom { @@ -22,11 +23,29 @@ enum class InputFormat Lua }; +/// Lua initialization chunk evaluated before deck parsing in an isolated environment. +struct LuaInitializationChunk +{ + /// Lua source to evaluate before the input deck. + std::string source; + + /// Source label used in diagnostics from this chunk. + std::string label {""}; +}; + +/// Optional caller-provided initialization for a Lua input deck. +struct LuaInputOptions +{ + /// Isolated chunk whose returned table entries become initial mutable Lua globals. + std::optional initialization; +}; + /** * Read a ShapeSet from an input stream. * * \param stream the stream from which to read the ShapeSet * \note This overload reads YAML for backward compatibility. + * \return the ShapeSet read from the stream * \throws KleeError if parsing, schema verification, or semantic validation fails */ ShapeSet readShapeSet(std::istream& stream); @@ -36,11 +55,24 @@ ShapeSet readShapeSet(std::istream& stream); * * \param stream the stream from which to read the ShapeSet * \param format the input file format to use + * \return the ShapeSet read from the stream * \throws KleeError if parsing, schema verification, or semantic validation fails, * or if the requested input format is unsupported by this build */ ShapeSet readShapeSet(std::istream& stream, InputFormat format); +/** + * Read a ShapeSet from an input stream with caller-provided Lua inputs. + * + * \param stream the stream from which to read the ShapeSet + * \param format the input deck format to use + * \param options optional initialization for a Lua input deck + * \note Lua input options are supported only for Lua input decks. + * \return the ShapeSet read from the stream + * \throws KleeError if the input or Lua input options are invalid + */ +ShapeSet readShapeSet(std::istream& stream, InputFormat format, const LuaInputOptions& options); + /** * Read a ShapeSet from a specified file * @@ -64,5 +96,30 @@ ShapeSet readShapeSet(const std::string& filePath); */ ShapeSet readShapeSet(const std::string& filePath, InputFormat format); +/** + * Read a ShapeSet from a specified file with caller-provided Lua inputs. + * + * \param filePath the file from which to read the ShapeSet + * \param options optional initialization for a Lua input deck + * \note The input format is inferred from the file extension. + * Lua input options are supported only for Lua input decks. + * \return the ShapeSet read from the file + * \throws KleeError if the input or Lua input options are invalid + */ +ShapeSet readShapeSet(const std::string& filePath, const LuaInputOptions& options); + +/** + * Read a ShapeSet from a specified file using an explicit format and + * caller-provided Lua inputs. + * + * \param filePath the file from which to read the ShapeSet + * \param format the input file format to use, regardless of the file extension + * \param options optional initialization for a Lua input deck + * \note Lua input options are supported only for Lua input decks. + * \return the ShapeSet read from the file + * \throws KleeError if the input or Lua input options are invalid + */ +ShapeSet readShapeSet(const std::string& filePath, InputFormat format, const LuaInputOptions& options); + } // namespace klee } // namespace axom diff --git a/src/axom/klee/tests/klee_geometry_operators_io.cpp b/src/axom/klee/tests/klee_geometry_operators_io.cpp index 7c19ad909d..460a1253d8 100644 --- a/src/axom/klee/tests/klee_geometry_operators_io.cpp +++ b/src/axom/klee/tests/klee_geometry_operators_io.cpp @@ -80,7 +80,7 @@ OperatorPointer readOperators(const TransformableGeometryProperties& startProper throw KleeError(errors); } auto opData = doc["test_list"].get(); - return opData.makeOperator(startProperties, namedOperators); + return opData.makeOperator(startProperties, namedOperators, ""); } /** @@ -336,6 +336,23 @@ TEST(GeometryOperatorsIO, readRotation_3D_optionalFields) EXPECT_EQ(expectedProperties, rotation.getEndProperties()); } +TEST(GeometryOperatorsIO, readRotation_3D_zeroAxis) +{ + try + { + readSingleOperator({Dimensions::Three, LengthUnit::cm}, R"( + rotate: 45 + axis: [0, 0, 0] + )"); + FAIL() << "Should have rejected a zero rotation axis"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr("axis")); + EXPECT_THAT(err.what(), HasSubstr("zero")); + } +} + TEST(GeometryOperatorsIO, readRotation_3D_axisMissing) { try @@ -351,6 +368,23 @@ TEST(GeometryOperatorsIO, readRotation_3D_axisMissing) } } +TEST(GeometryOperatorsIO, readOperator_unexpectedParameterNamesBothFields) +{ + try + { + readOperators({Dimensions::Three, LengthUnit::cm}, R"( + - translate: [1, 2, 3] + axis: [0, 0, 1] + )"); + FAIL() << "Should not have parsed"; + } + catch(const KleeError& ex) + { + // The unexpected parameter is "axis" and the operator is "translate" + EXPECT_THAT(ex.what(), HasSubstr("Unexpected parameter 'axis' for operator 'translate'")); + } +} + TEST(GeometryOperatorsIO, readScale_singleValue) { Dimensions all_dims[] = {Dimensions::Two, Dimensions::Three}; @@ -368,6 +402,30 @@ TEST(GeometryOperatorsIO, readScale_singleValue) } } +TEST(GeometryOperatorsIO, readScale_singleValue_withCenter) +{ + // A uniform scale honors "center" the same way a per-axis scale does + Dimensions all_dims[] = {Dimensions::Two, Dimensions::Three}; + for(Dimensions dims : all_dims) + { + auto scale = readSingleOperator({dims, LengthUnit::cm}, + dims == Dimensions::Two ? R"( + scale: 1.2 + center: [10, 20] + )" + : R"( + scale: 1.2 + center: [10, 20, 30] + )"); + EXPECT_DOUBLE_EQ(1.2, scale.getXFactor()); + EXPECT_DOUBLE_EQ(1.2, scale.getYFactor()); + EXPECT_DOUBLE_EQ(1.2, scale.getZFactor()); + const Point3D expectedCenter = + dims == Dimensions::Two ? Point3D {10, 20, 0} : Point3D {10, 20, 30}; + EXPECT_THAT(scale.getCenter(), AlmostEqPoint(expectedCenter)); + } +} + TEST(GeometryOperatorsIO, readScale_2d_array) { auto scale = readSingleOperator({Dimensions::Two, LengthUnit::cm}, R"( diff --git a/src/axom/klee/tests/klee_io.cpp b/src/axom/klee/tests/klee_io.cpp index 9643cf1165..e021b5c635 100644 --- a/src/axom/klee/tests/klee_io.cpp +++ b/src/axom/klee/tests/klee_io.cpp @@ -16,8 +16,10 @@ #include "gtest/gtest.h" +#include #include #include +#include namespace klee = axom::klee; namespace inlet = axom::inlet; @@ -29,12 +31,15 @@ using klee::Dimensions; using klee::InputFormat; using klee::KleeError; using klee::LengthUnit; +using klee::LuaInitializationChunk; +using klee::LuaInputOptions; using klee::Rotation; using klee::Scale; using klee::ShapeSet; using klee::SliceOperator; using klee::TransformableGeometryProperties; using klee::Translation; +using klee::UnitConverter; using primal::Point3D; using primal::Vector3D; using test::AlmostEqPoint; @@ -56,6 +61,108 @@ ShapeSet readShapeSetFromString(const std::string& input, InputFormat format) std::istringstream istream(input); return klee::readShapeSet(istream, format); } + +ShapeSet readShapeSetFromString(const std::string& input, + InputFormat format, + const LuaInputOptions& options) +{ + std::istringstream istream(input); + return klee::readShapeSet(istream, format, options); +} + +std::string makeLuaSliceCallbackInput(const std::string& sliceFields) +{ + std::ostringstream input; + input << R"( + dimensions = 2 + shapes = { + { + name = "slice_callback", + material = "steel", + geometry = { + format = "stl", + path = "slice_callback.stl", + start_dimensions = 3, + dimensions = 2, + units = "cm", + operators = { + { + slice = { + )" << sliceFields + << R"( + } + } + } + } + } + } + )"; + return input.str(); +} + +std::string makeLuaRotationCallbackInput(const std::string& rotationFields) +{ + std::ostringstream input; + input << R"( + dimensions = 3 + shapes = { + { + name = "rotation_callback", + material = "steel", + geometry = { + format = "stl", + path = "rotation_callback.stl", + units = "cm", + operators = { + { + )" << rotationFields + << R"( + } + } + } + } + } + )"; + return input.str(); +} + +std::string makeLuaStringOperatorCallbackInput(const std::string& fieldName, + const std::string& returnExpression) +{ + std::ostringstream input; + input << R"( + dimensions = 2 + + named_operators = { + { + name = "known_operator", + units = "cm", + value = { + { translate = {1, 2} } + } + } + } + + shapes = { + { + name = "string_callback", + material = "steel", + geometry = { + format = "stl", + path = "string_callback.stl", + units = "cm", + operators = { + { + )" << fieldName + << " = function() return " << returnExpression << R"( end + } + } + } + } + } + )"; + return input.str(); +} } // end namespace TEST(IOTest, readShapeSet_noShapes) @@ -423,6 +530,33 @@ TEST(IOTest, readShapeSet_streamDefaultsToYaml) } } +TEST(IOTest, readShapeSet_yamlRejectsLuaInitialization) +{ + LuaInputOptions options; + options.initialization = LuaInitializationChunk {R"( + return { + dimensions = 2 + } + )", + "runtime_initialization"}; + + try + { + readShapeSetFromString(R"( + dimensions: 2 + shapes: [] + )", + InputFormat::YAML, + options); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr("Lua initialization")); + EXPECT_THAT(err.what(), HasSubstr("Lua input decks")); + } +} + #ifndef AXOM_USE_LUA TEST(IOTest, readShapeSet_luaUnavailableDiagnostic) { @@ -450,10 +584,23 @@ TEST(IOTest, readShapeSet_explicitLuaOverridesFileExtension) { axom::utilities::filesystem::TempFile input {"explicitLua", "yaml"}; input.write(R"( - dimensions = 2 shapes = {})"); - auto shapeSet = klee::readShapeSet(input.getPath(), InputFormat::Lua); + LuaInputOptions options; + options.initialization = LuaInitializationChunk {"return {dimensions = 2}"}; + auto shapeSet = klee::readShapeSet(input.getPath(), InputFormat::Lua, options); + EXPECT_EQ(Dimensions::Two, shapeSet.getDimensions()); + EXPECT_EQ(input.getPath(), shapeSet.getPath()); +} + +TEST(IOTest, readShapeSet_inferredLuaAcceptsInitializationOptions) +{ + axom::utilities::filesystem::TempFile input {"inferredLua", "lua"}; + input.write("shapes = {}"); + + LuaInputOptions options; + options.initialization = LuaInitializationChunk {"return {dimensions = 2}"}; + auto shapeSet = klee::readShapeSet(input.getPath(), options); EXPECT_EQ(Dimensions::Two, shapeSet.getDimensions()); EXPECT_EQ(input.getPath(), shapeSet.getPath()); } @@ -499,165 +646,569 @@ TEST(IOTest, readShapeSet_luaStreamMinimalShapeList) EXPECT_EQ(Dimensions::Two, shapeSet.getDimensions()); } -TEST(IOTest, readShapeSet_luaFileExtension) +TEST(IOTest, readShapeSet_luaInitializationProvidesDimensionAndOperator) { - std::string fileName = "testFile.lua"; + LuaInitializationChunk initialization {R"( + local dim = 2 + local lift = 3.0 + + return { + dimensions = dim, + shape_suffix = "2d", + lift = lift, + enabled = true + } + )", + "runtime_initialization"}; + LuaInputOptions options; + options.initialization = initialization; + + auto shapeSet = readShapeSetFromString(R"( + local function shape_path() + return "part_" .. shape_suffix .. ".stl" + end - std::string fileContents = R"( - dimensions = 2 shapes = { { - name = "wheel", + name = "controlled", material = "steel", geometry = { - format = "test_format", - path = "relative/path.format" + format = "stl", + path = enabled and shape_path() or "disabled.stl", + units = "cm", + operators = { + { translate = (dimensions == 2) and {1.0, lift} or {1.0, 0.0, lift} } + } } } } - )"; - std::ofstream fout {fileName}; - fout << fileContents; - fout.close(); + )", + InputFormat::Lua, + options); - auto shapeSet = klee::readShapeSet(fileName); + ASSERT_EQ(Dimensions::Two, shapeSet.getDimensions()); ASSERT_EQ(1u, shapeSet.getShapes().size()); - EXPECT_EQ("testFile.lua", shapeSet.getPath()); - EXPECT_EQ("relative/path.format", shapeSet.getShapes()[0].getGeometry().getPath()); + const auto& geometry = shapeSet.getShapes()[0].getGeometry(); + EXPECT_EQ("part_2d.stl", geometry.getPath()); + auto composite = std::dynamic_pointer_cast(geometry.getGeometryOperator()); + ASSERT_TRUE(composite); + ASSERT_EQ(1u, composite->getOperators().size()); + auto translation = std::dynamic_pointer_cast(composite->getOperators()[0]); + ASSERT_TRUE(translation); + EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {1.0, 3.0, 0.0})); } -TEST(IOTest, readShapeSet_luaReplacementRules) +TEST(IOTest, readShapeSet_luaInitializationExportsMutableGlobals) { - auto replaces = readShapeSetFromString(R"( - dimensions = 2 - shapes = { - { - name = "wheel", - material = "steel", - replaces = {"mat1", "mat2"}, - geometry = { - format = "test_format", - path = "path/to/file.format" - } + LuaInitializationChunk initialization {R"( + return { + dimensions = 2, + settings = { + lift = 3.0 } } )", - InputFormat::Lua); - ASSERT_EQ(1u, replaces.getShapes().size()); - EXPECT_TRUE(replaces.getShapes()[0].replaces("mat1")); - EXPECT_FALSE(replaces.getShapes()[0].replaces("mat3")); + "runtime_initialization"}; + LuaInputOptions options; + options.initialization = initialization; + + auto shapeSet = readShapeSetFromString(R"( + dimensions = 3 + settings.lift = 7.0 - auto doesNotReplace = readShapeSetFromString(R"( - dimensions = 2 shapes = { { - name = "wheel", + name = "overridden", material = "steel", - does_not_replace = {"mat1", "mat2"}, geometry = { - format = "test_format", - path = "path/to/file.format" + format = "stl", + path = "part.stl", + units = "cm", + operators = { + { translate = {1.0, 2.0, settings.lift} } + } } } } )", - InputFormat::Lua); - ASSERT_EQ(1u, doesNotReplace.getShapes().size()); - EXPECT_FALSE(doesNotReplace.getShapes()[0].replaces("mat1")); - EXPECT_TRUE(doesNotReplace.getShapes()[0].replaces("mat3")); + InputFormat::Lua, + options); + + ASSERT_EQ(Dimensions::Three, shapeSet.getDimensions()); + ASSERT_EQ(1u, shapeSet.getShapes().size()); + const auto& geometry = shapeSet.getShapes()[0].getGeometry(); + auto composite = std::dynamic_pointer_cast(geometry.getGeometryOperator()); + ASSERT_TRUE(composite); + ASSERT_EQ(1u, composite->getOperators().size()); + auto translation = std::dynamic_pointer_cast(composite->getOperators()[0]); + ASSERT_TRUE(translation); + EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {1.0, 2.0, 7.0})); } -TEST(IOTest, readShapeSet_luaGeometryOperators) +TEST(IOTest, readShapeSet_luaInitializationIsolatesUnexportedGlobals) { - auto shapeSet = readShapeSetFromString(R"( + LuaInitializationChunk initialization {R"( dimensions = 3 + unexported_value = "private" + math = { + sqrt = function() return -1 end + } + _G.also_unexported = "private" + + return { + exported_lift = 4.0 + } + )", + "runtime_initialization"}; + LuaInputOptions options; + options.initialization = initialization; + + auto shapeSet = readShapeSetFromString(R"( + dimensions = 2 + local isolation_ok = + unexported_value == nil and + also_unexported == nil and + math.sqrt(9.0) == 3.0 + shapes = { { - name = "windshield", - material = "glass", + name = "isolated", + material = "steel", geometry = { format = "stl", - path = "windshield.stl", - start_units = "m", - end_units = "cm", + path = isolation_ok and "isolated.stl" or "leaked.stl", + units = "cm", operators = { - { rotate = 90, axis = {0, 1, 0}, center = {0, 0, -10} }, - { translate = {10, 20, 30} }, - { scale = {1.5, 2.5, 3.5}, center = {1, 2, 3} }, - { convert_units_to = "cm" } + { translate = {math.sqrt(4.0), exported_lift} } } } - }, + } + } + )", + InputFormat::Lua, + options); + + ASSERT_EQ(Dimensions::Two, shapeSet.getDimensions()); + ASSERT_EQ(1u, shapeSet.getShapes().size()); + const auto& geometry = shapeSet.getShapes()[0].getGeometry(); + EXPECT_EQ("isolated.stl", geometry.getPath()); + auto composite = std::dynamic_pointer_cast(geometry.getGeometryOperator()); + ASSERT_TRUE(composite); + auto translation = std::dynamic_pointer_cast(composite->getOperators()[0]); + ASSERT_TRUE(translation); + EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {2.0, 4.0, 0.0})); +} + +TEST(IOTest, readShapeSet_luaInitializationCannotSetSchemaGlobalsWithoutExporting) +{ + for(std::string_view source : {"dimensions = 2; return {}", "_G.dimensions = 2; return {}"}) + { + LuaInitializationChunk initialization {std::string {source}, "runtime_initialization"}; + LuaInputOptions options; + options.initialization = initialization; + EXPECT_THROW(readShapeSetFromString("shapes = {}", InputFormat::Lua, options), KleeError); + } +} + +TEST(IOTest, readShapeSet_luaInitializationClosureRetainsEnvironment) +{ + // private_offset is assigned in the chunk's isolated environment and is not + // exported, so it is reachable only through the exported closure + LuaInitializationChunk initialization {R"( + local base_offset = 1.5 + private_offset = 3.5 + + return { + dimensions = 2, + offset = function() + return {base_offset, private_offset} + end + } + )", + "runtime_initialization"}; + LuaInputOptions options; + options.initialization = initialization; + + auto shapeSet = readShapeSetFromString(R"( + shapes = { { - name = "slice", + name = "closure", material = "steel", geometry = { format = "stl", - path = "slice.stl", - start_dimensions = 3, - dimensions = 2, + path = "closure.stl", units = "cm", operators = { - { slice = { x = 10 } } + { translate = offset } } } } } )", - InputFormat::Lua); + InputFormat::Lua, + options); - ASSERT_EQ(2u, shapeSet.getShapes().size()); - const auto& geometryOperator = shapeSet.getShapes()[0].getGeometry().getGeometryOperator(); - ASSERT_TRUE(geometryOperator); - auto composite = std::dynamic_pointer_cast(geometryOperator); + ASSERT_EQ(1u, shapeSet.getShapes().size()); + const auto& geometry = shapeSet.getShapes()[0].getGeometry(); + auto composite = std::dynamic_pointer_cast(geometry.getGeometryOperator()); ASSERT_TRUE(composite); - ASSERT_EQ(4u, composite->getOperators().size()); - - auto rotation = dynamic_cast(composite->getOperators()[0].get()); - ASSERT_NE(rotation, nullptr); - EXPECT_EQ(rotation->getAngle(), 90); + auto translation = std::dynamic_pointer_cast(composite->getOperators()[0]); + ASSERT_TRUE(translation); + EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {1.5, 3.5, 0.0})); +} - auto translation = dynamic_cast(composite->getOperators()[1].get()); - ASSERT_NE(translation, nullptr); - EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {10, 20, 30})); +TEST(IOTest, readShapeSet_luaInitializationPreservesLuaInteger) +{ + LuaInputOptions options; + options.initialization = LuaInitializationChunk {R"( + return { + exact_integer = 9007199254740993 + } + )", + "integer_initialization"}; - auto scale = dynamic_cast(composite->getOperators()[2].get()); - ASSERT_NE(scale, nullptr); - EXPECT_DOUBLE_EQ(1.5, scale->getXFactor()); - EXPECT_DOUBLE_EQ(2.5, scale->getYFactor()); - EXPECT_DOUBLE_EQ(3.5, scale->getZFactor()); - EXPECT_THAT(scale->getCenter(), AlmostEqPoint(Point3D {1, 2, 3})); - EXPECT_EQ(LengthUnit::cm, composite->getEndProperties().units); + auto shapeSet = readShapeSetFromString(R"( + dimensions = 2 + local integer_is_exact = exact_integer == 9007199254740993 + shapes = { + { + name = "integer", + material = "steel", + geometry = { + format = "stl", + path = integer_is_exact and "exact.stl" or "rounded.stl", + units = "cm" + } + } + } + )", + InputFormat::Lua, + options); - auto sliceComposite = std::dynamic_pointer_cast( - shapeSet.getShapes()[1].getGeometry().getGeometryOperator()); - ASSERT_TRUE(sliceComposite); - ASSERT_EQ(1u, sliceComposite->getOperators().size()); - EXPECT_TRUE(std::dynamic_pointer_cast(sliceComposite->getOperators()[0])); + ASSERT_EQ(1u, shapeSet.getShapes().size()); + EXPECT_EQ("exact.stl", shapeSet.getShapes()[0].getGeometry().getPath()); } -TEST(IOTest, readShapeSet_luaNamedGeometryOperatorsWithNestedRef) +TEST(IOTest, readShapeSet_luaInitializationIsolationIsShallow) { + LuaInputOptions options; + options.initialization = LuaInitializationChunk {R"( + math.initialization_value = 4.0 + return {} + )", + "shallow_initialization"}; + auto shapeSet = readShapeSetFromString(R"( dimensions = 2 - shapes = { { - name = "wheel", + name = "shared_table", material = "steel", geometry = { - format = "test_format", - path = "path/to/file.format", - units = "m", - operators = { - { ref = "outer_operation" } - } + format = "stl", + path = math.initialization_value == 4.0 and "shared.stl" or "isolated.stl", + units = "cm" } } } + )", + InputFormat::Lua, + options); - named_operators = { - { + ASSERT_EQ(1u, shapeSet.getShapes().size()); + EXPECT_EQ("shared.stl", shapeSet.getShapes()[0].getGeometry().getPath()); +} + +TEST(IOTest, readShapeSet_luaInitializationRejectsInvalidChunks) +{ + struct InvalidInitialization + { + std::string source; + std::string expectedMessage; + }; + + const std::array invalidInitializations {{ + {"", "empty"}, + {"return {", "Failed to evaluate"}, + {"error('initialization boom')", "initialization boom"}, + {"local value = 2", "must return a table"}, + {"return {[1] = 2}", "string keys"}, + {"return {bad = Vector.new(1, 2)}", "unsupported value type"}, + }}; + + for(const auto& invalid : invalidInitializations) + { + LuaInputOptions options; + options.initialization = LuaInitializationChunk {invalid.source, "invalid_initialization"}; + + try + { + readShapeSetFromString("dimensions = 2; shapes = {}", InputFormat::Lua, options); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr("invalid_initialization")); + EXPECT_THAT(err.what(), HasSubstr(invalid.expectedMessage)); + } + } +} + +TEST(IOTest, readShapeSet_luaInitializationRejectsInvalidExportName) +{ + LuaInputOptions options; + options.initialization = LuaInitializationChunk {R"( + return { + ["shape-dim"] = 2 + } + )", + "runtime_initialization"}; + + try + { + readShapeSetFromString(R"( + shapes = {} + )", + InputFormat::Lua, + options); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr("Invalid exported Lua global name")); + EXPECT_THAT(err.what(), HasSubstr("Lua identifiers")); + } +} + +TEST(IOTest, readShapeSet_luaInitializationRejectsKeywordExport) +{ + LuaInputOptions options; + options.initialization = LuaInitializationChunk {R"( + return { + ["function"] = 2 + } + )", + "runtime_initialization"}; + + try + { + readShapeSetFromString("dimensions = 2; shapes = {}", InputFormat::Lua, options); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr("Reserved Lua keywords")); + EXPECT_THAT(err.what(), HasSubstr("function")); + } +} + +TEST(IOTest, readShapeSet_luaInitializationRejectsReservedGlobalName) +{ + LuaInputOptions options; + options.initialization = LuaInitializationChunk {R"( + return { + math = 2 + } + )", + "runtime_initialization"}; + + try + { + readShapeSetFromString(R"( + shapes = {} + )", + InputFormat::Lua, + options); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr("conflicts with an existing Lua global")); + EXPECT_THAT(err.what(), HasSubstr("math")); + } +} + +TEST(IOTest, readShapeSet_luaInitializationRequiresTableReturn) +{ + LuaInputOptions options; + options.initialization = LuaInitializationChunk {"return 2", "runtime_initialization"}; + + try + { + readShapeSetFromString(R"( + dimensions = 2 + shapes = {} + )", + InputFormat::Lua, + options); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr("must return a table")); + EXPECT_THAT(err.what(), HasSubstr("runtime_initialization")); + } +} + +TEST(IOTest, readShapeSet_luaFileExtension) +{ + std::string fileName = "testFile.lua"; + + std::string fileContents = R"( + dimensions = 2 + shapes = { + { + name = "wheel", + material = "steel", + geometry = { + format = "test_format", + path = "relative/path.format" + } + } + } + )"; + std::ofstream fout {fileName}; + fout << fileContents; + fout.close(); + + auto shapeSet = klee::readShapeSet(fileName); + ASSERT_EQ(1u, shapeSet.getShapes().size()); + EXPECT_EQ("testFile.lua", shapeSet.getPath()); + EXPECT_EQ("relative/path.format", shapeSet.getShapes()[0].getGeometry().getPath()); +} + +TEST(IOTest, readShapeSet_luaReplacementRules) +{ + auto replaces = readShapeSetFromString(R"( + dimensions = 2 + shapes = { + { + name = "wheel", + material = "steel", + replaces = {"mat1", "mat2"}, + geometry = { + format = "test_format", + path = "path/to/file.format" + } + } + } + )", + InputFormat::Lua); + ASSERT_EQ(1u, replaces.getShapes().size()); + EXPECT_TRUE(replaces.getShapes()[0].replaces("mat1")); + EXPECT_FALSE(replaces.getShapes()[0].replaces("mat3")); + + auto doesNotReplace = readShapeSetFromString(R"( + dimensions = 2 + shapes = { + { + name = "wheel", + material = "steel", + does_not_replace = {"mat1", "mat2"}, + geometry = { + format = "test_format", + path = "path/to/file.format" + } + } + } + )", + InputFormat::Lua); + ASSERT_EQ(1u, doesNotReplace.getShapes().size()); + EXPECT_FALSE(doesNotReplace.getShapes()[0].replaces("mat1")); + EXPECT_TRUE(doesNotReplace.getShapes()[0].replaces("mat3")); +} + +TEST(IOTest, readShapeSet_luaGeometryOperators) +{ + auto shapeSet = readShapeSetFromString(R"( + dimensions = 3 + shapes = { + { + name = "windshield", + material = "glass", + geometry = { + format = "stl", + path = "windshield.stl", + start_units = "m", + end_units = "cm", + operators = { + { rotate = 90, axis = {0, 1, 0}, center = {0, 0, -10} }, + { translate = {10, 20, 30} }, + { scale = {1.5, 2.5, 3.5}, center = {1, 2, 3} }, + { convert_units_to = "cm" } + } + } + }, + { + name = "slice", + material = "steel", + geometry = { + format = "stl", + path = "slice.stl", + start_dimensions = 3, + dimensions = 2, + units = "cm", + operators = { + { slice = { x = 10 } } + } + } + } + } + )", + InputFormat::Lua); + + ASSERT_EQ(2u, shapeSet.getShapes().size()); + const auto& geometryOperator = shapeSet.getShapes()[0].getGeometry().getGeometryOperator(); + ASSERT_TRUE(geometryOperator); + auto composite = std::dynamic_pointer_cast(geometryOperator); + ASSERT_TRUE(composite); + ASSERT_EQ(4u, composite->getOperators().size()); + + auto rotation = dynamic_cast(composite->getOperators()[0].get()); + ASSERT_NE(rotation, nullptr); + EXPECT_EQ(rotation->getAngle(), 90); + + auto translation = dynamic_cast(composite->getOperators()[1].get()); + ASSERT_NE(translation, nullptr); + EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {10, 20, 30})); + + auto scale = dynamic_cast(composite->getOperators()[2].get()); + ASSERT_NE(scale, nullptr); + EXPECT_DOUBLE_EQ(1.5, scale->getXFactor()); + EXPECT_DOUBLE_EQ(2.5, scale->getYFactor()); + EXPECT_DOUBLE_EQ(3.5, scale->getZFactor()); + EXPECT_THAT(scale->getCenter(), AlmostEqPoint(Point3D {1, 2, 3})); + EXPECT_EQ(LengthUnit::cm, composite->getEndProperties().units); + + auto sliceComposite = std::dynamic_pointer_cast( + shapeSet.getShapes()[1].getGeometry().getGeometryOperator()); + ASSERT_TRUE(sliceComposite); + ASSERT_EQ(1u, sliceComposite->getOperators().size()); + EXPECT_TRUE(std::dynamic_pointer_cast(sliceComposite->getOperators()[0])); +} + +TEST(IOTest, readShapeSet_luaNamedGeometryOperatorsWithNestedRef) +{ + auto shapeSet = readShapeSetFromString(R"( + dimensions = 2 + + shapes = { + { + name = "wheel", + material = "steel", + geometry = { + format = "test_format", + path = "path/to/file.format", + units = "m", + operators = { + { ref = "outer_operation" } + } + } + } + } + + named_operators = { + { name = "inner_operation", units = "m", value = { @@ -665,59 +1216,427 @@ TEST(IOTest, readShapeSet_luaNamedGeometryOperatorsWithNestedRef) } }, { - name = "outer_operation", - units = "m", - value = { - { ref = "inner_operation" }, - { translate = {10, 20} } + name = "outer_operation", + units = "m", + value = { + { ref = "inner_operation" }, + { translate = {10, 20} } + } + } + } + )", + InputFormat::Lua); + + ASSERT_EQ(1u, shapeSet.getShapes().size()); + auto composite = std::dynamic_pointer_cast( + shapeSet.getShapes()[0].getGeometry().getGeometryOperator()); + ASSERT_TRUE(composite); + ASSERT_EQ(1u, composite->getOperators().size()); + auto referenced = dynamic_cast(composite->getOperators()[0].get()); + ASSERT_NE(referenced, nullptr); + ASSERT_EQ(2u, referenced->getOperators().size()); + auto nested = dynamic_cast(referenced->getOperators()[0].get()); + ASSERT_NE(nested, nullptr); + EXPECT_EQ(1u, nested->getOperators().size()); + auto translation = dynamic_cast(referenced->getOperators()[1].get()); + ASSERT_NE(translation, nullptr); + EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {10, 20, 0})); +} + +TEST(IOTest, readShapeSet_luaNamedOperatorCallbackIsEvaluatedOnceAndReused) +{ + auto shapeSet = readShapeSetFromString(R"( + local callback_calls = 0 + + dimensions = 2 + + named_operators = { + { + name = "shared_callback", + units = "cm", + value = { + { + translate = function() + callback_calls = callback_calls + 1 + return {callback_calls, 2} + end + } + } + } + } + + shapes = { + { + name = "first", + material = "steel", + geometry = { + format = "stl", + path = "first.stl", + units = "cm", + operators = { + { ref = "shared_callback" } + } + } + }, + { + name = "second", + material = "steel", + geometry = { + format = "stl", + path = "second.stl", + units = "cm", + operators = { + { ref = "shared_callback" } + } + } + } + } + )", + InputFormat::Lua); + + ASSERT_EQ(2u, shapeSet.getShapes().size()); + auto firstComposite = std::dynamic_pointer_cast( + shapeSet.getShapes()[0].getGeometry().getGeometryOperator()); + auto secondComposite = std::dynamic_pointer_cast( + shapeSet.getShapes()[1].getGeometry().getGeometryOperator()); + ASSERT_TRUE(firstComposite); + ASSERT_TRUE(secondComposite); + ASSERT_EQ(1u, firstComposite->getOperators().size()); + ASSERT_EQ(1u, secondComposite->getOperators().size()); + + // Both refs reuse the concrete named operator built before either shape. + EXPECT_EQ(firstComposite->getOperators()[0], secondComposite->getOperators()[0]); + auto sharedOperator = + std::dynamic_pointer_cast(firstComposite->getOperators()[0]); + ASSERT_TRUE(sharedOperator); + ASSERT_EQ(1u, sharedOperator->getOperators().size()); + auto translation = std::dynamic_pointer_cast(sharedOperator->getOperators()[0]); + ASSERT_TRUE(translation); + EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {1, 2, 0})); +} + +TEST(IOTest, readShapeSet_luaCallbacksAreEachEvaluatedOnce) +{ + auto shapeSet = readShapeSetFromString(R"( + local callback_index = 0 + + local function ordered(expected_index, value) + return function() + callback_index = callback_index + 1 + if callback_index ~= expected_index then + error("expected callback " .. expected_index .. + ", got callback " .. callback_index) + end + return value + end + end + + dimensions = 3 + + named_operators = { + { + name = "rotate_and_scale", + units = "cm", + value = { + { + rotate = ordered(1, 30), + center = ordered(2, {1, 2, 3}), + axis = ordered(3, {0, 0, 1}) + }, + { + scale = ordered(4, {2}), + center = ordered(5, {4, 5, 6}) + } + } + }, + { + name = "shift", + units = "cm", + value = { + { translate = ordered(6, {7, 8, 9}) } + } + } + } + + shapes = { + { + name = "reference", + material = "steel", + geometry = { + format = "stl", + path = "reference.stl", + units = "cm", + operators = { + { translate = ordered(7, {1, 2, 3}) }, + { ref = ordered(8, "rotate_and_scale") } + } + } + }, + { + name = "arbitrary_slice", + material = "steel", + geometry = { + format = "stl", + path = "arbitrary_slice.stl", + start_dimensions = 3, + dimensions = 2, + units = "cm", + operators = { + { + slice = { + origin = ordered(9, {0, 0, 0}), + normal = ordered(10, {0, 0, 1}), + up = ordered(11, {0, 1, 0}) + } + } + } + } + }, + { + name = "perpendicular_slice", + material = "steel", + geometry = { + format = "stl", + path = "perpendicular_slice.stl", + start_dimensions = 3, + dimensions = 2, + units = "cm", + operators = { + { + slice = { + x = ordered(12, 3), + origin = ordered(13, {3, 0, 0}), + normal = ordered(14, {1, 0, 0}), + up = ordered(15, {0, 1, 0}) + } + } + } + } + } + } + )", + InputFormat::Lua); + + // Reaching the last callback demonstrates that each one ran exactly once. + // The specific order is an implementation detail. + ASSERT_EQ(3u, shapeSet.getShapes().size()); +} +#endif + +TEST(IOTest, readShapeSet_luaNamedOperatorCallbackErrorIncludesContext) +{ + try + { + readShapeSetFromString(R"( + dimensions = 2 + + named_operators = { + { + name = "bad_named_operator", + units = "cm", + value = { + { + translate = function() + error("named callback boom") + end + } + } + } + } + + shapes = { + { + name = "placeholder", + material = "steel", + geometry = { + format = "stl", + path = "placeholder.stl", + units = "cm" + } + } + } + )", + InputFormat::Lua); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr("translate")); + EXPECT_THAT(err.what(), HasSubstr("named operator")); + EXPECT_THAT(err.what(), HasSubstr("bad_named_operator")); + EXPECT_THAT(err.what(), HasSubstr("operator 1")); + EXPECT_THAT(err.what(), HasSubstr("named callback boom")); + } +} + +TEST(IOTest, readShapeSet_luaDifferentDimensions) +{ + auto shapeSet = readShapeSetFromString(R"( + dimensions = 2 + shapes = { + { + name = "flat", + material = "steel", + geometry = { + format = "stl", + path = "flat.stl", + dimensions = 3, + units = "cm" + } + }, + { + name = "sliced", + material = "glass", + geometry = { + format = "stl", + path = "sliced.stl", + start_dimensions = 3, + dimensions = 2, + units = "cm", + operators = { + { slice = { z = 0 } } + } + } + } + } + )", + InputFormat::Lua); + + ASSERT_EQ(2u, shapeSet.getShapes().size()); + EXPECT_EQ(Dimensions::Three, shapeSet.getShapes()[0].getGeometry().getInputDimensions()); + EXPECT_EQ(Dimensions::Three, shapeSet.getShapes()[0].getGeometry().getOutputDimensions()); + EXPECT_EQ(Dimensions::Three, shapeSet.getShapes()[1].getGeometry().getInputDimensions()); + EXPECT_EQ(Dimensions::Two, shapeSet.getShapes()[1].getGeometry().getOutputDimensions()); +} + +TEST(IOTest, readShapeSet_luaGeneratedOrdinaryTableValues) +{ + auto shapeSet = readShapeSetFromString(R"( + local dim = 2 + local r = 4.0 + local z = 8.0 + local x = 1.0 + local y = 2.0 + + dimensions = dim + + shapes = { + { + name = "part", + material = "steel", + geometry = { + format = "stl", + path = "part.stl", + units = "cm", + operators = { + { translate = (dim == 2) and {r, z} or {x, y, z} } + } + } + } + } + )", + InputFormat::Lua); + + ASSERT_EQ(1u, shapeSet.getShapes().size()); + auto composite = std::dynamic_pointer_cast( + shapeSet.getShapes()[0].getGeometry().getGeometryOperator()); + ASSERT_TRUE(composite); + ASSERT_EQ(1u, composite->getOperators().size()); + auto translation = dynamic_cast(composite->getOperators()[0].get()); + ASSERT_NE(translation, nullptr); + EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {4, 8, 0})); +} + +TEST(IOTest, readShapeSet_lua2dOperatorCallbacks) +{ + // Vector-valued callbacks in a 2D deck return two components and are padded to three. + // Scale is covered here because readShapeSet_luaOperatorCallbacks is 3D. + auto shapeSet = readShapeSetFromString(R"( + dimensions = 2 + shapes = { + { + name = "part", + material = "steel", + geometry = { + format = "stl", + path = "part.stl", + units = "cm", + operators = { + { translate = function() return {4, 8} end }, + { scale = function() return {2.0, 3.0} end } + } } } } )", InputFormat::Lua); - ASSERT_EQ(1u, shapeSet.getShapes().size()); auto composite = std::dynamic_pointer_cast( shapeSet.getShapes()[0].getGeometry().getGeometryOperator()); ASSERT_TRUE(composite); - ASSERT_EQ(1u, composite->getOperators().size()); - auto referenced = dynamic_cast(composite->getOperators()[0].get()); - ASSERT_NE(referenced, nullptr); - ASSERT_EQ(2u, referenced->getOperators().size()); - auto nested = dynamic_cast(referenced->getOperators()[0].get()); - ASSERT_NE(nested, nullptr); - EXPECT_EQ(1u, nested->getOperators().size()); - auto translation = dynamic_cast(referenced->getOperators()[1].get()); + ASSERT_EQ(2u, composite->getOperators().size()); + + auto translation = dynamic_cast(composite->getOperators()[0].get()); ASSERT_NE(translation, nullptr); - EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {10, 20, 0})); + EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {4, 8, 0})); + + auto scale = dynamic_cast(composite->getOperators()[1].get()); + ASSERT_NE(scale, nullptr); + EXPECT_DOUBLE_EQ(2.0, scale->getXFactor()); + EXPECT_DOUBLE_EQ(3.0, scale->getYFactor()); + EXPECT_DOUBLE_EQ(1.0, scale->getZFactor()); } -TEST(IOTest, readShapeSet_luaDifferentDimensions) +TEST(IOTest, readShapeSet_luaOperatorCallbacks) { auto shapeSet = readShapeSetFromString(R"( - dimensions = 2 + local dim = 3 + + dimensions = dim + shapes = { { - name = "flat", + name = "callbacks", material = "steel", geometry = { format = "stl", - path = "flat.stl", - dimensions = 3, - units = "cm" + path = "callbacks.stl", + units = "cm", + operators = { + { + rotate = function() return 45 end, + axis = function() return {0, 0, 1} end, + center = function() return Vector.new(1, 2, 3) end + }, + { translate = function() return {4, 5, 6} end }, + { + scale = function() return {2.0} end, + center = function() return {3, 4, 5} end + }, + { + scale = function() return {1.5, 2.5, 3.5} end, + center = function() return {1, 1, 1} end + } + } } }, { - name = "sliced", + name = "slice_callbacks", material = "glass", geometry = { format = "stl", - path = "sliced.stl", + path = "slice_callbacks.stl", start_dimensions = 3, dimensions = 2, units = "cm", operators = { - { slice = { z = 0 } } + { + slice = { + origin = function() return {1, 2, 3} end, + normal = function() return {0, 0, 1} end, + up = function() return {0, 1, 0} end + } + } } } } @@ -726,33 +1645,75 @@ TEST(IOTest, readShapeSet_luaDifferentDimensions) InputFormat::Lua); ASSERT_EQ(2u, shapeSet.getShapes().size()); - EXPECT_EQ(Dimensions::Three, shapeSet.getShapes()[0].getGeometry().getInputDimensions()); - EXPECT_EQ(Dimensions::Three, shapeSet.getShapes()[0].getGeometry().getOutputDimensions()); - EXPECT_EQ(Dimensions::Three, shapeSet.getShapes()[1].getGeometry().getInputDimensions()); - EXPECT_EQ(Dimensions::Two, shapeSet.getShapes()[1].getGeometry().getOutputDimensions()); + auto composite = std::dynamic_pointer_cast( + shapeSet.getShapes()[0].getGeometry().getGeometryOperator()); + ASSERT_TRUE(composite); + ASSERT_EQ(4u, composite->getOperators().size()); + + auto rotation = dynamic_cast(composite->getOperators()[0].get()); + ASSERT_NE(rotation, nullptr); + EXPECT_DOUBLE_EQ(45.0, rotation->getAngle()); + EXPECT_THAT(rotation->getAxis(), AlmostEqVector(Vector3D {0, 0, 1})); + EXPECT_THAT(rotation->getCenter(), AlmostEqPoint(Point3D {1, 2, 3})); + + auto translation = dynamic_cast(composite->getOperators()[1].get()); + ASSERT_NE(translation, nullptr); + EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {4, 5, 6})); + + auto uniformScale = dynamic_cast(composite->getOperators()[2].get()); + ASSERT_NE(uniformScale, nullptr); + EXPECT_DOUBLE_EQ(2.0, uniformScale->getXFactor()); + EXPECT_DOUBLE_EQ(2.0, uniformScale->getYFactor()); + EXPECT_DOUBLE_EQ(2.0, uniformScale->getZFactor()); + EXPECT_THAT(uniformScale->getCenter(), AlmostEqPoint(Point3D {3, 4, 5})); + + auto vectorScale = dynamic_cast(composite->getOperators()[3].get()); + ASSERT_NE(vectorScale, nullptr); + EXPECT_DOUBLE_EQ(1.5, vectorScale->getXFactor()); + EXPECT_DOUBLE_EQ(2.5, vectorScale->getYFactor()); + EXPECT_DOUBLE_EQ(3.5, vectorScale->getZFactor()); + EXPECT_THAT(vectorScale->getCenter(), AlmostEqPoint(Point3D {1, 1, 1})); + + auto sliceComposite = std::dynamic_pointer_cast( + shapeSet.getShapes()[1].getGeometry().getGeometryOperator()); + ASSERT_TRUE(sliceComposite); + ASSERT_EQ(1u, sliceComposite->getOperators().size()); + auto slice = std::dynamic_pointer_cast(sliceComposite->getOperators()[0]); + ASSERT_TRUE(slice); + EXPECT_THAT(slice->getOrigin(), AlmostEqPoint(Point3D {1, 2, 3})); + EXPECT_THAT(slice->getNormal(), AlmostEqVector(Vector3D {0, 0, 1})); + EXPECT_THAT(slice->getUp(), AlmostEqVector(Vector3D {0, 1, 0})); } -TEST(IOTest, readShapeSet_luaGeneratedOrdinaryTableValues) +TEST(IOTest, readShapeSet_luaStringOperatorCallbacks) { auto shapeSet = readShapeSetFromString(R"( - local dim = 2 - local r = 4.0 - local z = 8.0 - local x = 1.0 - local y = 2.0 + local target_units = "cm" + local selected_operator = "shift" - dimensions = dim + dimensions = 2 + + named_operators = { + { + name = "shift", + units = "cm", + value = { + { translate = {1, 2} } + } + } + } shapes = { { - name = "part", + name = "string_callbacks", material = "steel", geometry = { format = "stl", - path = "part.stl", - units = "cm", + path = "string_callbacks.stl", + units = "m", operators = { - { translate = (dim == 2) and {r, z} or {x, y, z} } + { convert_units_to = function() return target_units end }, + { ref = function() return selected_operator end } } } } @@ -764,10 +1725,350 @@ TEST(IOTest, readShapeSet_luaGeneratedOrdinaryTableValues) auto composite = std::dynamic_pointer_cast( shapeSet.getShapes()[0].getGeometry().getGeometryOperator()); ASSERT_TRUE(composite); - ASSERT_EQ(1u, composite->getOperators().size()); - auto translation = dynamic_cast(composite->getOperators()[0].get()); - ASSERT_NE(translation, nullptr); - EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {4, 8, 0})); + ASSERT_EQ(2u, composite->getOperators().size()); + + auto converter = std::dynamic_pointer_cast(composite->getOperators()[0]); + ASSERT_TRUE(converter); + EXPECT_EQ(LengthUnit::m, converter->getStartProperties().units); + EXPECT_EQ(LengthUnit::cm, converter->getEndProperties().units); + + auto referenced = std::dynamic_pointer_cast(composite->getOperators()[1]); + ASSERT_TRUE(referenced); + ASSERT_EQ(1u, referenced->getOperators().size()); + auto translation = std::dynamic_pointer_cast(referenced->getOperators()[0]); + ASSERT_TRUE(translation); + EXPECT_THAT(translation->getOffset(), AlmostEqVector(Vector3D {1, 2, 0})); +} + +TEST(IOTest, readShapeSet_luaStringOperatorCallbackErrorsIncludeContext) +{ + struct FailureCase + { + const char* field; + const char* returnExpression; + const char* expectedMessage; + }; + const std::array cases {{ + {"convert_units_to", "{}", "function call"}, + {"convert_units_to", "\"parsec\"", "Unrecognized units"}, + {"ref", "{}", "function call"}, + {"ref", "\"missing_operator\"", "No operator named"}, + }}; + + for(const auto& testCase : cases) + { + SCOPED_TRACE(testCase.expectedMessage); + try + { + readShapeSetFromString( + makeLuaStringOperatorCallbackInput(testCase.field, testCase.returnExpression), + InputFormat::Lua); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr(std::string {"callback for '"} + testCase.field + "'")); + EXPECT_THAT(err.what(), HasSubstr("string_callback")); + EXPECT_THAT(err.what(), HasSubstr("operator 1")); + EXPECT_THAT(err.what(), HasSubstr(testCase.expectedMessage)); + } + } +} + +TEST(IOTest, readShapeSet_luaPerpendicularSliceCallbacks) +{ + auto shapeSet = readShapeSetFromString(R"( + dimensions = 2 + + shapes = { + { + name = "x_slice", + material = "steel", + geometry = { + format = "stl", + path = "x_slice.stl", + start_dimensions = 3, + dimensions = 2, + units = "cm", + operators = { + { slice = { x = function() return 10 end } } + } + } + }, + { + name = "y_slice", + material = "steel", + geometry = { + format = "stl", + path = "y_slice.stl", + start_dimensions = 3, + dimensions = 2, + units = "cm", + operators = { + { slice = { y = function() return 20 end } } + } + } + }, + { + name = "z_slice", + material = "steel", + geometry = { + format = "stl", + path = "z_slice.stl", + start_dimensions = 3, + dimensions = 2, + units = "cm", + operators = { + { slice = { z = function() return 30 end } } + } + } + } + } + )", + InputFormat::Lua); + + const std::array expectedOrigins {{{10, 0, 0}, {0, 20, 0}, {0, 0, 30}}}; + const std::array expectedNormals {{{1, 0, 0}, {0, 1, 0}, {0, 0, 1}}}; + const std::array expectedUp {{{0, 0, 1}, {1, 0, 0}, {0, 1, 0}}}; + + ASSERT_EQ(3u, shapeSet.getShapes().size()); + for(std::size_t i = 0; i < shapeSet.getShapes().size(); ++i) + { + SCOPED_TRACE(i); + auto composite = std::dynamic_pointer_cast( + shapeSet.getShapes()[i].getGeometry().getGeometryOperator()); + ASSERT_TRUE(composite); + ASSERT_EQ(1u, composite->getOperators().size()); + auto slice = std::dynamic_pointer_cast(composite->getOperators()[0]); + ASSERT_TRUE(slice); + EXPECT_THAT(slice->getOrigin(), AlmostEqPoint(expectedOrigins[i])); + EXPECT_THAT(slice->getNormal(), AlmostEqVector(expectedNormals[i])); + EXPECT_THAT(slice->getUp(), AlmostEqVector(expectedUp[i])); + } +} + +TEST(IOTest, readShapeSet_luaPerpendicularSliceCallbackWrongTypeIncludesContext) +{ + for(const char* axis : {"x", "y", "z"}) + { + SCOPED_TRACE(axis); + const auto sliceFields = std::string {axis} + " = function() return {1} end"; + try + { + readShapeSetFromString(makeLuaSliceCallbackInput(sliceFields), InputFormat::Lua); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr(std::string {"callback for '"} + axis + "'")); + EXPECT_THAT(err.what(), HasSubstr("slice_callback")); + EXPECT_THAT(err.what(), HasSubstr("operator 1")); + } + } +} + +TEST(IOTest, readShapeSet_luaPerpendicularSliceCallbacksAreValidated) +{ + struct ValidationCase + { + const char* field; + const char* returnValue; + const char* expectedMessage; + }; + const std::array cases {{ + {"origin", "{20, 0, 0}", "slice plane"}, + {"normal", "{1, 2, 3}", "Invalid normal"}, + {"up", "{1, 0, 0}", "perpendicular"}, + }}; + + for(const auto& testCase : cases) + { + SCOPED_TRACE(testCase.field); + const auto sliceFields = std::string {"x = function() return 10 end, "} + testCase.field + + " = function() return " + testCase.returnValue + " end"; + try + { + readShapeSetFromString(makeLuaSliceCallbackInput(sliceFields), InputFormat::Lua); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr(std::string {"callback for '"} + testCase.field + "'")); + EXPECT_THAT(err.what(), HasSubstr("shape 'slice_callback'")); + EXPECT_THAT(err.what(), HasSubstr("operator 1")); + EXPECT_THAT(err.what(), HasSubstr(testCase.expectedMessage)); + } + } +} + +TEST(IOTest, readShapeSet_luaArbitrarySliceCallbackValidationErrorsIncludeContext) +{ + struct ValidationCase + { + const char* field; + const char* sliceFields; + const char* expectedMessage; + }; + const std::array cases {{ + {"normal", + "origin = {0, 0, 0}, " + "normal = function() return {0, 0, 0} end, " + "up = {0, 1, 0}", + "zero"}, + {"up", + "origin = {0, 0, 0}, " + "normal = {1, 0, 0}, " + "up = function() return {1, 0, 0} end", + "perpendicular"}, + }}; + + for(const auto& testCase : cases) + { + SCOPED_TRACE(testCase.field); + try + { + readShapeSetFromString(makeLuaSliceCallbackInput(testCase.sliceFields), InputFormat::Lua); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr(std::string {"callback for '"} + testCase.field + "'")); + EXPECT_THAT(err.what(), HasSubstr("shape 'slice_callback'")); + EXPECT_THAT(err.what(), HasSubstr("operator 1")); + EXPECT_THAT(err.what(), HasSubstr(testCase.expectedMessage)); + } + } +} + +TEST(IOTest, readShapeSet_luaRotationCallbacksAreValidated) +{ + struct ValidationCase + { + const char* fields; + const char* field; + const char* expectedMessage; + }; + const std::array cases {{ + {"rotate = function() return {45} end, axis = {0, 0, 1}", "rotate", "function call"}, + {"rotate = 45, axis = function() return {0, 1} end", "axis", "Wrong size"}, + {"rotate = 45, axis = {0, 0, 1}, center = function() return {1, 2} end", "center", "Wrong size"}, + {"rotate = 45, axis = function() return {0, 0, 0} end", "axis", "zero"}, + }}; + + for(const auto& testCase : cases) + { + SCOPED_TRACE(testCase.expectedMessage); + try + { + readShapeSetFromString(makeLuaRotationCallbackInput(testCase.fields), InputFormat::Lua); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr(std::string {"callback for '"} + testCase.field + "'")); + EXPECT_THAT(err.what(), HasSubstr("rotation_callback")); + EXPECT_THAT(err.what(), HasSubstr("operator 1")); + EXPECT_THAT(err.what(), HasSubstr(testCase.expectedMessage)); + } + } +} + +TEST(IOTest, readShapeSet_luaCallbackErrorIncludesContext) +{ + try + { + readShapeSetFromString(R"( + dimensions = 2 + shapes = { + { + name = "bad_shape", + material = "steel", + geometry = { + format = "stl", + path = "bad.stl", + units = "cm", + operators = { + { + translate = function() + error("callback boom") + end + } + } + } + } + } + )", + InputFormat::Lua); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + // The operator is identified by its Lua table key, which is 1-based + EXPECT_THAT(err.what(), + HasSubstr("Error evaluating callback for 'translate' in shape 'bad_shape' " + "operator 1")); + EXPECT_THAT(err.what(), HasSubstr("callback boom")); + } +} + +TEST(IOTest, readShapeSet_luaCallbackWrongVectorDimensionIncludesContext) +{ + try + { + readShapeSetFromString(R"( + dimensions = 2 + shapes = { + { + name = "wrong_dim", + material = "steel", + geometry = { + format = "stl", + path = "wrong_dim.stl", + units = "cm", + operators = { + { translate = function() return {1, 2, 3} end } + } + } + } + } + )", + InputFormat::Lua); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr("translate")); + EXPECT_THAT(err.what(), HasSubstr("wrong_dim")); + EXPECT_THAT(err.what(), HasSubstr("Wrong size")); + } +} + +TEST(IOTest, readShapeSet_luaFunctionValueWrongTypeOutsideCallbackFields) +{ + try + { + readShapeSetFromString(R"( + dimensions = 2 + shapes = { + { + name = "bad_units", + material = "steel", + geometry = { + format = "stl", + path = "part.stl", + units = function() return "cm" end + } + } + } + )", + InputFormat::Lua); + FAIL() << "Should have thrown"; + } + catch(const KleeError& err) + { + EXPECT_THAT(err.what(), HasSubstr("units")); + EXPECT_THAT(err.what(), HasSubstr("wrong type")); + } } TEST(IOTest, readShapeSet_luaUnexpectedGlobalDiagnostic) @@ -816,7 +2117,6 @@ TEST(IOTest, readShapeSet_luaNestedUnexpectedFieldsMatchYamlValidation) ASSERT_EQ(1u, shapeSet.getShapes().size()); EXPECT_EQ("wheel", shapeSet.getShapes()[0].getName()); } -#endif TEST(IOTest, readShapeSet_shapeWithReplacesAndDoesNotReplaceLists) { @@ -892,7 +2192,7 @@ TEST(IOTest, readShapeSet_geometryOperators_scaleWithCenter) path: path/to/file.format units: m operators: - - scale: [1.5, 2.5] + - scale: [1.5] center: [10, 20] )"); auto& shapes = shapeSet.getShapes(); @@ -907,8 +2207,8 @@ TEST(IOTest, readShapeSet_geometryOperators_scaleWithCenter) auto scale = dynamic_cast(composite->getOperators()[0].get()); ASSERT_NE(scale, nullptr); EXPECT_DOUBLE_EQ(1.5, scale->getXFactor()); - EXPECT_DOUBLE_EQ(2.5, scale->getYFactor()); - EXPECT_DOUBLE_EQ(1.0, scale->getZFactor()); + EXPECT_DOUBLE_EQ(1.5, scale->getYFactor()); + EXPECT_DOUBLE_EQ(1.5, scale->getZFactor()); EXPECT_THAT(scale->getCenter(), AlmostEqPoint(Point3D {10, 20, 0})); } diff --git a/src/axom/quest/examples/CMakeLists.txt b/src/axom/quest/examples/CMakeLists.txt index e168c25f03..fab95ba165 100644 --- a/src/axom/quest/examples/CMakeLists.txt +++ b/src/axom/quest/examples/CMakeLists.txt @@ -176,6 +176,7 @@ if(AXOM_HAS_MFEM_WITH_MPI AND AXOM_ENABLE_SIDRE AND AXOM_ENABLE_KLEE) if(AXOM_ENABLE_TESTS AND AXOM_DATA_DIR) # 2D shaping tests set(_nranks 1) + if(C2C_FOUND) set(_testname quest_shaping_driver_ex_sampling_circles) axom_add_test( @@ -350,6 +351,48 @@ if(AXOM_HAS_MFEM_WITH_MPI AND AXOM_ENABLE_SIDRE AND AXOM_ENABLE_KLEE) # 3D shaping tests set(_nranks 3) + + if(LUA_FOUND) + set(_testname quest_shaping_driver_ex_lua_callbacks) + axom_add_test( + NAME ${_testname} + COMMAND quest_shaping_driver_ex + -i ${shaping_data_dir}/sphere_callbacks.lua + --method sampling + inline_mesh --min -6 -6 -6 --max 6 6 6 --resolution 16 16 16 -d 3 + NUM_MPI_TASKS ${_nranks}) + set_tests_properties(${_testname} PROPERTIES + PASS_REGULAR_EXPRESSION "Volume of material 'steel' is 519.") + + set(_testname quest_shaping_driver_ex_lua_initialization) + axom_add_test( + NAME ${_testname} + COMMAND quest_shaping_driver_ex + -i ${shaping_data_dir}/sphere_callbacks.lua + --lua-init-file ${shaping_data_dir}/sphere_initialization.lua + --method sampling + inline_mesh --min -4.8 -4.8 -4.8 --max 4.8 4.8 4.8 + --resolution 16 16 16 -d 3 + NUM_MPI_TASKS ${_nranks}) + # Analytic volume for a sphere with radius 4 is ~268.08 + # the input surface is discretized and the computational mesh is coarse. + set_tests_properties(${_testname} PROPERTIES + PASS_REGULAR_EXPRESSION "Volume of material 'steel' is 26[56].") + + set(_testname quest_shaping_driver_ex_yaml_rejects_lua_initialization) + axom_add_test( + NAME ${_testname} + COMMAND quest_shaping_driver_ex + -i ${shaping_data_dir}/sphere.yaml + --lua-init-file ${shaping_data_dir}/sphere_initialization.lua + --method sampling + inline_mesh --min -6 -6 -6 --max 6 6 6 --resolution 16 16 16 -d 3 + NUM_MPI_TASKS ${_nranks}) + set_tests_properties(${_testname} PROPERTIES + PASS_REGULAR_EXPRESSION + "only supported for Lua input decks") + endif() + set(_testname quest_shaping_driver_ex_sampling_sphere) axom_add_test( NAME ${_testname} diff --git a/src/axom/quest/examples/shaping_driver.cpp b/src/axom/quest/examples/shaping_driver.cpp index 19b0ff1aae..c659a50f24 100644 --- a/src/axom/quest/examples/shaping_driver.cpp +++ b/src/axom/quest/examples/shaping_driver.cpp @@ -47,10 +47,12 @@ // C/C++ includes #include +#include +#include #include +#include #include #include -#include namespace klee = axom::klee; namespace primal = axom::primal; @@ -137,6 +139,7 @@ struct Input BlueprintMeshBacking blueprintMeshBacking {BlueprintMeshBacking::Sidre}; std::string shapeFile; + std::string luaInitializationFile; klee::ShapeSet shapeSet; ShapingMethod shapingMethod {ShapingMethod::Sampling}; @@ -335,10 +338,14 @@ struct Input void parse(int argc, char** argv, axom::CLI::App& app) { app.add_option("-i,--shape-file", shapeFile) - ->description("Path to input shape file") + ->description("Path to Klee input file (YAML or Lua)") ->check(axom::CLI::ExistingFile) ->required(); + app.add_option("--lua-init-file", luaInitializationFile) + ->description("Lua chunk that returns initial globals for a Lua Klee input file") + ->check(axom::CLI::ExistingFile); + app.add_flag("-v,--verbose,!--no-verbose", m_verboseOutput) ->description("Enable/disable verbose output") ->capture_default_str(); @@ -549,6 +556,33 @@ struct Input slic::setLoggingMsgLevel(m_verboseOutput ? slic::message::Debug : slic::message::Info); } + + /** + * Load the optional Lua initialization file selected on the command line. + * + * \return Lua input options containing the initialization source and label + * \throws klee::KleeError if the initialization file cannot be read + */ + klee::LuaInputOptions loadLuaInputOptions() const + { + klee::LuaInputOptions options; + if(luaInitializationFile.empty()) + { + return options; + } + + std::ifstream stream {luaInitializationFile}; + if(!stream) + { + throw klee::KleeError( + {axom::Path {luaInitializationFile}, "Could not read Lua initialization file"}); + } + + options.initialization = klee::LuaInitializationChunk { + std::string {std::istreambuf_iterator {stream}, std::istreambuf_iterator {}}, + luaInitializationFile}; + return options; + } }; /** @@ -699,12 +733,12 @@ int main(int argc, char** argv) AXOM_ANNOTATE_BEGIN("init"); //--------------------------------------------------------------------------- - // Load the klee shape file and extract some information + // Load the Klee input file and extract some information //--------------------------------------------------------------------------- try { AXOM_ANNOTATE_SCOPE("read Klee shape set"); - params.shapeSet = klee::readShapeSet(params.shapeFile); + params.shapeSet = klee::readShapeSet(params.shapeFile, params.loadLuaInputOptions()); slic::flushStreams(); } @@ -719,7 +753,7 @@ int main(int argc, char** argv) } SLIC_WARNING( - axom::fmt::format("Error during parsing klee input. Found the following errors:\n{}", + axom::fmt::format("Error during parsing Klee input. Found the following errors:\n{}", axom::fmt::join(errs, "\n"))); finalizeLogger(); diff --git a/src/axom/quest/util/make_clipper_strategy.cpp b/src/axom/quest/util/make_clipper_strategy.cpp index 9eec1e0efd..e8dbc9f441 100644 --- a/src/axom/quest/util/make_clipper_strategy.cpp +++ b/src/axom/quest/util/make_clipper_strategy.cpp @@ -10,13 +10,16 @@ #include "axom/quest/util/make_clipper_strategy.hpp" #include "axom/quest/detail/clipping/Plane3DClipper.hpp" #include "axom/quest/detail/clipping/TetClipper.hpp" - #include "axom/quest/detail/clipping/TetMeshClipper.hpp" #include "axom/quest/detail/clipping/HexClipper.hpp" #include "axom/quest/detail/clipping/SphereClipper.hpp" #include "axom/quest/detail/clipping/MonotonicZSORClipper.hpp" #include "axom/quest/detail/clipping/SORClipper.hpp" #include "axom/slic/interface/slic_macros.hpp" + #ifdef AXOM_USE_BUMP + #include "axom/quest/detail/clipping/TetMeshClipper.hpp" + #endif + namespace axom { namespace quest @@ -41,10 +44,6 @@ std::shared_ptr make_clipper_strategy(const axom::klee::Geo { strategy.reset(new TetClipper(kleeGeometry, name)); } - else if(format == "blueprint-tets") - { - strategy.reset(new TetMeshClipper(kleeGeometry, name)); - } else if(format == "hex3D") { strategy.reset(new HexClipper(kleeGeometry, name)); @@ -65,10 +64,24 @@ std::shared_ptr make_clipper_strategy(const axom::klee::Geo { strategy.reset(new MonotonicZSORClipper(kleeGeometry, name)); } + else if(format == "blueprint-tets") + { + #if defined(AXOM_USE_BUMP) + strategy.reset(new TetMeshClipper(kleeGeometry, name)); + #else + SLIC_WARNING( + axom::fmt::format("klee::Geometry format '{}' requires Axom to be configured with bump " + "but this build does not have it, so shape '{}' cannot be clipped.", + format, + name)); + #endif + } else { SLIC_WARNING( - axom::fmt::format("klee::Geometry format {} is not supported by MeshClipper.", format)); + axom::fmt::format("klee::Geometry format '{}' is not supported by MeshClipper (shape '{}').", + format, + name)); } return strategy; diff --git a/src/examples/CMakeLists.txt b/src/examples/CMakeLists.txt index a77b7e06a4..ab6f91a150 100644 --- a/src/examples/CMakeLists.txt +++ b/src/examples/CMakeLists.txt @@ -202,6 +202,58 @@ if(AXOM_ENABLE_TUTORIALS AND AXOM_ENABLE_KLEE AND AXOM_ENABLE_QUEST) blt_add_test(NAME shaping_tutorial_lesson_03_klee_operators_and_validation_lua COMMAND shaping_tutorial_lesson_03_klee_operators_and_validation ${CMAKE_CURRENT_SOURCE_DIR}/shaping_tutorial/lesson_03/ice_cream.lua) + + blt_add_test(NAME shaping_tutorial_lesson_03_klee_operators_and_validation_lua_initialization + COMMAND shaping_tutorial_lesson_03_klee_operators_and_validation + ${CMAKE_CURRENT_SOURCE_DIR}/shaping_tutorial/lesson_03/ice_cream_initialized.lua + --initialization-file + ${CMAKE_CURRENT_SOURCE_DIR}/shaping_tutorial/lesson_03/ice_cream_initialization.lua) + + if(MFEM_FOUND) + set(_lesson_04_dir "${CMAKE_CURRENT_SOURCE_DIR}/shaping_tutorial/lesson_04") + + set(_lesson_04_yaml_test shaping_tutorial_lesson_04_quest_sampling_shaper_yaml) + set(_lesson_04_lua_callbacks_test shaping_tutorial_lesson_04_quest_sampling_shaper_lua_callbacks) + set(_lesson_04_lua_initialization_test shaping_tutorial_lesson_04_quest_sampling_shaper_lua_initialization) + foreach(_testname + ${_lesson_04_yaml_test} + ${_lesson_04_lua_callbacks_test} + ${_lesson_04_lua_initialization_test}) + file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/${_testname}") + endforeach() + + blt_add_test(NAME ${_lesson_04_yaml_test} + COMMAND shaping_tutorial_lesson_04_quest_sampling_shaper + -m ${_lesson_04_dir}/circle_input.lua + -k ${_lesson_04_dir}/circles.yaml + WORKING_DIRECTORY + ${CMAKE_CURRENT_BINARY_DIR}/${_lesson_04_yaml_test}) + + blt_add_test(NAME ${_lesson_04_lua_callbacks_test} + COMMAND shaping_tutorial_lesson_04_quest_sampling_shaper + -m ${_lesson_04_dir}/circle_input.lua + -k ${_lesson_04_dir}/circles.lua + WORKING_DIRECTORY + ${CMAKE_CURRENT_BINARY_DIR}/${_lesson_04_lua_callbacks_test}) + + blt_add_test(NAME ${_lesson_04_lua_initialization_test} + COMMAND shaping_tutorial_lesson_04_quest_sampling_shaper + -m ${_lesson_04_dir}/circle_input.lua + -k ${_lesson_04_dir}/circles_initialized.lua + --lua-init-file ${_lesson_04_dir}/circles_initialization.lua + WORKING_DIRECTORY + ${CMAKE_CURRENT_BINARY_DIR}/${_lesson_04_lua_initialization_test}) + + set(_testname + shaping_tutorial_lesson_04_quest_sampling_shaper_yaml_rejects_lua_initialization) + blt_add_test(NAME ${_testname} + COMMAND shaping_tutorial_lesson_04_quest_sampling_shaper + -m ${_lesson_04_dir}/circle_input.lua + -k ${_lesson_04_dir}/circles.yaml + --lua-init-file ${_lesson_04_dir}/circles_initialization.lua) + set_tests_properties(${_testname} PROPERTIES + PASS_REGULAR_EXPRESSION "only supported for Lua input decks") + endif() endif() endif() diff --git a/src/examples/shaping_tutorial/CMakeLists.txt b/src/examples/shaping_tutorial/CMakeLists.txt index f990e82d89..b959eb2c6f 100644 --- a/src/examples/shaping_tutorial/CMakeLists.txt +++ b/src/examples/shaping_tutorial/CMakeLists.txt @@ -107,12 +107,57 @@ if(ENABLE_TESTS) if(AXOM_USE_LUA) blt_add_test(NAME lesson_03_klee_operators_and_validation_lua COMMAND lesson_03_klee_operators_and_validation ../lesson_03/ice_cream.lua) + + blt_add_test(NAME lesson_03_klee_operators_and_validation_lua_initialization + COMMAND lesson_03_klee_operators_and_validation + ../lesson_03/ice_cream_initialized.lua + --initialization-file + ../lesson_03/ice_cream_initialization.lua) endif() if(AXOM_USE_LUA AND AXOM_USE_MFEM) - blt_add_test(NAME lesson_04_quest_sampling_shaper - COMMAND lesson_04_quest_sampling_shaper -m ../lesson_04/circle_input.lua - -k ../lesson_04/circles.yaml -v) + set(_lesson_04_dir "${CMAKE_CURRENT_SOURCE_DIR}/lesson_04") + set(_lesson_04_yaml_test lesson_04_quest_sampling_shaper) + set(_lesson_04_lua_callbacks_test lesson_04_quest_sampling_shaper_lua_callbacks) + set(_lesson_04_lua_initialization_test lesson_04_quest_sampling_shaper_lua_initialization) + + foreach(_testname + ${_lesson_04_yaml_test} + ${_lesson_04_lua_callbacks_test} + ${_lesson_04_lua_initialization_test}) + file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/${_testname}") + endforeach() + + blt_add_test(NAME ${_lesson_04_yaml_test} + COMMAND lesson_04_quest_sampling_shaper + -m ${_lesson_04_dir}/circle_input.lua + -k ${_lesson_04_dir}/circles.yaml -v + WORKING_DIRECTORY + ${CMAKE_CURRENT_BINARY_DIR}/${_lesson_04_yaml_test}) + + blt_add_test(NAME ${_lesson_04_lua_callbacks_test} + COMMAND lesson_04_quest_sampling_shaper + -m ${_lesson_04_dir}/circle_input.lua + -k ${_lesson_04_dir}/circles.lua -v + WORKING_DIRECTORY + ${CMAKE_CURRENT_BINARY_DIR}/${_lesson_04_lua_callbacks_test}) + + blt_add_test(NAME ${_lesson_04_lua_initialization_test} + COMMAND lesson_04_quest_sampling_shaper + -m ${_lesson_04_dir}/circle_input.lua + -k ${_lesson_04_dir}/circles_initialized.lua + --lua-init-file ${_lesson_04_dir}/circles_initialization.lua -v + WORKING_DIRECTORY + ${CMAKE_CURRENT_BINARY_DIR}/${_lesson_04_lua_initialization_test}) + + set(_testname lesson_04_quest_sampling_shaper_yaml_rejects_lua_initialization) + blt_add_test(NAME ${_testname} + COMMAND lesson_04_quest_sampling_shaper + -m ${_lesson_04_dir}/circle_input.lua + -k ${_lesson_04_dir}/circles.yaml + --lua-init-file ${_lesson_04_dir}/circles_initialization.lua) + set_tests_properties(${_testname} PROPERTIES + PASS_REGULAR_EXPRESSION "only supported for Lua input decks") endif() endif() @@ -123,4 +168,3 @@ endif() if(EXAMPLE_VERBOSE_OUTPUT) blt_print_target_properties(TARGET axom CHILDREN TRUE) endif() - diff --git a/src/examples/shaping_tutorial/lesson_03/README.md b/src/examples/shaping_tutorial/lesson_03/README.md index 89011fc8b1..a514aaf88c 100644 --- a/src/examples/shaping_tutorial/lesson_03/README.md +++ b/src/examples/shaping_tutorial/lesson_03/README.md @@ -195,9 +195,9 @@ endsolid Mesh -#### Basic shape file +#### Basic Klee input -A minimal shape file includes a ``dimensions`` field and a ``shapes`` list. +A minimal Klee input includes a ``dimensions`` field and a ``shapes`` list. The following example has a single shape representing the boundary a tetrahedron. The name of the shape -- in this case ``my_tetrahedron`` -- is used internally, and it will get shaped into the ``steel`` material: @@ -281,37 +281,17 @@ shapes:
Figure: Example Klee inputs showing `scale`, `translate`, `rotate` and unit conversion operators.
-#### Lua decks +#### Lua decks for generated geometry setup Klee can also read Lua decks when Axom is configured with Lua support. -Lua decks use the same shape schema as YAML, but Lua is evaluated before -Inlet reads the resulting global tables. The lesson's `ice_cream.lua` deck -mirrors the YAML setup while using local Lua variables for shared values: +Lua decks use the same shape schema as YAML, but Lua is evaluated first, +which lets you keep helper constants and functions local to the deck: -```lua -local dim = 2 -local scoop_radius = 1.1 -local scoop_offset = {0.0, 2.0} +- ordinary Klee fields can be generated from local variables, +- selected affine operator fields can be zero-argument callbacks evaluated once during parsing. -dimensions = dim - -shapes = { - { - name = "vanilla_scoop", - material = "ice_cream", - geometry = { - format = "mfem", - path = "ice_cream_scoop.mesh", - units = "cm", - operators = { - { scale = {scoop_radius} }, - { rotate = 5 }, - { translate = scoop_offset } - } - } - } -} -``` +The lesson's `ice_cream.lua` deck mirrors the YAML ice-cream setup while using these Lua features in one small workflow. +Helper functions generate dimensional points, and callbacks compute scale and translation fields. ### Replacement Rules Replacement rules give users some extra control in how shapes get overlaid. By default, a new shape of a given material will replace all other shapes. @@ -413,30 +393,46 @@ shapes: ## Let's see some code! -The code example for this lesson loads a Klee file, performs some validation and then prints out details about the geometric setup +The code example for this lesson loads a Klee input file, performs some validation and then prints out details about the geometric setup. ### Load and validate the Klee input ```cpp -axom::klee::ShapeSet shapeset +axom::klee::ShapeSet shapeSet; try { shapeSet = axom::klee::readShapeSet(inputFilename); } -catch(axom::klee::KleeError& error) +catch(const axom::klee::KleeError& error) { std::vector errs; - for(auto verificationError : error.getErrors()) + for(const auto& verificationError : error.getErrors()) { errs.push_back(axom::fmt::format(" - '{}': {}", - static_cast(verificationError.path), - verificationError.message)); + static_cast(verificationError.path), + verificationError.message)); } SLIC_WARNING( - axom::fmt::format("Error during parsing klee input. Found the following errors:\n{}", + axom::fmt::format("Error during parsing Klee input. Found the following errors:\n{}", axom::fmt::join(errs, "\n"))); + return 1; } + +printShapeSetInfo(shapeSet); +``` + +The validator example also accepts an optional `--initialization-file` argument +for Lua decks. The initialization file is a Lua chunk that returns a table of +exported variables and helper functions, which are installed as initial mutable +globals before the deck is evaluated. This lets an application provide runtime +Lua customization without rebuilding the executable, while still allowing the +deck to reassign those globals if it chooses: + +```bash +./bin/lesson_03_klee_operators_and_validation \ + ../lesson_03/ice_cream_initialized.lua \ + --initialization-file ../lesson_03/ice_cream_initialization.lua ``` Next, we loop through the shapes and print out information about each shape. We're using an `fmt::memory_buffer` (similar to a `std::stringstream`) to write everything in a single log statement: diff --git a/src/examples/shaping_tutorial/lesson_03/ice_cream.lua b/src/examples/shaping_tutorial/lesson_03/ice_cream.lua index e1f6d5bece..77c76b4c5b 100644 --- a/src/examples/shaping_tutorial/lesson_03/ice_cream.lua +++ b/src/examples/shaping_tutorial/lesson_03/ice_cream.lua @@ -5,10 +5,22 @@ local scoop_lift = 2.0 local sprinkle_lift = 3.0 local cone_lift = -2.0 -local scoop_scale = {scoop_radius} -local scoop_offset = {0.0, scoop_lift} -local sprinkle_offset = {0.0, sprinkle_lift} -local cone_offset = {0.0, cone_lift} +local function point(x, y, z) + if dim == 2 then + return {x, y} + end + return {x, y, z or 0.0} +end + +local function offset(y) + return function() + return point(0.0, y) + end +end + +local function scoop_scale() + return {scoop_radius} +end dimensions = dim @@ -30,7 +42,7 @@ shapes = { operators = { { scale = scoop_scale }, { rotate = 5 }, - { translate = scoop_offset } + { translate = offset(scoop_lift) } } } }, @@ -43,7 +55,7 @@ shapes = { units = "cm", operators = { { rotate = 15 }, - { translate = sprinkle_offset } + { translate = offset(sprinkle_lift) } } }, replaces = {"ice_cream"} @@ -57,7 +69,7 @@ shapes = { units = "cm", operators = { { rotate = -5 }, - { translate = cone_offset } + { translate = offset(cone_lift) } } }, does_not_replace = {"ice_cream", "sprinkles"} diff --git a/src/examples/shaping_tutorial/lesson_03/ice_cream_initialization.lua b/src/examples/shaping_tutorial/lesson_03/ice_cream_initialization.lua new file mode 100644 index 0000000000..60332a6367 --- /dev/null +++ b/src/examples/shaping_tutorial/lesson_03/ice_cream_initialization.lua @@ -0,0 +1,32 @@ +local dim = 2 + +local scoop_radius = 1.1 +local scoop_lift = 2.0 +local sprinkle_lift = 3.0 +local cone_lift = -2.0 + +local function point(x, y, z) + if dim == 2 then + return {x, y} + end + return {x, y, z or 0.0} +end + +local function offset(y) + return function() + return point(0.0, y) + end +end + +local function scoop_scale() + return {scoop_radius} +end + +return { + dimensions = dim, + scoop_lift = scoop_lift, + sprinkle_lift = sprinkle_lift, + cone_lift = cone_lift, + offset = offset, + scoop_scale = scoop_scale +} diff --git a/src/examples/shaping_tutorial/lesson_03/ice_cream_initialized.lua b/src/examples/shaping_tutorial/lesson_03/ice_cream_initialized.lua new file mode 100644 index 0000000000..75c90ceaa7 --- /dev/null +++ b/src/examples/shaping_tutorial/lesson_03/ice_cream_initialized.lua @@ -0,0 +1,51 @@ +shapes = { + { + name = "background", + material = "background", + geometry = { + format = "none" + } + }, + { + name = "vanilla_scoop", + material = "ice_cream", + geometry = { + format = "mfem", + path = "ice_cream_scoop.mesh", + units = "cm", + operators = { + { scale = scoop_scale }, + { rotate = 5 }, + { translate = offset(scoop_lift) } + } + } + }, + { + name = "colorful_sprinkles", + material = "sprinkles", + geometry = { + format = "mfem", + path = "ice_cream_sprinkles.mesh", + units = "cm", + operators = { + { rotate = 15 }, + { translate = offset(sprinkle_lift) } + } + }, + replaces = {"ice_cream"} + }, + { + name = "cone", + material = "batter", + geometry = { + format = "mfem", + path = "ice_cream_cone.mesh", + units = "cm", + operators = { + { rotate = -5 }, + { translate = offset(cone_lift) } + } + }, + does_not_replace = {"ice_cream", "sprinkles"} + } +} diff --git a/src/examples/shaping_tutorial/lesson_03/klee_operators_and_validation.cpp b/src/examples/shaping_tutorial/lesson_03/klee_operators_and_validation.cpp index de8c08b170..539d1c4f7e 100644 --- a/src/examples/shaping_tutorial/lesson_03/klee_operators_and_validation.cpp +++ b/src/examples/shaping_tutorial/lesson_03/klee_operators_and_validation.cpp @@ -142,22 +142,41 @@ int main(int argc, char** argv) // CLI axom::CLI::App app {"Klee Input Validator and Summary"}; std::string inputFilename; + std::string initializationFilename; app.add_option("input", inputFilename) ->description("Klee input file") ->required() ->check(axom::CLI::ExistingFile); + app.add_option("--initialization-file", initializationFilename) + ->description("Optional Lua chunk that returns a table of initial globals") + ->check(axom::CLI::ExistingFile); CLI11_PARSE(app, argc, argv); - // Load the klee shape file and extract some information + auto loadShapeSet = [&]() { + if(initializationFilename.empty()) + { + return axom::klee::readShapeSet(inputFilename); + } + + std::ifstream initializationStream {initializationFilename}; + std::string initializationSource {std::istreambuf_iterator(initializationStream), {}}; + axom::klee::LuaInputOptions options; + options.initialization = + axom::klee::LuaInitializationChunk {initializationSource, initializationFilename}; + return axom::klee::readShapeSet(inputFilename, options); + }; + + // Load the Klee input file and extract some information + axom::klee::ShapeSet shapeSet; try { - auto shapeSet = axom::klee::readShapeSet(inputFilename); + shapeSet = loadShapeSet(); } - catch(axom::klee::KleeError& error) + catch(const axom::klee::KleeError& error) { std::vector errs; - for(auto verificationError : error.getErrors()) + for(const auto& verificationError : error.getErrors()) { errs.push_back(axom::fmt::format(" - '{}': {}", static_cast(verificationError.path), @@ -165,13 +184,11 @@ int main(int argc, char** argv) } SLIC_WARNING( - axom::fmt::format("Error during parsing klee input. Found the following errors:\n{}", + axom::fmt::format("Error during parsing Klee input. Found the following errors:\n{}", axom::fmt::join(errs, "\n"))); - exit(1); + return 1; } - auto shapeSet = axom::klee::readShapeSet(inputFilename); printShapeSetInfo(shapeSet); - return 0; } diff --git a/src/examples/shaping_tutorial/lesson_04/README.md b/src/examples/shaping_tutorial/lesson_04/README.md index a534e51bc4..0548a2ac71 100644 --- a/src/examples/shaping_tutorial/lesson_04/README.md +++ b/src/examples/shaping_tutorial/lesson_04/README.md @@ -464,6 +464,31 @@ The replacement rules are implicit -- each new shape replaces all existing mater > -m ../src/examples/shaping_tutorial/lesson_04/circle_input.lua > ``` +The Klee input can also be written in Lua. In [`circles.lua`](circles.lua), the scale factors +are zero-argument callbacks. Klee evaluates them while reading the deck +and passes ordinary concrete operators to Quest: + +```bash +./bin/shaping_tutorial_lesson_04_quest_sampling_shaper \ + -k ../src/examples/shaping_tutorial/lesson_04/circles.lua \ + -m ../src/examples/shaping_tutorial/lesson_04/circle_input.lua +``` + +An application can provide runtime values through an initialization file +instead of embedding them in the deck. The `--lua-init-file` argument names a +Lua chunk that returns a table of initial globals: + +```bash +./bin/shaping_tutorial_lesson_04_quest_sampling_shaper \ + -k ../src/examples/shaping_tutorial/lesson_04/circles_initialized.lua \ + -m ../src/examples/shaping_tutorial/lesson_04/circle_input.lua \ + --lua-init-file ../src/examples/shaping_tutorial/lesson_04/circles_initialization.lua +``` + +This option is valid only with a Lua Klee deck, and using it with YAML produces a +Klee validation error. See the Klee user guide for how initialization chunks +and callbacks behave. + ### Ice cream example revisited diff --git a/src/examples/shaping_tutorial/lesson_04/circles.lua b/src/examples/shaping_tutorial/lesson_04/circles.lua new file mode 100644 index 0000000000..5f5ed43901 --- /dev/null +++ b/src/examples/shaping_tutorial/lesson_04/circles.lua @@ -0,0 +1,48 @@ +local outer_radius = 5.0 +local inner_radius_mm = 25.0 + +dimensions = 2 + +shapes = { + { + name = "background", + material = "void", + geometry = { + format = "none" + } + }, + { + name = "outer_shell", + material = "steel", + geometry = { + format = "mfem", + path = "unit_circle.mesh", + units = "cm", + operators = { + { + scale = function() + return {outer_radius} + end + } + } + } + }, + { + name = "inner_ball", + material = "void", + geometry = { + format = "mfem", + path = "unit_circle.mesh", + start_units = "mm", + end_units = "cm", + operators = { + { + scale = function() + return {inner_radius_mm} + end + }, + { convert_units_to = "cm" } + } + } + } +} diff --git a/src/examples/shaping_tutorial/lesson_04/circles_initialization.lua b/src/examples/shaping_tutorial/lesson_04/circles_initialization.lua new file mode 100644 index 0000000000..8ce3542db7 --- /dev/null +++ b/src/examples/shaping_tutorial/lesson_04/circles_initialization.lua @@ -0,0 +1,4 @@ +return { + outer_radius = 4.0, + inner_radius_mm = 20.0 +} diff --git a/src/examples/shaping_tutorial/lesson_04/circles_initialized.lua b/src/examples/shaping_tutorial/lesson_04/circles_initialized.lua new file mode 100644 index 0000000000..7a0675bd49 --- /dev/null +++ b/src/examples/shaping_tutorial/lesson_04/circles_initialized.lua @@ -0,0 +1,45 @@ +dimensions = 2 + +shapes = { + { + name = "background", + material = "void", + geometry = { + format = "none" + } + }, + { + name = "outer_shell", + material = "steel", + geometry = { + format = "mfem", + path = "unit_circle.mesh", + units = "cm", + operators = { + { + scale = function() + return {outer_radius} + end + } + } + } + }, + { + name = "inner_ball", + material = "void", + geometry = { + format = "mfem", + path = "unit_circle.mesh", + start_units = "mm", + end_units = "cm", + operators = { + { + scale = function() + return {inner_radius_mm} + end + }, + { convert_units_to = "cm" } + } + } + } +} diff --git a/src/examples/shaping_tutorial/lesson_04/quest_sampling_shaper.cpp b/src/examples/shaping_tutorial/lesson_04/quest_sampling_shaper.cpp index 314339b4d3..1c58139d6a 100644 --- a/src/examples/shaping_tutorial/lesson_04/quest_sampling_shaper.cpp +++ b/src/examples/shaping_tutorial/lesson_04/quest_sampling_shaper.cpp @@ -12,14 +12,14 @@ * This example demonstrates how to use the Quest SamplingShaper to shape * a Klee input onto a computational mesh. The program: * 1. Creates a structured mesh from Inlet mesh metadata - * 2. Loads Klee shapes from a YAML file + * 2. Loads Klee shapes from a YAML or Lua file * 3. Generates volume fraction fields for each material based on the input shapes * 4. Outputs the generated mesh to disk * * This example supports both serial and MPI execution. * * Example run: - * > [srun -n8] ./quest_sampling_shaper -m mesh_metadata.lua -k shapes.yaml [-v] + * > [srun -n8] ./quest_sampling_shaper -m mesh_metadata.lua -k shapes.lua [-v] * */ //----------------------------------------------------------------------------- @@ -47,7 +47,9 @@ #error Shaping functionality requires Axom to be configured with Conduit and MFEM #endif +#include #include +#include #include #include #include @@ -306,7 +308,8 @@ int main(int argc, char** argv) // -------------------------------------------------------------------------- axom::CLI::App app {"Shaping pipeline using separate Inlet mesh metadata and Klee shapes"}; std::string inputFilename; // Mesh metadata Inlet Lua - std::string kleeFilename; // Klee shape set YAML + std::string kleeFilename; // Klee input file (YAML or Lua) + std::string luaInitializationFilename; bool verbose = false; app.add_option("-m,--mesh_file", inputFilename) @@ -314,9 +317,12 @@ int main(int argc, char** argv) ->required() ->check(axom::CLI::ExistingFile); app.add_option("-k,--klee_file", kleeFilename) - ->description("Klee shape set YAML file") + ->description("Klee input file (YAML or Lua)") ->required() ->check(axom::CLI::ExistingFile); + app.add_option("--lua-init-file", luaInitializationFilename) + ->description("Lua chunk that returns initial globals for a Lua Klee input file") + ->check(axom::CLI::ExistingFile); app.add_flag("-v,--verbose", verbose)->description("Enable verbose (debug) logging"); try @@ -375,7 +381,21 @@ int main(int argc, char** argv) klee::ShapeSet shapeSet; try { - shapeSet = klee::readShapeSet(kleeFilename); + klee::LuaInputOptions options; + if(!luaInitializationFilename.empty()) + { + std::ifstream stream {luaInitializationFilename}; + if(!stream) + { + throw klee::KleeError( + {axom::Path {luaInitializationFilename}, "Could not read Lua initialization file"}); + } + + options.initialization = klee::LuaInitializationChunk { + std::string {std::istreambuf_iterator {stream}, std::istreambuf_iterator {}}, + luaInitializationFilename}; + } + shapeSet = klee::readShapeSet(kleeFilename, options); } catch(klee::KleeError& error) { @@ -386,7 +406,7 @@ int main(int argc, char** argv) static_cast(verificationError.path), verificationError.message)); } - SLIC_WARNING(axom::fmt::format("Error parsing klee input:\n{}", axom::fmt::join(errs, "\n"))); + SLIC_WARNING(axom::fmt::format("Error parsing Klee input:\n{}", axom::fmt::join(errs, "\n"))); return 1; }