From f5673e94ad2aa258aa9118d01b82091e0f51a65d Mon Sep 17 00:00:00 2001 From: Noeri Huisman Date: Mon, 24 Jul 2023 12:41:56 +0200 Subject: [PATCH] GltfImporter: Support morph target attributes --- .../GltfImporter/GltfImporter.cpp | 231 +++++++++++------- .../GltfImporter/Test/CMakeLists.txt | 4 + .../GltfImporter/Test/GltfImporterTest.cpp | 170 +++++++++++-- .../Test/mesh-custom-attributes.bin | Bin 200 -> 216 bytes .../Test/mesh-custom-attributes.bin.in | 8 +- .../Test/mesh-custom-attributes.gltf | 26 +- .../Test/mesh-duplicate-attributes.gltf | 8 +- .../Test/mesh-invalid-morph-target.gltf | 19 ++ ...sh-invalid-primitive-targets-property.gltf | 19 ++ .../GltfImporter/Test/mesh-invalid.gltf | 28 +++ .../Test/mesh-morph-target-attributes.bin | Bin 0 -> 135 bytes .../Test/mesh-morph-target-attributes.bin.in | 39 +++ .../Test/mesh-morph-target-attributes.gltf | 106 ++++++++ .../Test/mesh-unsupported-vertex-formats.bin | Bin 25 -> 41 bytes .../mesh-unsupported-vertex-formats.bin.in | 5 +- .../Test/mesh-unsupported-vertex-formats.gltf | 29 ++- 16 files changed, 567 insertions(+), 125 deletions(-) create mode 100644 src/MagnumPlugins/GltfImporter/Test/mesh-invalid-morph-target.gltf create mode 100644 src/MagnumPlugins/GltfImporter/Test/mesh-invalid-primitive-targets-property.gltf create mode 100644 src/MagnumPlugins/GltfImporter/Test/mesh-morph-target-attributes.bin create mode 100644 src/MagnumPlugins/GltfImporter/Test/mesh-morph-target-attributes.bin.in create mode 100644 src/MagnumPlugins/GltfImporter/Test/mesh-morph-target-attributes.gltf diff --git a/src/MagnumPlugins/GltfImporter/GltfImporter.cpp b/src/MagnumPlugins/GltfImporter/GltfImporter.cpp index c4611f0de..c5d42c1ea 100644 --- a/src/MagnumPlugins/GltfImporter/GltfImporter.cpp +++ b/src/MagnumPlugins/GltfImporter/GltfImporter.cpp @@ -1245,84 +1245,112 @@ void GltfImporter::doOpenData(Containers::Array&& data, const DataFlags da if(configuration().value("textureCoordinateYFlipInMaterial")) _d->textureCoordinateYFlipInMaterial = true; for(std::size_t i = 0; i != _d->gltfMeshPrimitiveMap.size(); ++i) { + const auto collectCustomAttributes = [this, i](Utility::Json& gltf, const Utility::JsonToken& gltfAttributes) { + for(Utility::JsonObjectItem gltfAttribute: gltfAttributes.asObject()) { + /* Decide about texture coordinate Y flipping if not set already */ + if(gltfAttribute.key().hasPrefix("TEXCOORD_"_s) && isBuiltinNumberedMeshAttribute(gltfAttribute.key())) { + if(_d->textureCoordinateYFlipInMaterial) continue; + + /* Perform a subset of parsing and validation done in doMesh() + and parseAccessor(). Not calling parseAccessor() here because it + would cause the actual buffers to be loaded and a ton other + validation performed, which is undesirable during the + initial file opening. + + On the other hand, for simplicity also not making doMesh() + or parseAccessor() assume any of this was already parsed, + except for validation of the attributes object in the outer + loop, which is guaranteed to be done for all meshes. */ + + if(!gltf.parseUnsignedInt(gltfAttribute.value())) { + Error{} << "Trade::GltfImporter::openData(): invalid attribute" << gltfAttribute.key() << "in mesh" << _d->gltfMeshPrimitiveMap[i].first(); + return false; + } + if(gltfAttribute.value().asUnsignedInt() >= _d->gltfAccessors.size()) { + Error{} << "Trade::GltfImporter::openData(): accessor index" << gltfAttribute.value().asUnsignedInt() << "out of range for" << _d->gltfAccessors.size() << "accessors"; + return false; + } + + const Utility::JsonToken& gltfAccessor = _d->gltfAccessors[gltfAttribute.value().asUnsignedInt()]; + + const Utility::JsonToken* const gltfAccessorComponentType = gltfAccessor.find("componentType"_s); + if(!gltfAccessorComponentType || !gltf.parseUnsignedInt(*gltfAccessorComponentType)) { + Error{} << "Trade::GltfImporter::openData(): accessor" << gltfAttribute.value().asUnsignedInt() << "has missing or invalid componentType property"; + return false; + } + + /* Normalized is optional, defaulting to false */ + const Utility::JsonToken* const gltfAccessorNormalized = gltfAccessor.find("normalized"_s); + if(gltfAccessorNormalized && !gltf.parseBool(*gltfAccessorNormalized)) { + Error{} << "Trade::GltfImporter::openData(): accessor" << gltfAttribute.value().asUnsignedInt() << "has invalid normalized property"; + return false; + } + + const UnsignedInt accessorComponentType = gltfAccessorComponentType->asUnsignedInt(); + const bool normalized = gltfAccessorNormalized && gltfAccessorNormalized->asBool(); + if(accessorComponentType == Implementation::GltfTypeByte || + accessorComponentType == Implementation::GltfTypeShort || + (accessorComponentType == Implementation::GltfTypeUnsignedByte && !normalized) || + (accessorComponentType == Implementation::GltfTypeUnsignedShort && !normalized)) + { + Debug{} << "Trade::GltfImporter::openData(): file contains non-normalized texture coordinates, implicitly enabling textureCoordinateYFlipInMaterial"; + _d->textureCoordinateYFlipInMaterial = true; + } + } + + /* Add the attribute to custom if not there already. Do it for all + builtin attributes as well, as those may still get imported as + custom if they have a strange vertex format. */ + if(_d->meshAttributesForName.emplace(gltfAttribute.key(), + meshAttributeCustom(_d->meshAttributeNames.size())).second + ) + arrayAppend(_d->meshAttributeNames, gltfAttribute.key()); + + /* The spec says that all user-defined attributes must start with + an underscore. We don't really care and just print a warning. */ + /** @todo make this fail if strict mode is enabled? */ + if(!(flags() & ImporterFlag::Quiet) && !isBuiltinMeshAttribute(configuration(), gltfAttribute.key()) && !gltfAttribute.key().hasPrefix("_"_s)) + Warning{} << "Trade::GltfImporter::openData(): unknown attribute" << gltfAttribute.key() << Debug::nospace << ", importing as custom attribute"; + } + + return true; + }; + const Utility::JsonToken& gltfPrimitive = _d->gltfMeshPrimitiveMap[i].second(); /* The glTF spec requires a primitive to define an attribute property with at least one attribute, but we're fine without here. Stricter checks, if any, are done in doMesh(). */ const Utility::JsonToken* gltfAttributes = gltfPrimitive.find("attributes"_s); - if(!gltfAttributes) continue; + if(gltfAttributes) { + if(!gltf->parseObject(*gltfAttributes)) { + Error{} << "Trade::GltfImporter::openData(): invalid primitive attributes property in mesh" << _d->gltfMeshPrimitiveMap[i].first(); + return; + } - if(!gltf->parseObject(*gltfAttributes)) { - Error{} << "Trade::GltfImporter::openData(): invalid primitive attributes property in mesh" << _d->gltfMeshPrimitiveMap[i].first(); - return; + if(!collectCustomAttributes(*gltf, *gltfAttributes)) { + return; + } } - for(Utility::JsonObjectItem gltfAttribute: gltfAttributes->asObject()) { - /* Decide about texture coordinate Y flipping if not set already */ - if(gltfAttribute.key().hasPrefix("TEXCOORD_"_s) && isBuiltinNumberedMeshAttribute(gltfAttribute.key())) { - if(_d->textureCoordinateYFlipInMaterial) continue; - - /* Perform a subset of parsing and validation done in doMesh() - and parseAccessor(). Not calling parseAccessor() here because it - would cause the actual buffers to be loaded and a ton other - validation performed, which is undesirable during the - initial file opening. - - On the other hand, for simplicity also not making doMesh() - or parseAccessor() assume any of this was already parsed, - except for validation of the attributes object in the outer - loop, which is guaranteed to be done for all meshes. */ - - if(!gltf->parseUnsignedInt(gltfAttribute.value())) { - Error{} << "Trade::GltfImporter::openData(): invalid attribute" << gltfAttribute.key() << "in mesh" << _d->gltfMeshPrimitiveMap[i].first(); - return; - } - if(gltfAttribute.value().asUnsignedInt() >= _d->gltfAccessors.size()) { - Error{} << "Trade::GltfImporter::openData(): accessor index" << gltfAttribute.value().asUnsignedInt() << "out of range for" << _d->gltfAccessors.size() << "accessors"; - return; - } - - const Utility::JsonToken& gltfAccessor = _d->gltfAccessors[gltfAttribute.value().asUnsignedInt()]; + /* Go through any morph targets, collecting custom attributes. */ + const Utility::JsonToken* gltfTargets = gltfPrimitive.find("targets"_s); + if(!gltfTargets) continue; - const Utility::JsonToken* const gltfAccessorComponentType = gltfAccessor.find("componentType"_s); - if(!gltfAccessorComponentType || !gltf->parseUnsignedInt(*gltfAccessorComponentType)) { - Error{} << "Trade::GltfImporter::openData(): accessor" << gltfAttribute.value().asUnsignedInt() << "has missing or invalid componentType property"; - return; - } - - /* Normalized is optional, defaulting to false */ - const Utility::JsonToken* const gltfAccessorNormalized = gltfAccessor.find("normalized"_s); - if(gltfAccessorNormalized && !gltf->parseBool(*gltfAccessorNormalized)) { - Error{} << "Trade::GltfImporter::openData(): accessor" << gltfAttribute.value().asUnsignedInt() << "has invalid normalized property"; - return; - } + if(!gltf->parseArray(*gltfTargets)) { + Error{} << "Trade::GltfImporter::openData(): invalid primitive targets property in mesh" << _d->gltfMeshPrimitiveMap[i].first(); + return; + } - const UnsignedInt accessorComponentType = gltfAccessorComponentType->asUnsignedInt(); - const bool normalized = gltfAccessorNormalized && gltfAccessorNormalized->asBool(); - if(accessorComponentType == Implementation::GltfTypeByte || - accessorComponentType == Implementation::GltfTypeShort || - (accessorComponentType == Implementation::GltfTypeUnsignedByte && !normalized) || - (accessorComponentType == Implementation::GltfTypeUnsignedShort && !normalized)) - { - Debug{} << "Trade::GltfImporter::openData(): file contains non-normalized texture coordinates, implicitly enabling textureCoordinateYFlipInMaterial"; - _d->textureCoordinateYFlipInMaterial = true; - } + for(Utility::JsonArrayItem gltfTarget: gltfTargets->asArray()) { + if(!gltf->parseObject(gltfTarget)) { + Error{} << "Trade::GltfImporter::openData(): invalid morph target" << gltfTarget.index() << "in mesh" << _d->gltfMeshPrimitiveMap[i].first(); + return; } - /* Add the attribute to custom if not there already. Do it for all - builtin attributes as well, as those may still get imported as - custom if they have a strange vertex format. */ - if(_d->meshAttributesForName.emplace(gltfAttribute.key(), - meshAttributeCustom(_d->meshAttributeNames.size())).second - ) - arrayAppend(_d->meshAttributeNames, gltfAttribute.key()); - - /* The spec says that all user-defined attributes must start with - an underscore. We don't really care and just print a warning. */ - /** @todo make this fail if strict mode is enabled? */ - if(!(flags() & ImporterFlag::Quiet) && !isBuiltinMeshAttribute(configuration(), gltfAttribute.key()) && !gltfAttribute.key().hasPrefix("_"_s)) - Warning{} << "Trade::GltfImporter::openData(): unknown attribute" << gltfAttribute.key() << Debug::nospace << ", importing as custom attribute"; + if(!collectCustomAttributes(*gltf, gltfTarget)) { + return; + } } } @@ -3261,7 +3289,7 @@ Containers::Optional GltfImporter::doMesh(const UnsignedInt id, Unsign } /* Attributes */ - Containers::Array> attributeOrder; + Containers::Array> attributeOrder; if(const Utility::JsonToken* gltfAttributes = gltfPrimitive.find("attributes"_s)) { /* Primitive attributes object parsed in doOpenData() already, for custom attribute discovery, so we just use it directly. */ @@ -3273,7 +3301,7 @@ Containers::Optional GltfImporter::doMesh(const UnsignedInt id, Unsign /* Bounds check is done in parseAccessor() later, no need to do it here again */ - arrayAppend(attributeOrder, InPlaceInit, gltfAttribute.key(), gltfAttribute.value().asUnsignedInt()); + arrayAppend(attributeOrder, InPlaceInit, gltfAttribute.key(), gltfAttribute.value().asUnsignedInt(), -1); } } @@ -3286,15 +3314,31 @@ Containers::Optional GltfImporter::doMesh(const UnsignedInt id, Unsign return {}; } + /* Morph target attributes */ + if(const Utility::JsonToken* gltfTargets = gltfPrimitive.find("targets"_s)) { + /* Morph targets array and target attribute objects parsed in doOpenData() already, for + custom attribute discovery, so we just use it directly. */ + for(Utility::JsonArrayItem gltfTarget: gltfTargets->asArray()) { + for(Utility::JsonObjectItem gltfMorphAttribute: gltfTarget.value().asObject()) { + if(!_d->gltf->parseUnsignedInt(gltfMorphAttribute.value())) { + Error{} << "Trade::GltfImporter::mesh(): invalid morph target attribute" << gltfMorphAttribute.key() << "in mesh" << _d->gltfMeshPrimitiveMap[id].first(); + return {}; + } + + arrayAppend(attributeOrder, InPlaceInit, gltfMorphAttribute.key(), gltfMorphAttribute.value().asUnsignedInt(), Int(gltfTarget.index())); + } + } + } + /* Sort and remove duplicates except the last one. Attributes sorted by - name so that we add attribute sets in the correct order and can warn if - indices are not contiguous. */ + name (per morph target) so that we add attribute sets in the correct + order and can warn if indices are not contiguous. */ const std::size_t uniqueAttributeCount = stableSortRemoveDuplicatesToPrefix(arrayView(attributeOrder), - [](const Containers::Pair& a, const Containers::Pair& b) { - return a.first() < b.first(); + [](const Containers::Triple& a, const Containers::Triple& b) { + return a.third() != b.third() ? a.third() < b.third() : a.first() < b.first(); }, - [](const Containers::Pair& a, const Containers::Pair& b) { - return a.first() == b.first(); + [](const Containers::Triple& a, const Containers::Triple& b) { + return a.third() == b.third() && a.first() == b.first(); }); /* Gather all (whitelisted) attributes and the total buffer range spanning @@ -3308,10 +3352,12 @@ Containers::Optional GltfImporter::doMesh(const UnsignedInt id, Unsign Math::Range1D bufferRange; Containers::Array attributeData{uniqueAttributeCount}; /** @todo use suffix() once it takes suffix size and not prefix size */ - for(const Containers::Pair& attribute: attributeOrder.exceptPrefix(attributeOrder.size() - uniqueAttributeCount)) { + for(const Containers::Triple& attribute: attributeOrder.exceptPrefix(attributeOrder.size() - uniqueAttributeCount)) { /* Duplicate attribute, skip */ if(attribute.second() == ~0u) continue; + const Int morphTargetId = attribute.third(); + /* Extract base name and number from builtin glTF numbered attributes, use the whole name otherwise */ Containers::StringView baseAttributeName; @@ -3402,18 +3448,20 @@ Containers::Optional GltfImporter::doMesh(const UnsignedInt id, Unsign /** @todo consider merging JOINTS_0, JOINTS_1 etc if they follow each other and have the same type */ } else if(baseAttributeName == "JOINTS"_s) { - if(accessor->second() == VertexFormat::Vector4ui || - accessor->second() == VertexFormat::Vector4us || - accessor->second() == VertexFormat::Vector4ub) { + if((accessor->second() == VertexFormat::Vector4ui || + accessor->second() == VertexFormat::Vector4us || + accessor->second() == VertexFormat::Vector4ub) && morphTargetId == -1) + { ++jointIdAttributeCount; name = MeshAttribute::JointIds; arraySize = vertexFormatComponentCount(accessor->second()); accessor->second() = vertexFormatComponentFormat(accessor->second()); } } else if(baseAttributeName == "WEIGHTS"_s) { - if(accessor->second() == VertexFormat::Vector4 || - accessor->second() == VertexFormat::Vector4ubNormalized || - accessor->second() == VertexFormat::Vector4usNormalized) { + if((accessor->second() == VertexFormat::Vector4 || + accessor->second() == VertexFormat::Vector4ubNormalized || + accessor->second() == VertexFormat::Vector4usNormalized) && morphTargetId == -1) + { ++weightAttributeCount; name = MeshAttribute::Weights; arraySize = vertexFormatComponentCount(accessor->second()); @@ -3425,9 +3473,10 @@ Containers::Optional GltfImporter::doMesh(const UnsignedInt id, Unsign /* Object ID, name custom. To avoid confusion, print the error together with saying it's an object ID attribute */ } else if(attribute.first() == configuration().value("objectIdAttribute")) { - if(accessor->second() == VertexFormat::UnsignedInt || - accessor->second() == VertexFormat::UnsignedShort || - accessor->second() == VertexFormat::UnsignedByte) { + if((accessor->second() == VertexFormat::UnsignedInt || + accessor->second() == VertexFormat::UnsignedShort || + accessor->second() == VertexFormat::UnsignedByte) && morphTargetId == -1) + { name = MeshAttribute::ObjectId; } @@ -3454,8 +3503,12 @@ Containers::Optional GltfImporter::doMesh(const UnsignedInt id, Unsign print it without to be consistent with other messages */ e << attribute.first() << "format" << Debug::packed << accessor->second(); + /* Indicate it's invalid for a morph target */ + if(morphTargetId != -1) + e << "in morph target" << morphTargetId; + if(configuration().value("strict")) - e << Debug::nospace << ", set strict=false to import as a custom atttribute"; + e << Debug::nospace << ", set strict=false to import as a custom attribute"; else e << Debug::nospace << ", importing as a custom attribute"; } @@ -3487,7 +3540,11 @@ Containers::Optional GltfImporter::doMesh(const UnsignedInt id, Unsign bufferRange = Math::join(bufferRange, Math::Range1D::fromSize(reinterpret_cast(bufferView.first().data()), bufferView.first().size())); if(accessor->first().size()[0] != vertexCount) { - Error{} << "Trade::GltfImporter::mesh(): mismatched vertex count for attribute" << attribute.first() << Debug::nospace << ", expected" << vertexCount << "but got" << accessor->first().size()[0]; + Debug e = Error{}; + e << "Trade::GltfImporter::mesh(): mismatched vertex count for attribute" << attribute.first(); + if(morphTargetId != -1) + e << "in morph target" << morphTargetId; + e << Debug::nospace << ", expected" << vertexCount << "but got" << accessor->first().size()[0]; return {}; } } @@ -3496,7 +3553,7 @@ Containers::Optional GltfImporter::doMesh(const UnsignedInt id, Unsign /* Fill in an attribute. Points to the input data, will be patched to the output data once we know where it's allocated. */ - attributeData[attributeId++] = MeshAttributeData{name, accessor->second(), accessor->first(), arraySize}; + attributeData[attributeId++] = MeshAttributeData{name, accessor->second(), accessor->first(), arraySize, morphTargetId}; /* For backwards compatibility insert also a custom "JOINTS" / "WEIGHTS" attribute which is a Vector4 instead of T[4] */ @@ -3561,7 +3618,7 @@ Containers::Optional GltfImporter::doMesh(const UnsignedInt id, Unsign vertexCount, attributeData[i].stride()}; attributeData[i] = MeshAttributeData{attributeData[i].name(), - attributeData[i].format(), data, attributeData[i].arraySize()}; + attributeData[i].format(), data, attributeData[i].arraySize(), attributeData[i].morphTargetId()}; /* Flip Y axis of texture coordinates, unless it's done in the material instead */ diff --git a/src/MagnumPlugins/GltfImporter/Test/CMakeLists.txt b/src/MagnumPlugins/GltfImporter/Test/CMakeLists.txt index 89ea3a0c7..f533fbccc 100644 --- a/src/MagnumPlugins/GltfImporter/Test/CMakeLists.txt +++ b/src/MagnumPlugins/GltfImporter/Test/CMakeLists.txt @@ -154,7 +154,9 @@ corrade_add_test(GltfImporterTest mesh-invalid-buffer-notfound.gltf mesh-invalid-empty-primitives.gltf mesh-invalid-missing-primitives-property.gltf + mesh-invalid-morph-target.gltf mesh-invalid-primitive-attributes-property.gltf + mesh-invalid-primitive-targets-property.gltf mesh-invalid-primitive.gltf mesh-invalid-primitives-property.gltf mesh-invalid-texcoord-flip-attribute-accessor-invalid-component-type.gltf @@ -162,6 +164,8 @@ corrade_add_test(GltfImporterTest mesh-invalid-texcoord-flip-attribute-accessor-missing-component-type.gltf mesh-invalid-texcoord-flip-attribute-oob.gltf mesh-invalid-texcoord-flip-attribute.gltf + mesh-morph-target-attributes.gltf + mesh-morph-target-attributes.bin mesh-multiple-primitives.gltf mesh-no-indices-no-vertices-no-buffer-uri.gltf mesh-no-indices-no-vertices-no-buffer-uri.glb diff --git a/src/MagnumPlugins/GltfImporter/Test/GltfImporterTest.cpp b/src/MagnumPlugins/GltfImporter/Test/GltfImporterTest.cpp index d81040df3..f62ed294f 100644 --- a/src/MagnumPlugins/GltfImporter/Test/GltfImporterTest.cpp +++ b/src/MagnumPlugins/GltfImporter/Test/GltfImporterTest.cpp @@ -135,6 +135,7 @@ struct GltfImporterTest: TestSuite::Tester { void meshCustomAttributesNoFileOpened(); void meshDuplicateAttributes(); void meshUnorderedAttributes(); + void meshMorphTargetAttributes(); void meshMultiplePrimitives(); void meshUnsignedIntVertexFormats(); void meshUnsupportedVertexFormats(); @@ -798,20 +799,23 @@ const struct { "Trade::GltfImporter::mesh(): unsupported COLOR_3 format Vector4us, importing as a custom attribute\n" "Trade::GltfImporter::mesh(): unsupported object ID attribute _OBJECT_ID format Short, importing as a custom attribute\n" "Trade::GltfImporter::mesh(): found attribute JOINTS_7 but expected JOINTS_0\n" - "Trade::GltfImporter::mesh(): unsupported JOINTS_7 format Vector3ub, importing as a custom attribute\n"}, + "Trade::GltfImporter::mesh(): unsupported JOINTS_7 format Vector3ub, importing as a custom attribute\n" + "Trade::GltfImporter::mesh(): unsupported WEIGHTS_0 format Vector4 in morph target 0, importing as a custom attribute\n"}, {"quiet", ImporterFlag::Quiet, {}, ""}, {"strict", {}, true, "Trade::GltfImporter::mesh(): found attribute COLOR_3 but expected COLOR_0\n" - "Trade::GltfImporter::mesh(): unsupported COLOR_3 format Vector4us, set strict=false to import as a custom atttribute\n" - "Trade::GltfImporter::mesh(): unsupported object ID attribute _OBJECT_ID format Short, set strict=false to import as a custom atttribute\n" + "Trade::GltfImporter::mesh(): unsupported COLOR_3 format Vector4us, set strict=false to import as a custom attribute\n" + "Trade::GltfImporter::mesh(): unsupported object ID attribute _OBJECT_ID format Short, set strict=false to import as a custom attribute\n" "Trade::GltfImporter::mesh(): found attribute JOINTS_7 but expected JOINTS_0\n" - "Trade::GltfImporter::mesh(): unsupported JOINTS_7 format Vector3ub, set strict=false to import as a custom atttribute\n"}, + "Trade::GltfImporter::mesh(): unsupported JOINTS_7 format Vector3ub, set strict=false to import as a custom attribute\n" + "Trade::GltfImporter::mesh(): unsupported WEIGHTS_0 format Vector4 in morph target 0, set strict=false to import as a custom attribute\n"}, {"strict, quiet", ImporterFlag::Quiet, true, /* Warnings omitted, errors stay */ - "Trade::GltfImporter::mesh(): unsupported COLOR_3 format Vector4us, set strict=false to import as a custom atttribute\n" - "Trade::GltfImporter::mesh(): unsupported object ID attribute _OBJECT_ID format Short, set strict=false to import as a custom atttribute\n" - "Trade::GltfImporter::mesh(): unsupported JOINTS_7 format Vector3ub, set strict=false to import as a custom atttribute\n"}, + "Trade::GltfImporter::mesh(): unsupported COLOR_3 format Vector4us, set strict=false to import as a custom attribute\n" + "Trade::GltfImporter::mesh(): unsupported object ID attribute _OBJECT_ID format Short, set strict=false to import as a custom attribute\n" + "Trade::GltfImporter::mesh(): unsupported JOINTS_7 format Vector3ub, set strict=false to import as a custom attribute\n" + "Trade::GltfImporter::mesh(): unsupported WEIGHTS_0 format Vector4 in morph target 0, set strict=false to import as a custom attribute\n"}, }; constexpr struct { @@ -897,6 +901,14 @@ const struct { "mesh-invalid-primitive-attributes-property.gltf", "Utility::Json::parseObject(): expected an object, got Utility::JsonToken::Type::Array at {}:14:25\n" "Trade::GltfImporter::openData(): invalid primitive attributes property in mesh 1\n"}, + {"invalid primitive targets property", + "mesh-invalid-primitive-targets-property.gltf", + "Utility::Json::parseArray(): expected an array, got Utility::JsonToken::Type::Object at {}:14:22\n" + "Trade::GltfImporter::openData(): invalid primitive targets property in mesh 1\n"}, + {"invalid morph target", + "mesh-invalid-morph-target.gltf", + "Utility::Json::parseObject(): expected an object, got Utility::JsonToken::Type::Number at {}:14:23\n" + "Trade::GltfImporter::openData(): invalid morph target 0 in mesh 1\n"}, {"texcoord flip invalid attribute", "mesh-invalid-texcoord-flip-attribute.gltf", "Utility::Json::parseUnsignedInt(): expected a number, got Utility::JsonToken::Type::String at {}:15:27\n" @@ -968,56 +980,56 @@ const struct { {"buffer with missing uri property", "buffer 2 has missing uri property"}, {"buffer with invalid uri property", - "Utility::Json::parseString(): expected a string, got Utility::JsonToken::Type::Array at {}:875:14\n" + "Utility::Json::parseString(): expected a string, got Utility::JsonToken::Type::Array at {}:903:14\n" "Trade::GltfImporter::mesh(): buffer 3 has invalid uri property\n"}, {"buffer with invalid uri", "invalid URI escape sequence %%"}, {"buffer with missing byteLength property", "buffer 5 has missing or invalid byteLength property"}, {"buffer with invalid byteLength property", - "Utility::Json::parseSize(): too large integer literal -3 at {}:889:21\n" + "Utility::Json::parseSize(): too large integer literal -3 at {}:917:21\n" "Trade::GltfImporter::mesh(): buffer 6 has missing or invalid byteLength property\n"}, {"buffer view with missing buffer property", "buffer view 9 has missing or invalid buffer property"}, {"buffer view with invalid buffer property", - "Utility::Json::parseUnsignedInt(): too large integer literal -1 at {}:825:17\n" + "Utility::Json::parseUnsignedInt(): too large integer literal -1 at {}:853:17\n" "Trade::GltfImporter::mesh(): buffer view 10 has missing or invalid buffer property\n"}, {"buffer view with invalid byteOffset property", - "Utility::Json::parseSize(): too large integer literal -1 at {}:831:21\n" + "Utility::Json::parseSize(): too large integer literal -1 at {}:859:21\n" "Trade::GltfImporter::mesh(): buffer view 11 has invalid byteOffset property\n"}, {"buffer view with missing byteLength property", "buffer view 12 has missing or invalid byteLength property"}, {"buffer view with invalid byteLength property", - "Utility::Json::parseSize(): too large integer literal -12 at {}:841:21\n" + "Utility::Json::parseSize(): too large integer literal -12 at {}:869:21\n" "Trade::GltfImporter::mesh(): buffer view 13 has missing or invalid byteLength property\n"}, {"buffer view with invalid byteStride property", - "Utility::Json::parseUnsignedInt(): too large integer literal -4 at {}:847:21\n" + "Utility::Json::parseUnsignedInt(): too large integer literal -4 at {}:875:21\n" "Trade::GltfImporter::mesh(): buffer view 14 has invalid byteStride property\n"}, {"accessor with missing bufferView property", "accessor 11 has missing or invalid bufferView property"}, {"accessor with invalid bufferView property", - "Utility::Json::parseUnsignedInt(): too large integer literal -1 at {}:687:21\n" + "Utility::Json::parseUnsignedInt(): too large integer literal -1 at {}:715:21\n" "Trade::GltfImporter::mesh(): accessor 30 has missing or invalid bufferView property\n"}, {"accessor with invalid byteOffset property", - "Utility::Json::parseSize(): too large integer literal -1 at {}:695:21\n" + "Utility::Json::parseSize(): too large integer literal -1 at {}:723:21\n" "Trade::GltfImporter::mesh(): accessor 31 has invalid byteOffset property\n"}, {"accessor with missing componentType property", "accessor 32 has missing or invalid componentType property"}, {"accessor with invalid componentType property", - "Utility::Json::parseUnsignedInt(): too large integer literal -1 at {}:709:24\n" + "Utility::Json::parseUnsignedInt(): too large integer literal -1 at {}:737:24\n" "Trade::GltfImporter::mesh(): accessor 33 has missing or invalid componentType property\n"}, {"accessor with missing count property", "accessor 34 has missing or invalid count property"}, {"accessor with invalid count property", - "Utility::Json::parseSize(): too large integer literal -1 at {}:723:16\n" + "Utility::Json::parseSize(): too large integer literal -1 at {}:751:16\n" "Trade::GltfImporter::mesh(): accessor 35 has missing or invalid count property\n"}, {"accessor with missing type property", "accessor 36 has missing or invalid type property"}, {"accessor with invalid type property", - "Utility::Json::parseString(): expected a string, got Utility::JsonToken::Type::Number at {}:737:15\n" + "Utility::Json::parseString(): expected a string, got Utility::JsonToken::Type::Number at {}:765:15\n" "Trade::GltfImporter::mesh(): accessor 37 has missing or invalid type property\n"}, {"accessor with invalid normalized property", - "Utility::Json::parseBool(): expected a bool, got Utility::JsonToken::Type::Null at {}:745:21\n" + "Utility::Json::parseBool(): expected a bool, got Utility::JsonToken::Type::Null at {}:773:21\n" "Trade::GltfImporter::mesh(): accessor 38 has invalid normalized property\n"}, {"invalid primitive property", "Utility::Json::parseUnsignedInt(): too large integer literal -1 at {}:436:19\n" @@ -1028,6 +1040,11 @@ const struct { {"invalid indices property", "Utility::Json::parseUnsignedInt(): too large integer literal -1 at {}:456:22\n" "Trade::GltfImporter::mesh(): invalid indices property\n"}, + {"invalid morph target attribute", + "Utility::Json::parseUnsignedInt(): too large integer literal -1 at {}:467:27\n" + "Trade::GltfImporter::mesh(): invalid morph target attribute POSITION in mesh 46\n"}, + {"different vertex count for morph target attribute", + "Trade::GltfImporter::mesh(): mismatched vertex count for attribute TEXCOORD_0 in morph target 0, expected 3 but got 4\n"}, }; constexpr struct { @@ -1677,6 +1694,8 @@ GltfImporterTest::GltfImporterTest() { addInstancedTests({&GltfImporterTest::meshUnorderedAttributes}, Containers::arraySize(QuietData)); + addTests({&GltfImporterTest::meshMorphTargetAttributes}); + addTests({&GltfImporterTest::meshMultiplePrimitives}); addInstancedTests({&GltfImporterTest::meshUnsignedIntVertexFormats}, @@ -4355,16 +4374,20 @@ void GltfImporterTest::meshCustomAttributes() { const MeshAttribute tbnPreciserAttribute = importer->meshAttributeForName("_TBN_PRECISER"); const MeshAttribute objectIdAttribute = importer->meshAttributeForName("OBJECT_ID3"); + const MeshAttribute fancyPropertyAttribute = importer->meshAttributeForName("_FANCY_PROPERTY3"); + CORRADE_COMPARE(fancyPropertyAttribute, meshAttributeCustom(customAttributeOffset + 4)); + CORRADE_COMPARE(importer->meshAttributeName(fancyPropertyAttribute), "_FANCY_PROPERTY3"); + const MeshAttribute doubleShotAttribute = importer->meshAttributeForName("_DOUBLE_SHOT"); - CORRADE_COMPARE(doubleShotAttribute, meshAttributeCustom(customAttributeOffset + 6)); + CORRADE_COMPARE(doubleShotAttribute, meshAttributeCustom(customAttributeOffset + 7)); const MeshAttribute negativePaddingAttribute = importer->meshAttributeForName("_NEGATIVE_PADDING"); - CORRADE_COMPARE(negativePaddingAttribute, meshAttributeCustom(customAttributeOffset + 4)); + CORRADE_COMPARE(negativePaddingAttribute, meshAttributeCustom(customAttributeOffset + 5)); const MeshAttribute notAnIdentityAttribute = importer->meshAttributeForName("NOT_AN_IDENTITY"); CORRADE_VERIFY(notAnIdentityAttribute != MeshAttribute{}); Containers::Optional mesh = importer->mesh("standard types"); CORRADE_VERIFY(mesh); - CORRADE_COMPARE(mesh->attributeCount(), 4); + CORRADE_COMPARE(mesh->attributeCount(), 5); CORRADE_VERIFY(mesh->hasAttribute(tbnAttribute)); CORRADE_COMPARE(mesh->attributeFormat(tbnAttribute), VertexFormat::Matrix3x3bNormalizedAligned); @@ -4398,6 +4421,12 @@ void GltfImporterTest::meshCustomAttributes() { Containers::arrayView({5678125}), TestSuite::Compare::Container); + CORRADE_VERIFY(mesh->hasAttribute(fancyPropertyAttribute, 0)); + CORRADE_COMPARE(mesh->attributeFormat(fancyPropertyAttribute, 0, 0), VertexFormat::Vector4); + CORRADE_COMPARE_AS(mesh->attribute(fancyPropertyAttribute, 0, 0), + Containers::arrayView({{0.1f, 0.2f, 0.3f, 0.4f}}), + TestSuite::Compare::Container); + /* Not testing import failure of non-core glTF attribute types, that's already tested in meshInvalid() */ } @@ -4421,7 +4450,7 @@ void GltfImporterTest::meshDuplicateAttributes() { Containers::Optional mesh = importer->mesh(0); CORRADE_VERIFY(mesh); - CORRADE_COMPARE(mesh->attributeCount(), 3); + CORRADE_COMPARE(mesh->attributeCount(), 4); /* Duplicate attributes replace previously declared attributes with the same name. Checking the formats should be enough to test the right @@ -4434,6 +4463,11 @@ void GltfImporterTest::meshDuplicateAttributes() { CORRADE_VERIFY(mesh->hasAttribute(thingAttribute)); CORRADE_COMPARE(mesh->attributeCount(thingAttribute), 1); CORRADE_COMPARE(mesh->attributeFormat(thingAttribute), VertexFormat::Vector2); + + /* Duplicate morph target attribute also replace previously + declared attributes within their respective morph target. */ + CORRADE_COMPARE(mesh->attributeCount(MeshAttribute::Color, 0), 1); + CORRADE_COMPARE(mesh->attributeFormat(MeshAttribute::Color, 0, 0), VertexFormat::Vector3); } void GltfImporterTest::meshUnorderedAttributes() { @@ -4492,6 +4526,73 @@ void GltfImporterTest::meshUnorderedAttributes() { CORRADE_COMPARE(mesh->attributeFormat(customAttribute1), VertexFormat::Vector3); } +void GltfImporterTest::meshMorphTargetAttributes() { + Containers::Pointer importer = _manager.instantiate("GltfImporter"); + + CORRADE_VERIFY(importer->openFile(Utility::Path::join(GLTFIMPORTER_TEST_DIR, "mesh-morph-target-attributes.gltf"))); + CORRADE_COMPARE(importer->meshCount(), 1); + + Containers::Optional mesh = importer->mesh(0); + CORRADE_VERIFY(mesh); + CORRADE_COMPARE(mesh->attributeCount(), 6); + + /* Base mesh (position, normal and color) */ + CORRADE_COMPARE(mesh->attributeCount(-1), 2); + + CORRADE_COMPARE(mesh->attributeCount(MeshAttribute::Position), 1); + CORRADE_COMPARE(mesh->attributeFormat(MeshAttribute::Position), VertexFormat::Vector3); + + CORRADE_COMPARE(mesh->attributeCount(MeshAttribute::Normal), 1); + CORRADE_COMPARE(mesh->attributeFormat(MeshAttribute::Normal), VertexFormat::Vector3); + CORRADE_COMPARE_AS(mesh->normalsAsArray(), Containers::arrayView({ + {1.0f, 0.0f, 0.0f}, + {0.0f, 1.0f, 0.0f}, + {0.0f, 0.0f, 1.0f} + }), TestSuite::Compare::Container); + + /* First morph target (position and normal) */ + CORRADE_COMPARE(mesh->attributeCount(0), 2); + + CORRADE_COMPARE(mesh->attributeCount(MeshAttribute::Position, 0), 1); + CORRADE_COMPARE(mesh->attributeFormat(MeshAttribute::Position, 0, 0), VertexFormat::Vector3us); + CORRADE_COMPARE_AS(mesh->attribute(MeshAttribute::Position, 0, 0), Containers::arrayView({ + {10, 20, 30}, + {40, 50, 60}, + {70, 80, 90} + }), TestSuite::Compare::Container); + + CORRADE_COMPARE(mesh->attributeCount(MeshAttribute::Normal, 0), 1); + CORRADE_COMPARE(mesh->attributeFormat(MeshAttribute::Normal, 0, 0), VertexFormat::Vector3sNormalized); + CORRADE_COMPARE_AS(mesh->attribute(MeshAttribute::Normal, 0, 0), Containers::arrayView({ + {1, 2, 3}, + {4, 5, 6}, + {7, 8, 9} + }), TestSuite::Compare::Container); + + /* Second morph target (position) */ + CORRADE_COMPARE(mesh->attributeCount(1), 1); + + CORRADE_COMPARE(mesh->attributeCount(MeshAttribute::Position, 1), 1); + CORRADE_COMPARE(mesh->attributeFormat(MeshAttribute::Position, 0, 1), VertexFormat::Vector3us); + CORRADE_COMPARE_AS(mesh->attribute(MeshAttribute::Position, 0, 1), Containers::arrayView({ + {100, 200, 300}, + {400, 500, 600}, + {700, 800, 900} + }), TestSuite::Compare::Container); + + /* Third morph target (normal) */ + CORRADE_COMPARE(mesh->attributeCount(2), 1); + + CORRADE_COMPARE(mesh->attributeCount(MeshAttribute::Normal, 2), 1); + CORRADE_COMPARE(mesh->attributeFormat(MeshAttribute::Normal, 0, 2), VertexFormat::Vector3bNormalized); + CORRADE_COMPARE_AS(mesh->attribute(MeshAttribute::Normal, 0, 2), Containers::arrayView({ + {-1, -2, -3}, + {-4, -5, -6}, + {-7, -8, -9} + }), TestSuite::Compare::Container); + +} + void GltfImporterTest::meshMultiplePrimitives() { Containers::Pointer importer = _manager.instantiate("GltfImporter"); CORRADE_VERIFY(importer->openFile(Utility::Path::join(GLTFIMPORTER_TEST_DIR, "mesh-multiple-primitives.gltf"))); @@ -4631,7 +4732,7 @@ void GltfImporterTest::meshUnsupportedVertexFormats() { importer->configuration().setValue("strict", *data.strict); CORRADE_VERIFY(importer->openFile(Utility::Path::join(GLTFIMPORTER_TEST_DIR, "mesh-unsupported-vertex-formats.gltf"))); - CORRADE_COMPARE(importer->meshCount(), 3); + CORRADE_COMPARE(importer->meshCount(), 4); /* The data have to be split across three meshes because it always bails on the first error and so the subsequent errors wouldn't be caught if @@ -4639,6 +4740,7 @@ void GltfImporterTest::meshUnsupportedVertexFormats() { Containers::Optional mesh0; Containers::Optional mesh1; Containers::Optional mesh2; + Containers::Optional mesh3; std::ostringstream out; { Error redirectError{&out}; @@ -4646,10 +4748,12 @@ void GltfImporterTest::meshUnsupportedVertexFormats() { mesh0 = importer->mesh(0); mesh1 = importer->mesh(1); mesh2 = importer->mesh(2); + mesh3 = importer->mesh(3); } CORRADE_COMPARE(!!mesh0, !data.strict || !*data.strict); CORRADE_COMPARE(!!mesh1, !data.strict || !*data.strict); CORRADE_COMPARE(!!mesh2, !data.strict || !*data.strict); + CORRADE_COMPARE(!!mesh3, !data.strict || !*data.strict); CORRADE_COMPARE(out.str(), data.message); if(mesh0) { @@ -4688,9 +4792,23 @@ void GltfImporterTest::meshUnsupportedVertexFormats() { }), TestSuite::Compare::Container); } - /* All three meshes should have the same position attribute which didn't + if(mesh3) { + CORRADE_COMPARE(mesh3->attributeCount(), 3); + CORRADE_VERIFY(isMeshAttributeCustom(mesh3->attributeName(2))); + + /* The WEIGHTS attribute is not supported as part of a morph target, + despite the otherwise supported Vertex4 format */ + CORRADE_COMPARE(importer->meshAttributeName(mesh3->attributeName(2)), "WEIGHTS_0"); + CORRADE_COMPARE(mesh3->attributeMorphTargetId(2), 0); + CORRADE_COMPARE(mesh3->attributeFormat(2), VertexFormat::Vector4); + CORRADE_COMPARE_AS(mesh3->attribute(2), Containers::arrayView({ + Vector4{1.0f, 2.0f, 3.0f, 4.0f} + }), TestSuite::Compare::Container); + } + + /* All four meshes should have the same position attribute which didn't cause an error */ - if(mesh0) for(auto* mesh: {&*mesh0, &*mesh1, &*mesh2}) { + if(mesh0) for(auto* mesh: {&*mesh0, &*mesh1, &*mesh2, &*mesh3}) { CORRADE_VERIFY(mesh->hasAttribute(MeshAttribute::Position)); CORRADE_COMPARE(mesh->attributeFormat(MeshAttribute::Position), VertexFormat::Vector3); CORRADE_COMPARE_AS(mesh->attribute(MeshAttribute::Position), Containers::arrayView({ diff --git a/src/MagnumPlugins/GltfImporter/Test/mesh-custom-attributes.bin b/src/MagnumPlugins/GltfImporter/Test/mesh-custom-attributes.bin index 99562d4e90121806ac2487371db7bc09eccdb0d8..b515c73a01a198deea221dd77f4d654d56b55723 100644 GIT binary patch delta 23 ecmX@Xc!P1m34ya`&e)zk<6}2#=1e;v+YSJcg$#WF delta 6 Ncmcb?c!F`l2>=Qv0{Z{} diff --git a/src/MagnumPlugins/GltfImporter/Test/mesh-custom-attributes.bin.in b/src/MagnumPlugins/GltfImporter/Test/mesh-custom-attributes.bin.in index 8a6f9634e..f34c23c50 100644 --- a/src/MagnumPlugins/GltfImporter/Test/mesh-custom-attributes.bin.in +++ b/src/MagnumPlugins/GltfImporter/Test/mesh-custom-attributes.bin.in @@ -24,7 +24,7 @@ input += [ -7, -8, -9 ] -# _OBJECT_ID, _DOUBLE_SHOT, _NEGATIVE_PADDING +# OBJECT_ID3, _DOUBLE_SHOT, _NEGATIVE_PADDING type += 'Iddixxxx' input += [5678125, 31.2, 28.8, -3548415] @@ -37,4 +37,10 @@ input += [ 1.3, 1.4, 1.5, 1.6 ] +# _FANCY_PROPERTY3 +type += '4f' +input += [ + 0.1, 0.2, 0.3, 0.4 +] + # kate: hl python diff --git a/src/MagnumPlugins/GltfImporter/Test/mesh-custom-attributes.gltf b/src/MagnumPlugins/GltfImporter/Test/mesh-custom-attributes.gltf index f6973eaaa..7a3a06937 100644 --- a/src/MagnumPlugins/GltfImporter/Test/mesh-custom-attributes.gltf +++ b/src/MagnumPlugins/GltfImporter/Test/mesh-custom-attributes.gltf @@ -12,7 +12,12 @@ "_UV_ROTATION": 1, "_TBN_PRECISER": 2, "OBJECT_ID3": 3 - } + }, + "targets": [ + { + "_FANCY_PROPERTY3": 4 + } + ] } ] }, @@ -22,9 +27,9 @@ "primitives": [ { "attributes": { - "_NEGATIVE_PADDING": 4, - "NOT_AN_IDENTITY": 5, - "_DOUBLE_SHOT": 6 + "_NEGATIVE_PADDING": 5, + "NOT_AN_IDENTITY": 6, + "_DOUBLE_SHOT": 7 } } ] @@ -62,6 +67,13 @@ "count": 1, "type": "SCALAR" }, + { + "bufferView": 0, + "byteOffset": 200, + "componentType": 5126, + "count": 1, + "type": "VEC4" + }, { "bufferView": 0, "byteOffset": 64, @@ -87,13 +99,13 @@ "bufferViews": [ { "buffer": 0, - "byteLength": 200, - "byteStride": 200 + "byteLength": 216, + "byteStride": 216 } ], "buffers": [ { - "byteLength": 200, + "byteLength": 216, "uri": "mesh-custom-attributes.bin" } ] diff --git a/src/MagnumPlugins/GltfImporter/Test/mesh-duplicate-attributes.gltf b/src/MagnumPlugins/GltfImporter/Test/mesh-duplicate-attributes.gltf index 82fb561a8..9281b0921 100644 --- a/src/MagnumPlugins/GltfImporter/Test/mesh-duplicate-attributes.gltf +++ b/src/MagnumPlugins/GltfImporter/Test/mesh-duplicate-attributes.gltf @@ -13,7 +13,13 @@ "COLOR_0": 1, "COLOR_1": 1, "COLOR_0": 3 - } + }, + "targets": [ + { + "COLOR_0": 3, + "COLOR_0": 1 + } + ] } ] } diff --git a/src/MagnumPlugins/GltfImporter/Test/mesh-invalid-morph-target.gltf b/src/MagnumPlugins/GltfImporter/Test/mesh-invalid-morph-target.gltf new file mode 100644 index 000000000..932a88e42 --- /dev/null +++ b/src/MagnumPlugins/GltfImporter/Test/mesh-invalid-morph-target.gltf @@ -0,0 +1,19 @@ +{ + "asset": { + "version": "2.0" + }, + "meshes": [ + { + "primitives": [ + {} + ] + }, + { + "primitives": [ + { + "targets": [-1] + } + ] + } + ] +} diff --git a/src/MagnumPlugins/GltfImporter/Test/mesh-invalid-primitive-targets-property.gltf b/src/MagnumPlugins/GltfImporter/Test/mesh-invalid-primitive-targets-property.gltf new file mode 100644 index 000000000..f2fa0d3a7 --- /dev/null +++ b/src/MagnumPlugins/GltfImporter/Test/mesh-invalid-primitive-targets-property.gltf @@ -0,0 +1,19 @@ +{ + "asset": { + "version": "2.0" + }, + "meshes": [ + { + "primitives": [ + {} + ] + }, + { + "primitives": [ + { + "targets": {} + } + ] + } + ] +} diff --git a/src/MagnumPlugins/GltfImporter/Test/mesh-invalid.gltf b/src/MagnumPlugins/GltfImporter/Test/mesh-invalid.gltf index 78781246a..633968aad 100644 --- a/src/MagnumPlugins/GltfImporter/Test/mesh-invalid.gltf +++ b/src/MagnumPlugins/GltfImporter/Test/mesh-invalid.gltf @@ -456,6 +456,34 @@ "indices": -1 } ] + }, + { + "name": "invalid morph target attribute", + "primitives": [ + { + "attributes": {}, + "targets": [ + { + "POSITION": -1 + } + ] + } + ] + }, + { + "name": "different vertex count for morph target attribute", + "primitives": [ + { + "attributes": { + "POSITION": 0 + }, + "targets": [ + { + "TEXCOORD_0": 1 + } + ] + } + ] } ], "accessors": [ diff --git a/src/MagnumPlugins/GltfImporter/Test/mesh-morph-target-attributes.bin b/src/MagnumPlugins/GltfImporter/Test/mesh-morph-target-attributes.bin new file mode 100644 index 0000000000000000000000000000000000000000..b59d3498bc194cf30269fed44ed38d9d337f5a8d GIT binary patch literal 135 zcmZQzIAG7f(6FC@fdPmcfNVGb@)aBy7#yHtaJd5@b+cy9w1cYz^0^p97~~ij8JHND z88jG-7;G3=7+4wD7~B{F7@`>188{d?8B!QdFz7H&VEn=u!L)}-fw_hG|G&R~e*gOU G<2wNDRvE