diff --git a/source/MaterialXCore/Element.cpp b/source/MaterialXCore/Element.cpp index 94836245a7..149b7bc757 100644 --- a/source/MaterialXCore/Element.cpp +++ b/source/MaterialXCore/Element.cpp @@ -39,6 +39,12 @@ const string ValueElement::UI_ADVANCED_ATTRIBUTE = "uiadvanced"; const string ValueElement::UNIT_ATTRIBUTE = "unit"; const string ValueElement::UNITTYPE_ATTRIBUTE = "unittype"; const string ValueElement::UNIFORM_ATTRIBUTE = "uniform"; +const string ElementEquivalenceResult::ATTRIBUTE = "attribute"; +const string ElementEquivalenceResult::ATTRIBUTE_NAMES = "attribute names"; +const string ElementEquivalenceResult::CHILD_COUNT = "child count"; +const string ElementEquivalenceResult::CHILD_NAME = "child name"; +const string ElementEquivalenceResult::NAME = "name"; +const string ElementEquivalenceResult::CATEGORY = "category"; Element::CreatorMap Element::_creatorMap; @@ -334,6 +340,108 @@ bool Element::hasInheritanceCycle() const return false; } +bool Element::isEquivalent(ConstElementPtr rhs, const ElementEquivalenceOptions& options, + ElementEquivalenceResultVec* results) const +{ + if (getName() != rhs->getName()) + { + if (results) + results->push_back(ElementEquivalenceResult(getNamePath(), rhs->getNamePath(), ElementEquivalenceResult::NAME)); + return false; + } + if (getCategory() != rhs->getCategory()) + { + if (results) + results->push_back(ElementEquivalenceResult(getNamePath(), rhs->getNamePath(), ElementEquivalenceResult::CATEGORY)); + return false; + } + + // Compare attribute names. + StringVec attributeNames = getAttributeNames(); + StringVec rhsAttributeNames = rhs->getAttributeNames(); + + // Filter out any attributes specified in the options. + const StringSet& skipAttributes = options.skipAttributes; + if (!skipAttributes.empty()) + { + attributeNames.erase(std::remove_if(attributeNames.begin(), attributeNames.end(), + [&skipAttributes](const string& attr) { return skipAttributes.find(attr) != skipAttributes.end(); }), + attributeNames.end()); + rhsAttributeNames.erase(std::remove_if(rhsAttributeNames.begin(), rhsAttributeNames.end(), + [&skipAttributes](const string& attr) { return skipAttributes.find(attr) != skipAttributes.end(); }), + rhsAttributeNames.end()); + } + + // Ignore attribute ordering by sorting names + std::sort(attributeNames.begin(), attributeNames.end()); + std::sort(rhsAttributeNames.begin(), rhsAttributeNames.end()); + + if (attributeNames != rhsAttributeNames) + { + if (results) + results->push_back(ElementEquivalenceResult(getNamePath(), rhs->getNamePath(), ElementEquivalenceResult::ATTRIBUTE_NAMES)); + return false; + } + + for (const string& attr : rhsAttributeNames) + { + if (!isAttributeEquivalent(rhs, attr, options, results)) + { + return false; + } + } + + // Compare children. + const vector& children = getChildren(); + const vector& rhsChildren = rhs->getChildren(); + if (children.size() != rhsChildren.size()) + { + if (results) + results->push_back(ElementEquivalenceResult(getNamePath(), rhs->getNamePath(), ElementEquivalenceResult::CHILD_COUNT)); + return false; + } + for (size_t i = 0; i < children.size(); i++) + { + ElementPtr rhsElement = rhsChildren[i]; + // Handle unordered children if parent is a compound graph (NodeGraph, Document). + // (Functional graphs have a "nodedef" reference and define node interfaces + // so require strict interface ordering.) + ConstGraphElementPtr graph = this->getSelf()->asA(); + if (graph) + { + ConstNodeGraphPtr nodeGraph = graph->asA(); + ConstDocumentPtr document = graph->asA(); + if (document || (nodeGraph && !nodeGraph->getNodeDef())) + { + const string& childName = children[i]->getName(); + rhsElement = rhs->getChild(childName); + if (!rhsElement) + { + if (results) + results->push_back(ElementEquivalenceResult(children[i]->getNamePath(), "", + ElementEquivalenceResult::CHILD_NAME, childName)); + return false; + } + } + } + if (!children[i]->isEquivalent(rhsElement, options, results)) + return false; + } + return true; +} + +bool Element::isAttributeEquivalent(ConstElementPtr rhs, const string& attributeName, + const ElementEquivalenceOptions& /*options*/, ElementEquivalenceResultVec* results) const +{ + if (getAttribute(attributeName) != rhs->getAttribute(attributeName)) + { + if (results) + results->push_back(ElementEquivalenceResult(getNamePath(), rhs->getNamePath(), ElementEquivalenceResult::ATTRIBUTE, attributeName)); + return false; + } + return true; +} + TreeIterator Element::traverseTree() const { return TreeIterator(getSelfNonConst()); @@ -534,6 +642,72 @@ const string& ValueElement::getActiveUnit() const return EMPTY_STRING; } +bool ValueElement::isAttributeEquivalent(ConstElementPtr rhs, const string& attributeName, + const ElementEquivalenceOptions& options, ElementEquivalenceResultVec* results) const +{ + // Perform value comparisons + bool performedValueComparison = false; + if (!options.skipValueComparisons) + { + const StringSet uiAttributes = + { + ValueElement::UI_MIN_ATTRIBUTE, ValueElement::UI_MAX_ATTRIBUTE, + ValueElement::UI_SOFT_MIN_ATTRIBUTE, ValueElement::UI_SOFT_MAX_ATTRIBUTE, + ValueElement::UI_STEP_ATTRIBUTE + }; + + // Get precision and format options + ScopedFloatFormatting fmt(options.format, options.precision); + + ConstValueElementPtr rhsValueElement = rhs->asA(); + + // Check value equality + if (attributeName == ValueElement::VALUE_ATTRIBUTE) + { + ValuePtr thisValue = getValue(); + ValuePtr rhsValue = rhsValueElement->getValue(); + if (thisValue && rhsValue) + { + if (thisValue->getValueString() != rhsValue->getValueString()) + { + if (results) + results->push_back(ElementEquivalenceResult(getNamePath(), rhs->getNamePath(), ElementEquivalenceResult::ATTRIBUTE, attributeName)); + return false; + } + } + performedValueComparison = true; + } + + // Check ui attribute value equality + else if (uiAttributes.find(attributeName) != uiAttributes.end()) + { + const string& uiAttribute = getAttribute(attributeName); + const string& rhsUiAttribute = getAttribute(attributeName); + ValuePtr uiValue = !rhsUiAttribute.empty() ? Value::createValueFromStrings(uiAttribute, getType()) : nullptr; + ValuePtr rhsUiValue = !rhsUiAttribute.empty() ? Value::createValueFromStrings(rhsUiAttribute, getType()) : nullptr; + if (uiValue && rhsUiValue) + { + if (uiValue->getValueString() != rhsUiValue->getValueString()) + { + if (results) + results->push_back(ElementEquivalenceResult(getNamePath(), rhs->getNamePath(), ElementEquivalenceResult::ATTRIBUTE, attributeName)); + return false; + } + } + + performedValueComparison = true; + } + } + + // If did not peform a value comparison, perform the default comparison + if (!performedValueComparison) + { + return Element::isAttributeEquivalent(rhs, attributeName, options, results); + } + + return true; +} + bool ValueElement::validate(string* message) const { bool res = true; diff --git a/source/MaterialXCore/Element.h b/source/MaterialXCore/Element.h index d1abcdfdca..2b92dd8094 100644 --- a/source/MaterialXCore/Element.h +++ b/source/MaterialXCore/Element.h @@ -71,6 +71,10 @@ using ElementMap = std::unordered_map; /// A standard function taking an ElementPtr and returning a boolean. using ElementPredicate = std::function; +class ElementEquivalenceOptions; +class ElementEquivalenceResult; +using ElementEquivalenceResultVec = vector; + /// @class Element /// The base class for MaterialX elements. /// @@ -612,6 +616,31 @@ class MX_CORE_API Element : public std::enable_shared_from_this return nullptr; } + /// @} + /// @name Functional Equivalence + /// @{ + + /// Return true if the given element tree, including all descendents, + /// is considered to be equivalent to this one based on the equivalence + /// criteria provided. + /// @param rhs Element to compare against + /// @param options Equivalence criteria + /// @param results Results of comparison if argument is specified. + /// @return True if the elements are equivalent. False otherwise. + bool isEquivalent(ConstElementPtr rhs, const ElementEquivalenceOptions& options, + ElementEquivalenceResultVec* results = nullptr) const; + + /// Return true if the attribute on a given element is equivalent + /// based on the equivalence criteria provided. + /// @param rhs Element to compare against + /// @param attributeName Name of attribute to compare + /// @param options Equivalence criteria + /// @param results Results of comparison if argument is specified. + /// @return True if the attribute on the elements are equivalent. False otherwise. + virtual bool isAttributeEquivalent(ConstElementPtr rhs, const string& attributeName, + const ElementEquivalenceOptions& options, + ElementEquivalenceResultVec* results = nullptr) const; + /// @} /// @name Traversal /// @{ @@ -1114,6 +1143,21 @@ class MX_CORE_API ValueElement : public TypedElement return getTypedAttribute(UNIFORM_ATTRIBUTE); } + /// @} + /// @name Functional Equivalence + /// @{ + + /// Return true if the attribute on a given element is equivalent + /// based on the equivalence criteria provided. + /// @param rhs Element to compare against + /// @param attributeName Name of attribute to compare + /// @param options Equivalence criteria + /// @param results Results of comparison if argument is specified. + /// @return True if the attribute on the elements are equivalent. False otherwise. + bool isAttributeEquivalent(ConstElementPtr rhs, const string& attributeName, + const ElementEquivalenceOptions& options, + ElementEquivalenceResultVec* results = nullptr) const override; + /// @} /// @name Validation /// @{ @@ -1336,6 +1380,72 @@ class MX_CORE_API StringResolver StringMap _geomNameMap; }; +/// @class ElementEquivalenceResult +/// A comparison result for the functional equivalence of two elements. +class MX_CORE_API ElementEquivalenceResult +{ + public: + ElementEquivalenceResult(const string& p1, const string& p2, const string& type, + const string& attrName = EMPTY_STRING) + { + path1 = p1; + path2 = p2; + differenceType = type; + attributeName = attrName; + } + ElementEquivalenceResult() = delete; + ~ElementEquivalenceResult() = default; + + string path1; + string path2; + string differenceType; + string attributeName; + + static const string ATTRIBUTE; + static const string ATTRIBUTE_NAMES; + static const string CHILD_COUNT; + static const string CHILD_NAME; + static const string NAME; + static const string CATEGORY; +}; + +/// @class ElementEquivalenceOptions +/// A set of options for comparing the functional equivalence of elements. +class MX_CORE_API ElementEquivalenceOptions +{ + public: + ElementEquivalenceOptions() + { + format = Value::getFloatFormat(); + precision = Value::getFloatPrecision(); + skipAttributes = {}; + skipValueComparisons = false; + }; + ~ElementEquivalenceOptions() { } + + /// Floating point format option for floating point value comparisons + Value::FloatFormat format; + + /// Floating point precision option for floating point value comparisons + int precision; + + /// Attribute filtering options. By default all attributes are considered. + /// Name, category attributes cannot be skipped. + /// + /// For example UI attribute comparision be skipped by setting: + /// skipAttributes = { + /// ValueElement::UI_MIN_ATTRIBUTE, ValueElement::UI_MAX_ATTRIBUTE, + /// ValueElement::UI_SOFT_MIN_ATTRIBUTE, ValueElement::UI_SOFT_MAX_ATTRIBUTE, + /// ValueElement::UI_STEP_ATTRIBUTE, Element::XPOS_ATTRIBUTE, + /// Element::YPOS_ATTRIBUTE }; + StringSet skipAttributes; + + /// Do not perform any value comparisions. Instead perform exact string comparisons for attributes + /// Default is false. The operator==() method can be used instead as it always performs + /// a strict comparison. Default is false. + bool skipValueComparisons; +}; + /// @class ExceptionOrphanedElement /// An exception that is thrown when an ElementPtr is used after its owning /// Document has gone out of scope. diff --git a/source/MaterialXTest/MaterialXCore/Document.cpp b/source/MaterialXTest/MaterialXCore/Document.cpp index fb35e58ccb..1b02966fb8 100644 --- a/source/MaterialXTest/MaterialXCore/Document.cpp +++ b/source/MaterialXTest/MaterialXCore/Document.cpp @@ -10,6 +10,8 @@ #include #include +#include + namespace mx = MaterialX; TEST_CASE("Document", "[document]") @@ -116,3 +118,213 @@ TEST_CASE("Document", "[document]") // Validate the combined document. REQUIRE(doc->validate()); } + +void printDifferences(const mx::ElementEquivalenceResultVec& results, const std::string& label) +{ + for (const mx::ElementEquivalenceResult& result : results) + { + std::cout << label << ": " << "Element: " << result.path1 << + ", Element: " << result.path2 << ", Difference Type: " << result.differenceType + << ", Value: " << result.attributeName << std::endl; + } +} + +TEST_CASE("Document equivalence", "[document]") +{ + mx::DocumentPtr doc = mx::createDocument(); + std::unordered_multimap inputMap; + + inputMap.insert({ "color3", " 1.0, +2.0, 3.0 " }); + inputMap.insert({ "color4", "1.0, 2.00, 0.3000, -4" }); + inputMap.insert({ "integer", " 12 " }); + inputMap.insert({ "matrix33", + "01.0, 2.0, 0000.2310, " + " 01.0, 2.0, 0000.2310, " + "01.0, 2.0, 0000.2310 " }); + inputMap.insert({ "matrix44", + "01.0, 2.0, 0000.2310, 0.100, " + "01.0, 2.0, 0000.2310, 0.100, " + "01.0, 2.0, 0000.2310, 0.100, " + "01.0, 2.0, 0000.2310, 0.100" }); + inputMap.insert({ "vector2", "1.0, 0.012345608" }); // For precision check + inputMap.insert({ "vector3", " 1.0, +2.0, 3.0 " }); + inputMap.insert({ "vector4", "1.0, 2.00, 0.3000, -4" }); + inputMap.insert({ "string", "mystring" }); + inputMap.insert({ "boolean", "false" }); + inputMap.insert({ "filename", "filename1" }); + inputMap.insert({ "float", " 1.2e-10 " }); + inputMap.insert({ "float", " 00.1000 " }); + + unsigned int index = 0; + mx::ElementPtr child = doc->addNodeGraph("mygraph"); + mx::NodeGraphPtr graph = child->asA(); + for (auto it = inputMap.begin(); it != inputMap.end(); ++it) + { + const std::string inputType = (*it).first; + mx::InputPtr input = graph->addInput("input_" + std::to_string(index), inputType); + if (inputType == "float") + { + input->setAttribute(mx::ValueElement::UI_MIN_ATTRIBUTE, " 0.0100 "); + input->setAttribute(mx::ValueElement::UI_MAX_ATTRIBUTE, " 01.0100 "); + index++; + } + else + { + input->setName("input_" + inputType); // Set by name for difference in order test + } + input->setValueString((*it).second); + } + + mx::DocumentPtr doc2 = mx::createDocument(); + std::unordered_multimap inputMap2; + inputMap2.insert({ "color4", "1, 2, 0.3, -4" }); + inputMap2.insert({ "integer", "12" }); + inputMap2.insert({ "matrix33", "1, 2, 0.231, 1, 2, 0.231, 1, 2, 0.231, 1, 2, 0.231" }); + inputMap2.insert({ "matrix44", "1, 2, 0.231, 0.1, 1, 2, 0.231, 0.1, 1, 2, 0.231, 0.1, 1, 2, 0.231, 0.1" }); + inputMap2.insert({ "vector2", "1, 0.012345611" }); // For precision check + inputMap2.insert({ "string", "mystring" }); + inputMap2.insert({ "boolean", "false" }); + inputMap2.insert({ "color3", "1, 2, 3" }); + inputMap2.insert({ "vector3", "1, 2, 3" }); + inputMap2.insert({ "vector4", "1, 2, 0.3, -4" }); + inputMap2.insert({ "filename", "filename1" }); + inputMap2.insert({ "float", "1.2e-10" }); + inputMap2.insert({ "float", "0.1" }); + + index = 0; + child = doc2->addNodeGraph("mygraph"); + graph = child->asA(); + std::vector floatInputs; + for (auto it = inputMap2.begin(); it != inputMap2.end(); ++it) + { + const std::string inputType = (*it).first; + mx::InputPtr input = graph->addInput("input_" + std::to_string(index), inputType); + // Note: order of value and ui attributes is different for ordering comparison + input->setValueString((*it).second); + if (inputType == "float") + { + input->setAttribute(mx::ValueElement::UI_MIN_ATTRIBUTE, " 0.01"); + input->setAttribute(mx::ValueElement::UI_MAX_ATTRIBUTE, " 1.01"); + floatInputs.push_back(input); + index++; + } + else + { + input->setName("input_" + inputType); + } + } + + mx::ElementEquivalenceOptions options; + mx::ElementEquivalenceResultVec results; + + // Check skipping all value compares + options.skipValueComparisons = true; + bool equivalent = doc->isEquivalent(doc2, options, &results); + if (equivalent) + { + std::cout << "Unexpected skip value equivalence:" << std::endl; + std::cout << "Document 1: " << mx::prettyPrint(doc) << std::endl; + std::cout << "Document 2: " << mx::prettyPrint(doc2) << std::endl; + } + else + { + printDifferences(results, "Expected value differences"); + } + REQUIRE(!equivalent); + + // Check attibute values + options.skipValueComparisons = false; + results.clear(); + equivalent = doc->isEquivalent(doc2, options, &results); + if (!equivalent) + { + printDifferences(results, "Unexpected value difference"); + std::cout << "Document 1: " << mx::prettyPrint(doc) << std::endl; + std::cout << "Document 2: " << mx::prettyPrint(doc2) << std::endl; + } + REQUIRE(equivalent); + + unsigned int currentPrecision = mx::Value::getFloatPrecision(); + // This will compare 0.012345608 versus: 1, 0.012345611 for input10 + options.precision = 8; + equivalent = doc->isEquivalent(doc2, options); + if (equivalent) + { + std::cout << "Unexpected precision equivalence:" << std::endl; + std::cout << "Document 1: " << mx::prettyPrint(doc) << std::endl; + std::cout << "Document 2: " << mx::prettyPrint(doc2) << std::endl; + } + else + { + printDifferences(results, "Expected precision difference"); + } + REQUIRE(!equivalent); + options.precision = currentPrecision; + + // Check attribute filtering of inputs + results.clear(); + options.skipAttributes = { mx::ValueElement::UI_MIN_ATTRIBUTE, mx::ValueElement::UI_MAX_ATTRIBUTE }; + for (mx::InputPtr floatInput : floatInputs) + { + floatInput->setAttribute(mx::ValueElement::UI_MIN_ATTRIBUTE, "0.9"); + floatInput->setAttribute(mx::ValueElement::UI_MAX_ATTRIBUTE, "100.0"); + } + equivalent = doc->isEquivalent(doc2, options, &results); + if (!equivalent) + { + printDifferences(results, "Unexpected filtering differences"); + std::cout << "Document 1: " << mx::prettyPrint(doc) << std::endl; + std::cout << "Document 2: " << mx::prettyPrint(doc2) << std::endl; + } + REQUIRE(equivalent); + for (mx::InputPtr floatInput : floatInputs) + { + floatInput->setAttribute(mx::ValueElement::UI_MIN_ATTRIBUTE, " 0.01"); + floatInput->setAttribute(mx::ValueElement::UI_MAX_ATTRIBUTE, " 1.01"); + } + + // Check for child name mismatch + mx::ElementPtr mismatchElement = doc->getDescendant("mygraph/input_color4"); + std::string previousName = mismatchElement->getName(); + mismatchElement->setName("mismatch_color4"); + results.clear(); + equivalent = doc->isEquivalent(doc2, options, &results); + if (!equivalent) + { + printDifferences(results, "Expected name mismatch differences"); + } + else + { + std::cout << "Unexpected name match equivalence:" << std::endl; + std::cout << "Document 1: " << mx::prettyPrint(doc) << std::endl; + std::cout << "Document 2: " << mx::prettyPrint(doc2) << std::endl; + } + REQUIRE(!equivalent); + mismatchElement->setName(previousName); + results.clear(); + equivalent = doc->isEquivalent(doc2, options, &results); + REQUIRE(equivalent); + + // Check for functional nodegraphs + mx::NodeGraphPtr nodeGraph = doc->getNodeGraph("mygraph"); + REQUIRE(nodeGraph); + doc->addNodeDef("ND_mygraph"); + nodeGraph->setNodeDefString("ND_mygraph"); + mx::NodeGraphPtr nodeGraph2 = doc2->getNodeGraph("mygraph"); + REQUIRE(nodeGraph2); + doc2->addNodeDef("ND_mygraph"); + nodeGraph2->setNodeDefString("ND_mygraph"); + results.clear(); + equivalent = doc->isEquivalent(doc2, options, &results); + if (!equivalent) + { + printDifferences(results, "Expected functional graph differences"); + } + else + { + std::cout << "Unexpected functional graph equivalence:" << std::endl; + std::cout << "Document 1: " << mx::prettyPrint(doc) << std::endl; + std::cout << "Document 2: " << mx::prettyPrint(doc2) << std::endl; + } + REQUIRE(!equivalent); +} diff --git a/source/PyMaterialX/PyMaterialXCore/PyElement.cpp b/source/PyMaterialX/PyMaterialXCore/PyElement.cpp index 9f35770e8b..cb70d04704 100644 --- a/source/PyMaterialX/PyMaterialXCore/PyElement.cpp +++ b/source/PyMaterialX/PyMaterialXCore/PyElement.cpp @@ -29,6 +29,12 @@ void bindPyElement(py::module& mod) py::class_(mod, "Element") .def(py::self == py::self) .def(py::self != py::self) + .def("isEquivalent", [](const mx::Element& elem, mx::ConstElementPtr& rhs, const mx::ElementEquivalenceOptions& options) + { + mx::ElementEquivalenceResultVec results; + bool res = elem.isEquivalent(rhs, options, &results); + return std::pair(res, results); + }) .def("setCategory", &mx::Element::setCategory) .def("getCategory", &mx::Element::getCategory) .def("setName", &mx::Element::setName) @@ -205,6 +211,25 @@ void bindPyElement(py::module& mod) py::class_(mod, "GenericElement") .def_readonly_static("CATEGORY", &mx::GenericElement::CATEGORY); + py::class_(mod, "ElementEquivalenceResult") + .def_readonly_static("ATTRIBUTE", &mx::ElementEquivalenceResult::ATTRIBUTE) + .def_readonly_static("ATTRIBUTE_NAMES", &mx::ElementEquivalenceResult::ATTRIBUTE_NAMES) + .def_readonly_static("CHILD_COUNT", &mx::ElementEquivalenceResult::CHILD_COUNT) + .def_readonly_static("CHILD_NAME", &mx::ElementEquivalenceResult::CHILD_NAME) + .def_readonly_static("NAME", &mx::ElementEquivalenceResult::NAME) + .def_readonly_static("CATEGORY", &mx::ElementEquivalenceResult::CATEGORY) + .def_readwrite("path1", &mx::ElementEquivalenceResult::path1) + .def_readwrite("path2", &mx::ElementEquivalenceResult::path2) + .def_readwrite("differenceType", &mx::ElementEquivalenceResult::differenceType) + .def_readwrite("attributeName", &mx::ElementEquivalenceResult::attributeName); + + py::class_(mod, "ElementEquivalenceOptions") + .def_readwrite("format", &mx::ElementEquivalenceOptions::format) + .def_readwrite("precision", &mx::ElementEquivalenceOptions::precision) + .def_readwrite("skipAttributes", &mx::ElementEquivalenceOptions::skipAttributes) + .def_readwrite("skipValueComparisons", &mx::ElementEquivalenceOptions::skipValueComparisons) + .def(py::init<>()); + py::class_(mod, "StringResolver") .def("setFilePrefix", &mx::StringResolver::setFilePrefix) .def("getFilePrefix", &mx::StringResolver::getFilePrefix)