diff --git a/CHANGES.md b/CHANGES.md index b50a002bc..a00cfba6a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,11 @@ # Change Log +### v0.?.? - 2023-?-? + +##### Additions :tada: + +- Added support for loading tilesets with `pnts` content. Point clouds are converted to `glTF`s with a single `POINTS` primitive, while batch tables are converted to `EXT_feature_metadata`. + ### v0.21.3 - 2023-02-01 ##### Fixes :wrench: diff --git a/Cesium3DTilesSelection/CMakeLists.txt b/Cesium3DTilesSelection/CMakeLists.txt index 08beda942..793a7da73 100644 --- a/Cesium3DTilesSelection/CMakeLists.txt +++ b/Cesium3DTilesSelection/CMakeLists.txt @@ -55,6 +55,7 @@ target_link_libraries(Cesium3DTilesSelection uriparser libmorton expected-lite + ${CESIUM_NATIVE_DRACO_LIBRARY} ) install(TARGETS Cesium3DTilesSelection diff --git a/Cesium3DTilesSelection/src/B3dmToGltfConverter.cpp b/Cesium3DTilesSelection/src/B3dmToGltfConverter.cpp index 7b708cd54..6ee772ca5 100644 --- a/Cesium3DTilesSelection/src/B3dmToGltfConverter.cpp +++ b/Cesium3DTilesSelection/src/B3dmToGltfConverter.cpp @@ -220,7 +220,7 @@ void convertB3dmMetadataToGltfFeatureMetadata( } // upgrade batch table to glTF feature metadata and append the result - result.errors.merge(BatchTableToGltfFeatureMetadata::convert( + result.errors.merge(BatchTableToGltfFeatureMetadata::convertFromB3dm( featureTableJson, batchTableJson, batchTableBinaryData, diff --git a/Cesium3DTilesSelection/src/BatchTableToGltfFeatureMetadata.cpp b/Cesium3DTilesSelection/src/BatchTableToGltfFeatureMetadata.cpp index 8d814347f..4984e681e 100644 --- a/Cesium3DTilesSelection/src/BatchTableToGltfFeatureMetadata.cpp +++ b/Cesium3DTilesSelection/src/BatchTableToGltfFeatureMetadata.cpp @@ -45,7 +45,7 @@ struct CompatibleTypes { }; struct BinaryProperty { - int64_t b3dmByteOffset; + int64_t batchTableByteOffset; int64_t gltfByteOffset; int64_t byteLength; }; @@ -55,8 +55,8 @@ struct GltfFeatureTableType { size_t typeSize; }; -const std::map b3dmComponentTypeToGltfType = - { +const std::map + batchTableComponentTypeToGltfType = { {"BYTE", GltfFeatureTableType{"INT8", sizeof(int8_t)}}, {"UNSIGNED_BYTE", GltfFeatureTableType{"UINT8", sizeof(uint8_t)}}, {"SHORT", GltfFeatureTableType{"INT16", sizeof(int16_t)}}, @@ -1223,13 +1223,13 @@ void updateExtensionWithBinaryProperty( assert( gltfBufferIndex >= 0 && "gltfBufferIndex is negative. Need to allocate buffer before " - "convert the binary property"); + "converting the binary property"); const auto& byteOffsetIt = propertyValue.FindMember("byteOffset"); if (byteOffsetIt == propertyValue.MemberEnd() || !byteOffsetIt->value.IsInt64()) { result.emplaceWarning(fmt::format( - "Skip convert {}. The binary property doesn't have required " + "Skip converting {}. The binary property doesn't have required " "byteOffset.", propertyName)); return; @@ -1239,7 +1239,7 @@ void updateExtensionWithBinaryProperty( if (componentTypeIt == propertyValue.MemberEnd() || !componentTypeIt->value.IsString()) { result.emplaceWarning(fmt::format( - "Skip convert {}. The binary property doesn't have required " + "Skip converting {}. The binary property doesn't have required " "componentType.", propertyName)); return; @@ -1258,8 +1258,8 @@ void updateExtensionWithBinaryProperty( const std::string& componentType = componentTypeIt->value.GetString(); const std::string& type = typeIt->value.GetString(); - auto convertedTypeIt = b3dmComponentTypeToGltfType.find(componentType); - if (convertedTypeIt == b3dmComponentTypeToGltfType.end()) { + auto convertedTypeIt = batchTableComponentTypeToGltfType.find(componentType); + if (convertedTypeIt == batchTableComponentTypeToGltfType.end()) { return; } const GltfFeatureTableType& gltfType = convertedTypeIt->second; @@ -1297,7 +1297,7 @@ void updateExtensionWithBinaryProperty( featureTableProperty.bufferView = static_cast(gltf.bufferViews.size() - 1); - binaryProperty.b3dmByteOffset = byteOffset; + binaryProperty.batchTableByteOffset = byteOffset; binaryProperty.gltfByteOffset = gltfBufferOffset; binaryProperty.byteLength = static_cast(bufferView.byteLength); } @@ -1379,37 +1379,12 @@ void updateExtensionWithBatchTableHierarchy( } } -} // namespace - -ErrorList BatchTableToGltfFeatureMetadata::convert( - const rapidjson::Document& featureTableJson, +void convertBatchTableToGltfFeatureMetadataExtension( const rapidjson::Document& batchTableJson, const gsl::span& batchTableBinaryData, - CesiumGltf::Model& gltf) { - // Check to make sure a char of rapidjson is 1 byte - static_assert( - sizeof(rapidjson::Value::Ch) == 1, - "RapidJson::Value::Ch is not 1 byte"); - - ErrorList result; - - // Parse the b3dm batch table and convert it to the EXT_feature_metadata - // extension. - - // If the feature table is missing the BATCH_LENGTH semantic, ignore the batch - // table completely. - const auto batchLengthIt = featureTableJson.FindMember("BATCH_LENGTH"); - if (batchLengthIt == featureTableJson.MemberEnd() || - !batchLengthIt->value.IsInt64()) { - result.emplaceWarning( - "The B3DM has a batch table, but it is being ignored because there is " - "no BATCH_LENGTH semantic in the feature table or it is not an " - "integer."); - return result; - } - - const int64_t batchLength = batchLengthIt->value.GetInt64(); - + CesiumGltf::Model& gltf, + const int64_t featureCount, + ErrorList& result) { // Add the binary part of the batch table - if any - to the glTF as a buffer. // We will reallign this buffer later on int32_t gltfBufferIndex = -1; @@ -1430,7 +1405,7 @@ ErrorList BatchTableToGltfFeatureMetadata::convert( FeatureTable& featureTable = modelExtension.featureTables.emplace("default", FeatureTable()) .first->second; - featureTable.count = batchLength; + featureTable.count = featureCount; featureTable.classProperty = "default"; // Convert each regular property in the batch table @@ -1499,10 +1474,49 @@ ErrorList BatchTableToGltfFeatureMetadata::convert( for (const BinaryProperty& binaryProperty : binaryProperties) { std::memcpy( buffer.cesium.data.data() + binaryProperty.gltfByteOffset, - batchTableBinaryData.data() + binaryProperty.b3dmByteOffset, + batchTableBinaryData.data() + binaryProperty.batchTableByteOffset, static_cast(binaryProperty.byteLength)); } } +} + +} // namespace + +ErrorList BatchTableToGltfFeatureMetadata::convertFromB3dm( + const rapidjson::Document& featureTableJson, + const rapidjson::Document& batchTableJson, + const gsl::span& batchTableBinaryData, + CesiumGltf::Model& gltf) { + // Check to make sure a char of rapidjson is 1 byte + static_assert( + sizeof(rapidjson::Value::Ch) == 1, + "RapidJson::Value::Ch is not 1 byte"); + + ErrorList result; + + // Parse the b3dm batch table and convert it to the EXT_feature_metadata + // extension. + + // If the feature table is missing the BATCH_LENGTH semantic, ignore the batch + // table completely. + const auto batchLengthIt = featureTableJson.FindMember("BATCH_LENGTH"); + if (batchLengthIt == featureTableJson.MemberEnd() || + !batchLengthIt->value.IsInt64()) { + result.emplaceWarning( + "The B3DM has a batch table, but it is being ignored because there is " + "no BATCH_LENGTH semantic in the feature table or it is not an " + "integer."); + return result; + } + + const int64_t batchLength = batchLengthIt->value.GetInt64(); + + convertBatchTableToGltfFeatureMetadataExtension( + batchTableJson, + batchTableBinaryData, + gltf, + batchLength, + result); // Create an EXT_feature_metadata extension for each primitive with a _BATCHID // attribute. @@ -1530,4 +1544,83 @@ ErrorList BatchTableToGltfFeatureMetadata::convert( return result; } + +ErrorList BatchTableToGltfFeatureMetadata::convertFromPnts( + const rapidjson::Document& featureTableJson, + const rapidjson::Document& batchTableJson, + const gsl::span& batchTableBinaryData, + CesiumGltf::Model& gltf) { + // Check to make sure a char of rapidjson is 1 byte + static_assert( + sizeof(rapidjson::Value::Ch) == 1, + "RapidJson::Value::Ch is not 1 byte"); + + ErrorList result; + + // Parse the pnts batch table and convert it to the EXT_feature_metadata + // extension. + + const auto pointsLengthIt = featureTableJson.FindMember("POINTS_LENGTH"); + if (pointsLengthIt == featureTableJson.MemberEnd() || + !pointsLengthIt->value.IsInt64()) { + result.emplaceError("The PNTS cannot be parsed because there is no valid " + "POINTS_LENGTH semantic."); + return result; + } + + int64_t featureCount = 0; + const auto batchLengthIt = featureTableJson.FindMember("BATCH_LENGTH"); + const auto batchIdIt = featureTableJson.FindMember("BATCH_ID"); + + // If the feature table is missing the BATCH_LENGTH semantic, the batch table + // corresponds to per-point properties. + if (batchLengthIt != featureTableJson.MemberEnd() && + batchLengthIt->value.IsInt64()) { + featureCount = batchLengthIt->value.GetInt64(); + } else if ( + batchIdIt != featureTableJson.MemberEnd() && + batchIdIt->value.IsObject()) { + result.emplaceWarning( + "The PNTS has a batch table, but it is being ignored because there " + "is no valid BATCH_LENGTH in the feature table even though BATCH_ID is " + "defined."); + return result; + } else { + featureCount = pointsLengthIt->value.GetInt64(); + } + + convertBatchTableToGltfFeatureMetadataExtension( + batchTableJson, + batchTableBinaryData, + gltf, + featureCount, + result); + + // Create the EXT_feature_metadata extension for the single mesh primitive. + assert(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + + assert(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + + ExtensionMeshPrimitiveExtFeatureMetadata& extension = + primitive.addExtension(); + FeatureIDAttribute& attribute = extension.featureIdAttributes.emplace_back(); + attribute.featureTable = "default"; + + auto primitiveBatchIdIt = primitive.attributes.find("_BATCHID"); + if (primitiveBatchIdIt != primitive.attributes.end()) { + // If _BATCHID is present, rename the _BATCHID attribute to _FEATURE_ID_0 + primitive.attributes["_FEATURE_ID_0"] = primitiveBatchIdIt->second; + primitive.attributes.erase("_BATCHID"); + attribute.featureIds.attribute = "_FEATURE_ID_0"; + } else { + // Otherwise, use implicit feature IDs to indicate the metadata is stored in + // per-point properties. + attribute.featureIds.constant = 0; + attribute.featureIds.divisor = 1; + } + + return result; +} } // namespace Cesium3DTilesSelection diff --git a/Cesium3DTilesSelection/src/BatchTableToGltfFeatureMetadata.h b/Cesium3DTilesSelection/src/BatchTableToGltfFeatureMetadata.h index 136118870..65008f913 100644 --- a/Cesium3DTilesSelection/src/BatchTableToGltfFeatureMetadata.h +++ b/Cesium3DTilesSelection/src/BatchTableToGltfFeatureMetadata.h @@ -10,7 +10,13 @@ namespace Cesium3DTilesSelection { struct BatchTableToGltfFeatureMetadata { - static ErrorList convert( + static ErrorList convertFromB3dm( + const rapidjson::Document& featureTableJson, + const rapidjson::Document& batchTableJson, + const gsl::span& batchTableBinaryData, + CesiumGltf::Model& gltf); + + static ErrorList convertFromPnts( const rapidjson::Document& featureTableJson, const rapidjson::Document& batchTableJson, const gsl::span& batchTableBinaryData, diff --git a/Cesium3DTilesSelection/src/PntsToGltfConverter.cpp b/Cesium3DTilesSelection/src/PntsToGltfConverter.cpp new file mode 100644 index 000000000..58592a941 --- /dev/null +++ b/Cesium3DTilesSelection/src/PntsToGltfConverter.cpp @@ -0,0 +1,1636 @@ +#include "PntsToGltfConverter.h" + +#include "BatchTableToGltfFeatureMetadata.h" + +#include +#include +#include +#include +#include +#include + +#ifdef _MSC_VER +#pragma warning(push) +#pragma warning(disable : 4127 4018 4804) +#endif + +#include +#include +#include +#include + +#ifdef _MSC_VER +#pragma warning(pop) +#endif + +#include +#include + +#include + +using namespace CesiumGltf; +using namespace CesiumUtility; + +namespace Cesium3DTilesSelection { +namespace { +struct PntsHeader { + unsigned char magic[4]; + uint32_t version; + uint32_t byteLength; + uint32_t featureTableJsonByteLength; + uint32_t featureTableBinaryByteLength; + uint32_t batchTableJsonByteLength; + uint32_t batchTableBinaryByteLength; +}; + +void parsePntsHeader( + const gsl::span& pntsBinary, + PntsHeader& header, + uint32_t& headerLength, + GltfConverterResult& result) { + if (pntsBinary.size() < sizeof(PntsHeader)) { + result.errors.emplaceError("The PNTS is invalid because it is too small to " + "include a PNTS header."); + return; + } + + const PntsHeader* pHeader = + reinterpret_cast(pntsBinary.data()); + + header = *pHeader; + headerLength = sizeof(PntsHeader); + + if (pHeader->version != 1) { + result.errors.emplaceError(fmt::format( + "The PNTS file is version {}, which is unsupported.", + pHeader->version)); + return; + } + + if (static_cast(pntsBinary.size()) < pHeader->byteLength) { + result.errors.emplaceError( + "The PNTS is invalid because the total data available is less than the " + "size specified in its header."); + return; + } +} + +struct MetadataProperty { +public: + enum ComponentType { + BYTE, + UNSIGNED_BYTE, + SHORT, + UNSIGNED_SHORT, + INT, + UNSIGNED_INT, + FLOAT, + DOUBLE + }; + + enum Type { SCALAR, VEC2, VEC3, VEC4 }; + + static std::optional + getComponentTypeFromDracoDataType(const draco::DataType dataType) { + switch (dataType) { + case draco::DT_INT8: + return ComponentType::BYTE; + case draco::DT_UINT8: + return ComponentType::UNSIGNED_BYTE; + case draco::DT_INT16: + return ComponentType::SHORT; + case draco::DT_UINT16: + return ComponentType::UNSIGNED_SHORT; + case draco::DT_INT32: + return ComponentType::INT; + case draco::DT_UINT32: + return ComponentType::UNSIGNED_INT; + case draco::DT_FLOAT32: + return ComponentType::FLOAT; + case draco::DT_FLOAT64: + return ComponentType::DOUBLE; + default: + return std::nullopt; + } + } + + static size_t getSizeOfComponentType(ComponentType componentType) { + switch (componentType) { + case ComponentType::BYTE: + case ComponentType::UNSIGNED_BYTE: + return sizeof(uint8_t); + case ComponentType::SHORT: + case ComponentType::UNSIGNED_SHORT: + return sizeof(uint16_t); + case ComponentType::INT: + case ComponentType::UNSIGNED_INT: + return sizeof(uint32_t); + case ComponentType::FLOAT: + return sizeof(float); + case ComponentType::DOUBLE: + return sizeof(double); + default: + return 0; + } + }; + + static std::optional + getTypeFromNumberOfComponents(int8_t numComponents) { + switch (numComponents) { + case 1: + return Type::SCALAR; + case 2: + return Type::VEC2; + case 3: + return Type::VEC3; + case 4: + return Type::VEC4; + default: + return std::nullopt; + } + } +}; + +const std::map + stringToMetadataComponentType{ + {"BYTE", MetadataProperty::ComponentType::BYTE}, + {"UNSIGNED_BYTE", MetadataProperty::ComponentType::UNSIGNED_BYTE}, + {"SHORT", MetadataProperty::ComponentType::SHORT}, + {"UNSIGNED_SHORT", MetadataProperty::ComponentType::UNSIGNED_SHORT}, + {"INT", MetadataProperty::ComponentType::INT}, + {"UNSIGNED_INT", MetadataProperty::ComponentType::UNSIGNED_INT}, + {"FLOAT", MetadataProperty::ComponentType::FLOAT}, + {"DOUBLE", MetadataProperty::ComponentType::DOUBLE}, + }; + +const std::map stringToMetadataType{ + {"SCALAR", MetadataProperty::Type::SCALAR}, + {"VEC2", MetadataProperty::Type::VEC2}, + {"VEC3", MetadataProperty::Type::VEC3}, + {"VEC4", MetadataProperty::Type::VEC4}}; + +struct PntsSemantic { + uint32_t byteOffset = 0; + std::optional dracoId; + std::vector data; +}; + +struct DracoMetadataSemantic { + int32_t dracoId; + MetadataProperty::ComponentType componentType; + MetadataProperty::Type type; +}; + +enum PntsColorType { CONSTANT, RGBA, RGB, RGB565 }; + +// Point cloud colors are stored in sRGB space, so they need to be converted to +// linear RGB for the glTF. +// This function assumes the sRGB values are normalized from [0, 255] to [0, 1] +template TColor srgbToLinear(const TColor srgb) { + static_assert( + std::is_same_v || std::is_same_v); + + glm::vec3 srgbInput = glm::vec3(srgb); + glm::vec3 linearOutput = glm::pow(srgbInput, glm::vec3(2.2f)); + + if constexpr (std::is_same_v) { + return glm::vec4(linearOutput, srgb.w); + } else if constexpr (std::is_same_v) { + return linearOutput; + } +} + +struct PntsContent { + uint32_t pointsLength = 0; + std::optional rtcCenter; + std::optional quantizedVolumeOffset; + std::optional quantizedVolumeScale; + std::optional constantRgba; + std::optional batchLength; + + PntsSemantic position; + bool positionQuantized = false; + // required by glTF spec + glm::vec3 positionMin = glm::vec3(std::numeric_limits::max()); + glm::vec3 positionMax = glm::vec3(std::numeric_limits::lowest()); + + std::optional color; + PntsColorType colorType = PntsColorType::CONSTANT; + + std::optional normal; + bool normalOctEncoded = false; + + std::optional batchId; + std::optional batchIdComponentType; + + std::optional dracoByteOffset; + std::optional dracoByteLength; + + std::map dracoMetadataSemantics; + std::vector dracoBatchTableBinary; + + Cesium3DTilesSelection::ErrorList errors; + bool dracoMetadataHasErrors = false; +}; + +template +bool validateJsonArrayValues( + const rapidjson::Value& arrayValue, + uint32_t expectedLength, + TValidate validate) { + if (!arrayValue.IsArray()) { + return false; + } + + if (arrayValue.Size() != expectedLength) { + return false; + } + + for (rapidjson::SizeType i = 0; i < expectedLength; i++) { + if (!validate(arrayValue[i])) { + return false; + } + } + + return true; +} + +void parsePositionsFromFeatureTableJson( + const rapidjson::Document& featureTableJson, + PntsContent& parsedContent) { + const auto positionIt = featureTableJson.FindMember("POSITION"); + if (positionIt != featureTableJson.MemberEnd() && + positionIt->value.IsObject()) { + const auto byteOffsetIt = positionIt->value.FindMember("byteOffset"); + if (byteOffsetIt == positionIt->value.MemberEnd() || + !byteOffsetIt->value.IsUint()) { + parsedContent.errors.emplaceError( + "Error parsing PNTS feature table, POSITION does not have " + "valid byteOffset."); + return; + } + parsedContent.position.byteOffset = byteOffsetIt->value.GetUint(); + return; + } + + const auto positionQuantizedIt = + featureTableJson.FindMember("POSITION_QUANTIZED"); + if (positionQuantizedIt != featureTableJson.MemberEnd() && + positionQuantizedIt->value.IsObject()) { + auto quantizedVolumeOffsetIt = + featureTableJson.FindMember("QUANTIZED_VOLUME_OFFSET"); + auto quantizedVolumeScaleIt = + featureTableJson.FindMember("QUANTIZED_VOLUME_SCALE"); + + auto isNumber = [](const rapidjson::Value& value) -> bool { + return value.IsNumber(); + }; + + if (quantizedVolumeOffsetIt == featureTableJson.MemberEnd() || + !validateJsonArrayValues(quantizedVolumeOffsetIt->value, 3, isNumber)) { + parsedContent.errors.emplaceError( + "Error parsing PNTS feature table, POSITION_QUANTIZED is used but " + "no valid QUANTIZED_VOLUME_OFFSET was found."); + return; + } + + if (quantizedVolumeScaleIt == featureTableJson.MemberEnd() || + !validateJsonArrayValues(quantizedVolumeScaleIt->value, 3, isNumber)) { + parsedContent.errors.emplaceError( + "Error parsing PNTS feature table, POSITION_QUANTIZED is used but " + "no valid QUANTIZED_VOLUME_SCALE was found."); + return; + } + + const auto byteOffsetIt = + positionQuantizedIt->value.FindMember("byteOffset"); + if (byteOffsetIt == positionQuantizedIt->value.MemberEnd() || + !byteOffsetIt->value.IsUint()) { + parsedContent.errors.emplaceError( + "Error parsing PNTS feature table, POSITION_QUANTIZED does not have " + "valid byteOffset."); + return; + } + + parsedContent.positionQuantized = true; + parsedContent.position.byteOffset = byteOffsetIt->value.GetUint(); + + auto quantizedVolumeOffset = quantizedVolumeOffsetIt->value.GetArray(); + auto quantizedVolumeScale = quantizedVolumeScaleIt->value.GetArray(); + + parsedContent.quantizedVolumeOffset = glm::dvec3( + quantizedVolumeOffset[0].GetDouble(), + quantizedVolumeOffset[1].GetDouble(), + quantizedVolumeOffset[2].GetDouble()); + + parsedContent.quantizedVolumeScale = glm::dvec3( + quantizedVolumeScale[0].GetDouble(), + quantizedVolumeScale[1].GetDouble(), + quantizedVolumeScale[2].GetDouble()); + + return; + } + + parsedContent.errors.emplaceError( + "Error parsing PNTS feature table, one of POSITION or POSITION_QUANTIZED " + "must be defined."); + + return; +} + +void parseColorsFromFeatureTableJson( + const rapidjson::Document& featureTableJson, + PntsContent& parsedContent) { + const auto rgbaIt = featureTableJson.FindMember("RGBA"); + if (rgbaIt != featureTableJson.MemberEnd() && rgbaIt->value.IsObject()) { + const auto byteOffsetIt = rgbaIt->value.FindMember("byteOffset"); + if (byteOffsetIt != rgbaIt->value.MemberEnd() && + byteOffsetIt->value.IsUint()) { + parsedContent.color = std::make_optional(); + parsedContent.color.value().byteOffset = byteOffsetIt->value.GetUint(); + parsedContent.colorType = PntsColorType::RGBA; + return; + } + parsedContent.errors.emplaceWarning( + "Error parsing PNTS feature table, RGBA does not have valid " + "byteOffset. Skip parsing RGBA colors."); + } + + const auto rgbIt = featureTableJson.FindMember("RGB"); + if (rgbIt != featureTableJson.MemberEnd() && rgbIt->value.IsObject()) { + const auto byteOffsetIt = rgbIt->value.FindMember("byteOffset"); + if (byteOffsetIt != rgbIt->value.MemberEnd() && + byteOffsetIt->value.IsUint()) { + parsedContent.color = std::make_optional(); + parsedContent.color.value().byteOffset = byteOffsetIt->value.GetUint(); + parsedContent.colorType = PntsColorType::RGB; + return; + } + parsedContent.errors.emplaceWarning( + "Error parsing PNTS feature table, RGB does not have valid byteOffset. " + "Skip parsing RGB colors."); + } + + const auto rgb565It = featureTableJson.FindMember("RGB565"); + if (rgb565It != featureTableJson.MemberEnd() && rgb565It->value.IsObject()) { + const auto byteOffsetIt = rgb565It->value.FindMember("byteOffset"); + if (byteOffsetIt != rgb565It->value.MemberEnd() && + byteOffsetIt->value.IsUint()) { + parsedContent.color = std::make_optional(); + parsedContent.color.value().byteOffset = byteOffsetIt->value.GetUint(); + parsedContent.colorType = PntsColorType::RGB565; + return; + } + + parsedContent.errors.emplaceWarning( + "Error parsing PNTS feature table, RGB565 does not have valid " + "byteOffset. Skip parsing RGB565 colors."); + } + + auto isUint = [](const rapidjson::Value& value) -> bool { + return value.IsUint(); + }; + + const auto constantRgbaIt = featureTableJson.FindMember("CONSTANT_RGBA"); + if (constantRgbaIt != featureTableJson.MemberEnd() && + validateJsonArrayValues(constantRgbaIt->value, 4, isUint)) { + const rapidjson::Value& arrayValue = constantRgbaIt->value; + parsedContent.constantRgba = glm::u8vec4( + arrayValue[0].GetUint(), + arrayValue[1].GetUint(), + arrayValue[2].GetUint(), + arrayValue[3].GetUint()); + } +} + +void parseNormalsFromFeatureTableJson( + const rapidjson::Document& featureTableJson, + PntsContent& parsedContent) { + const auto normalIt = featureTableJson.FindMember("NORMAL"); + if (normalIt != featureTableJson.MemberEnd() && normalIt->value.IsObject()) { + const auto byteOffsetIt = normalIt->value.FindMember("byteOffset"); + if (byteOffsetIt != normalIt->value.MemberEnd() && + byteOffsetIt->value.IsUint()) { + parsedContent.normal = std::make_optional(); + parsedContent.normal.value().byteOffset = byteOffsetIt->value.GetUint(); + return; + } + + parsedContent.errors.emplaceWarning( + "Error parsing PNTS feature table, NORMAL does not have valid " + "byteOffset. Skip parsing normals."); + } + + const auto normalOct16pIt = featureTableJson.FindMember("NORMAL_OCT16P"); + if (normalOct16pIt != featureTableJson.MemberEnd() && + normalOct16pIt->value.IsObject()) { + const auto byteOffsetIt = normalOct16pIt->value.FindMember("byteOffset"); + if (byteOffsetIt != normalOct16pIt->value.MemberEnd() && + byteOffsetIt->value.IsUint()) { + parsedContent.normal = std::make_optional(); + parsedContent.normal.value().byteOffset = byteOffsetIt->value.GetUint(); + parsedContent.normalOctEncoded = true; + return; + } + + parsedContent.errors.emplaceWarning( + "Error parsing PNTS feature table, NORMAL_OCT16P does not have valid " + "byteOffset. Skip parsing oct-encoded normals."); + } +} + +void parseBatchIdsFromFeatureTableJson( + const rapidjson::Document& featureTableJson, + PntsContent& parsedContent) { + const auto batchIdIt = featureTableJson.FindMember("BATCH_ID"); + if (batchIdIt == featureTableJson.MemberEnd() || + !batchIdIt->value.IsObject()) { + return; + } + + const auto byteOffsetIt = batchIdIt->value.FindMember("byteOffset"); + if (byteOffsetIt == batchIdIt->value.MemberEnd() || + !byteOffsetIt->value.IsUint()) { + parsedContent.errors.emplaceWarning( + "Error parsing PNTS feature table, BATCH_ID semantic does not have " + "valid byteOffset. Skip parsing batch IDs."); + return; + } + + parsedContent.batchId = std::make_optional(); + PntsSemantic& batchId = parsedContent.batchId.value(); + batchId.byteOffset = byteOffsetIt->value.GetUint(); + + const auto componentTypeIt = batchIdIt->value.FindMember("componentType"); + if (componentTypeIt != featureTableJson.MemberEnd() && + componentTypeIt->value.IsString()) { + const std::string& componentTypeString = componentTypeIt->value.GetString(); + if (stringToMetadataComponentType.find(componentTypeString) == + stringToMetadataComponentType.end()) { + parsedContent.errors.emplaceWarning( + "Error parsing PNTS feature table, BATCH_ID does not have " + "valid componentType. Skip parsing batch IDs."); + return; + } + + MetadataProperty::ComponentType componentType = + stringToMetadataComponentType.at(componentTypeString); + if (componentType != MetadataProperty::ComponentType::UNSIGNED_BYTE && + componentType != MetadataProperty::ComponentType::UNSIGNED_SHORT && + componentType != MetadataProperty::ComponentType::UNSIGNED_INT) { + parsedContent.errors.emplaceWarning( + "Error parsing PNTS feature table, BATCH_ID componentType is defined " + "but is not UNSIGNED_BYTE, UNSIGNED_SHORT, or UNSIGNED_INT. Skip " + "parsing batch IDs."); + return; + } + parsedContent.batchIdComponentType = componentType; + } else { + parsedContent.batchIdComponentType = + MetadataProperty::ComponentType::UNSIGNED_SHORT; + } + + const auto batchLengthIt = featureTableJson.FindMember("BATCH_LENGTH"); + if (batchLengthIt != featureTableJson.MemberEnd() && + batchLengthIt->value.IsUint()) { + parsedContent.batchLength = batchLengthIt->value.GetUint(); + } +} + +void parseSemanticsFromFeatureTableJson( + const rapidjson::Document& featureTableJson, + PntsContent& parsedContent) { + parsePositionsFromFeatureTableJson(featureTableJson, parsedContent); + if (parsedContent.errors) { + return; + } + + parseColorsFromFeatureTableJson(featureTableJson, parsedContent); + if (parsedContent.errors) { + return; + } + + parseNormalsFromFeatureTableJson(featureTableJson, parsedContent); + if (parsedContent.errors) { + return; + } + + parseBatchIdsFromFeatureTableJson(featureTableJson, parsedContent); + if (parsedContent.errors) { + return; + } + + auto isNumber = [](const rapidjson::Value& value) -> bool { + return value.IsNumber(); + }; + + const auto rtcIt = featureTableJson.FindMember("RTC_CENTER"); + if (rtcIt != featureTableJson.MemberEnd() && + validateJsonArrayValues(rtcIt->value, 3, isNumber)) { + const rapidjson::Value& rtcValue = rtcIt->value; + parsedContent.rtcCenter = glm::vec3( + rtcValue[0].GetDouble(), + rtcValue[1].GetDouble(), + rtcValue[2].GetDouble()); + } +} + +void parseDracoExtensionFromFeatureTableJson( + const rapidjson::Value& dracoExtension, + PntsContent& parsedContent) { + const auto propertiesIt = dracoExtension.FindMember("properties"); + if (propertiesIt == dracoExtension.MemberEnd() || + !propertiesIt->value.IsObject()) { + parsedContent.errors.emplaceError( + "Error parsing 3DTILES_draco_compression extension, " + "no valid properties object found."); + return; + } + + const auto byteOffsetIt = dracoExtension.FindMember("byteOffset"); + if (byteOffsetIt == dracoExtension.MemberEnd() || + !byteOffsetIt->value.IsUint64()) { + parsedContent.errors.emplaceError( + "Error parsing 3DTILES_draco_compression extension, " + "no valid byteOffset found."); + return; + } + + const auto byteLengthIt = dracoExtension.FindMember("byteLength"); + if (byteLengthIt == dracoExtension.MemberEnd() || + !byteLengthIt->value.IsUint64()) { + parsedContent.errors.emplaceError( + "Error parsing 3DTILES_draco_compression extension, " + "no valid byteLength found."); + return; + } + + parsedContent.dracoByteOffset = byteOffsetIt->value.GetUint(); + parsedContent.dracoByteLength = byteLengthIt->value.GetUint(); + + const rapidjson::Value& dracoProperties = propertiesIt->value; + auto positionDracoIdIt = dracoProperties.FindMember("POSITION"); + if (positionDracoIdIt != dracoProperties.MemberEnd() && + positionDracoIdIt->value.IsInt()) { + parsedContent.position.dracoId = positionDracoIdIt->value.GetInt(); + } + + if (parsedContent.color) { + if (parsedContent.colorType == PntsColorType::RGBA) { + auto rgbaDracoIdIt = dracoProperties.FindMember("RGBA"); + if (rgbaDracoIdIt != dracoProperties.MemberEnd() && + rgbaDracoIdIt->value.IsInt()) { + parsedContent.color.value().dracoId = rgbaDracoIdIt->value.GetInt(); + } + } else if (parsedContent.colorType == PntsColorType::RGB) { + auto rgbDracoIdIt = dracoProperties.FindMember("RGB"); + if (rgbDracoIdIt != dracoProperties.MemberEnd() && + rgbDracoIdIt->value.IsInt()) { + parsedContent.color.value().dracoId = rgbDracoIdIt->value.GetInt(); + } + } + } + + if (parsedContent.normal) { + auto normalDracoIdIt = dracoProperties.FindMember("NORMAL"); + if (normalDracoIdIt != dracoProperties.MemberEnd() && + normalDracoIdIt->value.IsInt()) { + parsedContent.normal.value().dracoId = normalDracoIdIt->value.GetInt(); + } + } + + if (parsedContent.batchId) { + auto batchIdDracoIdIt = dracoProperties.FindMember("BATCH_ID"); + if (batchIdDracoIdIt != dracoProperties.MemberEnd() && + batchIdDracoIdIt->value.IsInt()) { + parsedContent.batchId.value().dracoId = batchIdDracoIdIt->value.GetInt(); + } + } +} + +rapidjson::Document parseFeatureTableJson( + const gsl::span& featureTableJsonData, + PntsContent& parsedContent) { + rapidjson::Document document; + document.Parse( + reinterpret_cast(featureTableJsonData.data()), + featureTableJsonData.size()); + if (document.HasParseError()) { + parsedContent.errors.emplaceError(fmt::format( + "Error when parsing feature table JSON, error code {} at byte offset " + "{}", + document.GetParseError(), + document.GetErrorOffset())); + return document; + } + + const auto pointsLengthIt = document.FindMember("POINTS_LENGTH"); + if (pointsLengthIt == document.MemberEnd() || + !pointsLengthIt->value.IsUint()) { + parsedContent.errors.emplaceError( + "Error parsing PNTS feature table, no valid POINTS_LENGTH was found."); + return document; + } + parsedContent.pointsLength = pointsLengthIt->value.GetUint(); + + if (parsedContent.pointsLength == 0) { + // This *should* be disallowed by the spec, but it currently isn't. + // In the future, this can be converted to an error. + parsedContent.errors.emplaceWarning("The PNTS has a POINTS_LENGTH of zero. " + "Skip parsing the PNTS feature table."); + return document; + } + + parseSemanticsFromFeatureTableJson(document, parsedContent); + if (parsedContent.errors) { + return document; + } + + const auto extensionsIt = document.FindMember("extensions"); + if (extensionsIt != document.MemberEnd() && extensionsIt->value.IsObject()) { + const auto dracoExtensionIt = + extensionsIt->value.FindMember("3DTILES_draco_point_compression"); + if (dracoExtensionIt != extensionsIt->value.MemberEnd() && + dracoExtensionIt->value.IsObject()) { + const rapidjson::Value& dracoExtension = dracoExtensionIt->value; + parseDracoExtensionFromFeatureTableJson(dracoExtension, parsedContent); + } + } + return document; +} + +void parseDracoExtensionFromBatchTableJson( + const rapidjson::Document& batchTableJson, + PntsContent& parsedContent) { + const auto extensionsIt = batchTableJson.FindMember("extensions"); + if (extensionsIt == batchTableJson.MemberEnd() || + !extensionsIt->value.IsObject()) { + return; + } + + const auto dracoExtensionIt = + extensionsIt->value.FindMember("3DTILES_draco_point_compression"); + if (dracoExtensionIt == extensionsIt->value.MemberEnd() || + !dracoExtensionIt->value.IsObject()) { + return; + } + + if (parsedContent.batchLength) { + parsedContent.errors.emplaceWarning( + "Error parsing batch table, 3DTILES_draco_point_compression is present " + "but BATCH_LENGTH is defined. Only per-point properties can be " + "compressed by Draco."); + parsedContent.dracoMetadataHasErrors = true; + return; + } + + const auto propertiesIt = dracoExtensionIt->value.FindMember("properties"); + if (propertiesIt == dracoExtensionIt->value.MemberEnd() || + !propertiesIt->value.IsObject()) { + parsedContent.errors.emplaceWarning( + "Error parsing 3DTILES_draco_point_compression extension, no " + "properties object was found."); + parsedContent.dracoMetadataHasErrors = true; + return; + } + + const rapidjson::Value& properties = propertiesIt->value.GetObject(); + for (auto dracoPropertyIt = properties.MemberBegin(); + dracoPropertyIt != properties.MemberEnd(); + ++dracoPropertyIt) { + const std::string name = dracoPropertyIt->name.GetString(); + + // If there are issues with the extension, skip parsing metadata altogether. + // Otherwise, BatchTableToGltfFeatureMetadata will still try to parse the + // invalid Draco-compressed properties. + auto batchTablePropertyIt = batchTableJson.FindMember(name.c_str()); + if (batchTablePropertyIt == batchTableJson.MemberEnd() || + !batchTablePropertyIt->value.IsObject()) { + parsedContent.errors.emplaceWarning(fmt::format( + "The metadata property {} is in the 3DTILES_draco_point_compression " + "extension but not in the batch table itself.", + name)); + continue; + } + + if (!dracoPropertyIt->value.IsInt()) { + parsedContent.errors.emplaceWarning(fmt::format( + "Error parsing 3DTILES_draco_compression extension, the metadata " + "property {} does not have valid draco ID. Skip parsing metadata.", + name)); + parsedContent.dracoMetadataHasErrors = true; + return; + } + + // If the batch table binary property is invalid, it will also be ignored by + // BatchTableToGltfFeatureMetadata, so it's fine to continue parsing the + // other properties. + const rapidjson::Value& batchTableProperty = batchTablePropertyIt->value; + auto byteOffsetIt = batchTableProperty.FindMember("byteOffset"); + if (byteOffsetIt == batchTableProperty.MemberEnd() || + !byteOffsetIt->value.IsUint()) { + parsedContent.errors.emplaceWarning(fmt::format( + "Skip decoding Draco-compressed property {}. The binary property " + "doesn't have a valid byteOffset.", + name)); + continue; + } + + std::string componentType; + auto componentTypeIt = batchTableProperty.FindMember("componentType"); + if (componentTypeIt != batchTableProperty.MemberEnd() && + componentTypeIt->value.IsString()) { + componentType = componentTypeIt->value.GetString(); + } + if (stringToMetadataComponentType.find(componentType) == + stringToMetadataComponentType.end()) { + parsedContent.errors.emplaceWarning(fmt::format( + "Skip decoding Draco-compressed property {}. The binary property " + "doesn't have a valid componentType.", + name)); + continue; + } + + std::string type; + auto typeIt = batchTableProperty.FindMember("type"); + if (typeIt != batchTableProperty.MemberEnd() && typeIt->value.IsString()) { + type = typeIt->value.GetString(); + } + if (stringToMetadataType.find(type) == stringToMetadataType.end()) { + parsedContent.errors.emplaceWarning(fmt::format( + "Skip decoding Draco-compressed property {}. The binary property " + "doesn't have a valid type.", + name)); + continue; + } + + DracoMetadataSemantic semantic; + semantic.dracoId = dracoPropertyIt->value.GetInt(); + semantic.componentType = stringToMetadataComponentType.at(componentType); + semantic.type = stringToMetadataType.at(type); + + parsedContent.dracoMetadataSemantics.insert({name, semantic}); + } +} + +rapidjson::Document parseBatchTableJson( + const gsl::span& batchTableJsonData, + PntsContent& parsedContent) { + rapidjson::Document document; + document.Parse( + reinterpret_cast(batchTableJsonData.data()), + batchTableJsonData.size()); + if (document.HasParseError()) { + parsedContent.errors.emplaceWarning(fmt::format( + "Error when parsing batch table JSON, error code {} at byte " + "offset " + "{}. Skip parsing metadata", + document.GetParseError(), + document.GetErrorOffset())); + return document; + } + + parseDracoExtensionFromBatchTableJson(document, parsedContent); + + return document; +} + +bool validateDracoAttribute( + const draco::PointAttribute* const pAttribute, + const draco::DataType expectedDataType, + const int32_t expectedNumComponents) { + return pAttribute && pAttribute->data_type() == expectedDataType && + pAttribute->num_components() == expectedNumComponents; +} + +bool validateDracoMetadataAttribute( + const draco::PointAttribute* const pAttribute, + const DracoMetadataSemantic semantic) { + if (!pAttribute) { + return false; + } + + auto componentType = MetadataProperty::getComponentTypeFromDracoDataType( + pAttribute->data_type()); + if (!componentType || componentType.value() != semantic.componentType) { + return false; + } + + auto type = MetadataProperty::getTypeFromNumberOfComponents( + pAttribute->num_components()); + return type && type.value() == semantic.type; +} + +template +void getDracoData( + const draco::PointAttribute* pAttribute, + std::vector& data, + const uint32_t pointsLength) { + const size_t dataElementSize = sizeof(T); + const size_t databufferByteLength = pointsLength * dataElementSize; + data.resize(databufferByteLength); + + draco::DataBuffer* decodedBuffer = pAttribute->buffer(); + int64_t decodedByteOffset = pAttribute->byte_offset(); + int64_t decodedByteStride = pAttribute->byte_stride(); + + const uint8_t* pSource = decodedBuffer->data() + decodedByteOffset; + if (dataElementSize != static_cast(decodedByteStride)) { + gsl::span outData(reinterpret_cast(data.data()), pointsLength); + for (uint32_t i = 0; i < pointsLength; ++i) { + outData[i] = *reinterpret_cast(pSource + decodedByteStride * i); + } + } else { + std::memcpy( + data.data(), + decodedBuffer->data() + decodedByteOffset, + databufferByteLength); + } +} + +void decodeDracoMetadata( + const std::unique_ptr& pPointCloud, + rapidjson::Document& batchTableJson, + PntsContent& parsedContent) { + const uint64_t pointsLength = parsedContent.pointsLength; + std::vector& data = parsedContent.dracoBatchTableBinary; + + const auto& dracoMetadataSemantics = parsedContent.dracoMetadataSemantics; + for (auto dracoSemanticIt = dracoMetadataSemantics.begin(); + dracoSemanticIt != dracoMetadataSemantics.end(); + dracoSemanticIt++) { + DracoMetadataSemantic dracoSemantic = dracoSemanticIt->second; + draco::PointAttribute* pAttribute = + pPointCloud->attribute(dracoSemantic.dracoId); + if (!validateDracoMetadataAttribute(pAttribute, dracoSemantic)) { + parsedContent.errors.emplaceWarning(fmt::format( + "Error decoding {} property in the 3DTILES_draco_compression " + "extension. Skip parsing metadata.", + dracoSemanticIt->first)); + parsedContent.dracoMetadataHasErrors = true; + return; + } + + const size_t byteOffset = data.size(); + + // These do not test for validity since the batch table and extension + // were validated in parseDracoExtensionFromBatchTableJson. + auto batchTableSemanticIt = + batchTableJson.FindMember(dracoSemanticIt->first.c_str()); + rapidjson::Value& batchTableSemantic = + batchTableSemanticIt->value.GetObject(); + auto byteOffsetIt = batchTableSemantic.FindMember("byteOffset"); + byteOffsetIt->value.SetUint(static_cast(byteOffset)); + + const size_t metadataByteStride = + MetadataProperty::getSizeOfComponentType(dracoSemantic.componentType) * + static_cast(pAttribute->num_components()); + + data.resize(byteOffset + pointsLength * metadataByteStride); + draco::DataBuffer* decodedBuffer = pAttribute->buffer(); + int64_t decodedByteOffset = pAttribute->byte_offset(); + int64_t decodedByteStride = pAttribute->byte_stride(); + + if (metadataByteStride != static_cast(decodedByteStride)) { + for (uint32_t i = 0; i < pointsLength; ++i) { + std::memcpy( + data.data() + byteOffset + i * metadataByteStride, + decodedBuffer->data() + decodedByteOffset + decodedByteStride * i, + metadataByteStride); + } + } else { + std::memcpy( + data.data() + byteOffset, + decodedBuffer->data() + decodedByteOffset, + metadataByteStride * pointsLength); + } + } +} + +void decodeDraco( + const gsl::span& featureTableBinaryData, + rapidjson::Document& batchTableJson, + const gsl::span& batchTableBinaryData, + PntsContent& parsedContent) { + if (!parsedContent.dracoByteOffset || !parsedContent.dracoByteLength) { + return; + } + + draco::Decoder decoder; + draco::DecoderBuffer buffer; + buffer.Init( + (char*)featureTableBinaryData.data() + + parsedContent.dracoByteOffset.value(), + parsedContent.dracoByteLength.value()); + + draco::StatusOr> dracoResult = + decoder.DecodePointCloudFromBuffer(&buffer); + + if (!dracoResult.ok()) { + parsedContent.errors.emplaceError("Error decoding Draco point cloud."); + return; + } + + const std::unique_ptr& pPointCloud = dracoResult.value(); + const uint32_t pointsLength = parsedContent.pointsLength; + + if (parsedContent.position.dracoId) { + draco::PointAttribute* pPositionAttribute = + pPointCloud->attribute(parsedContent.position.dracoId.value()); + if (!validateDracoAttribute(pPositionAttribute, draco::DT_FLOAT32, 3)) { + parsedContent.errors.emplaceError( + "Error with decoded Draco point cloud, no valid position " + "attribute."); + return; + } + + std::vector& positionData = parsedContent.position.data; + positionData.resize(pointsLength * sizeof(glm::vec3)); + + gsl::span outPositions( + reinterpret_cast(positionData.data()), + pointsLength); + + draco::DataBuffer* decodedBuffer = pPositionAttribute->buffer(); + int64_t decodedByteOffset = pPositionAttribute->byte_offset(); + int64_t decodedByteStride = pPositionAttribute->byte_stride(); + + for (uint32_t i = 0; i < pointsLength; ++i) { + const glm::vec3 position = *reinterpret_cast( + decodedBuffer->data() + decodedByteOffset + decodedByteStride * i); + outPositions[i] = position; + + parsedContent.positionMin = glm::min(position, parsedContent.positionMin); + parsedContent.positionMax = glm::max(position, parsedContent.positionMax); + } + } + + if (parsedContent.color) { + PntsSemantic& color = parsedContent.color.value(); + if (color.dracoId) { + draco::PointAttribute* pColorAttribute = + pPointCloud->attribute(color.dracoId.value()); + std::vector& colorData = parsedContent.color->data; + if (parsedContent.colorType == PntsColorType::RGBA && + validateDracoAttribute(pColorAttribute, draco::DT_UINT8, 4)) { + colorData.resize(pointsLength * sizeof(glm::vec4)); + + gsl::span outColors( + reinterpret_cast(colorData.data()), + pointsLength); + + draco::DataBuffer* decodedBuffer = pColorAttribute->buffer(); + int64_t decodedByteOffset = pColorAttribute->byte_offset(); + int64_t decodedByteStride = pColorAttribute->byte_stride(); + + for (uint32_t i = 0; i < pointsLength; ++i) { + const glm::u8vec4 rgbaColor = *reinterpret_cast( + decodedBuffer->data() + decodedByteOffset + + decodedByteStride * i); + outColors[i] = srgbToLinear(glm::vec4(rgbaColor) / 255.0f); + } + } else if ( + parsedContent.colorType == PntsColorType::RGB && + validateDracoAttribute(pColorAttribute, draco::DT_UINT8, 3)) { + colorData.resize(pointsLength * sizeof(glm::vec3)); + + gsl::span outColors( + reinterpret_cast(colorData.data()), + pointsLength); + + draco::DataBuffer* decodedBuffer = pColorAttribute->buffer(); + int64_t decodedByteOffset = pColorAttribute->byte_offset(); + int64_t decodedByteStride = pColorAttribute->byte_stride(); + + for (uint32_t i = 0; i < pointsLength; ++i) { + const glm::u8vec3 rgbColor = *reinterpret_cast( + decodedBuffer->data() + decodedByteOffset + + decodedByteStride * i); + outColors[i] = srgbToLinear(glm::vec3(rgbColor) / 255.0f); + } + } else { + parsedContent.errors.emplaceWarning( + "Error parsing decoded Draco point cloud, invalid color attribute. " + "Skip parsing colors."); + parsedContent.color = std::nullopt; + parsedContent.colorType = PntsColorType::CONSTANT; + } + } + } + + if (parsedContent.normal) { + PntsSemantic& normal = parsedContent.normal.value(); + if (normal.dracoId) { + draco::PointAttribute* pNormalAttribute = + pPointCloud->attribute(normal.dracoId.value()); + if (validateDracoAttribute(pNormalAttribute, draco::DT_FLOAT32, 3)) { + getDracoData(pNormalAttribute, normal.data, pointsLength); + } else { + parsedContent.errors.emplaceWarning("Error parsing decoded Draco point " + "cloud, invalid normal attribute. " + "Skip parsing normals."); + parsedContent.normal = std::nullopt; + } + } + } + + if (parsedContent.batchId) { + PntsSemantic& batchId = parsedContent.batchId.value(); + if (batchId.dracoId) { + draco::PointAttribute* pBatchIdAttribute = + pPointCloud->attribute(batchId.dracoId.value()); + + int32_t componentType = 0; + if (parsedContent.batchIdComponentType) { + componentType = parsedContent.batchIdComponentType.value(); + } + + if (componentType == MetadataProperty::ComponentType::UNSIGNED_BYTE && + validateDracoAttribute(pBatchIdAttribute, draco::DT_UINT8, 1)) { + getDracoData(pBatchIdAttribute, batchId.data, pointsLength); + } else if ( + componentType == MetadataProperty::ComponentType::UNSIGNED_INT && + validateDracoAttribute(pBatchIdAttribute, draco::DT_UINT32, 1)) { + getDracoData(pBatchIdAttribute, batchId.data, pointsLength); + } else if ( + (componentType == 0 || + componentType == MetadataProperty::ComponentType::UNSIGNED_SHORT) && + validateDracoAttribute(pBatchIdAttribute, draco::DT_UINT16, 1)) { + getDracoData(pBatchIdAttribute, batchId.data, pointsLength); + } else { + parsedContent.errors.emplaceWarning( + "Error parsing decoded Draco point cloud, invalid batch ID " + "attribute. " + "Skip parsing batch IDs."); + parsedContent.batchId = std::nullopt; + } + } + } + + if (batchTableJson.HasParseError() || parsedContent.dracoMetadataHasErrors) { + return; + } + + // Not all metadata attributes are necessarily compressed. Copy the binary of + // the uncompressed attributes first, before appending the decoded data. + size_t batchTableBinaryByteLength = batchTableBinaryData.size(); + if (batchTableBinaryByteLength > 0) { + parsedContent.dracoBatchTableBinary.resize(batchTableBinaryByteLength); + std::memcpy( + parsedContent.dracoBatchTableBinary.data(), + batchTableBinaryData.data(), + batchTableBinaryByteLength); + } + + decodeDracoMetadata(pPointCloud, batchTableJson, parsedContent); +} + +void parsePositionsFromFeatureTableBinary( + const gsl::span& featureTableBinaryData, + PntsContent& parsedContent) { + std::vector& positionData = parsedContent.position.data; + if (positionData.size() > 0) { + // If data isn't empty, it must have been decoded from Draco. + return; + } + + const uint32_t pointsLength = parsedContent.pointsLength; + const size_t positionsByteStride = sizeof(glm::vec3); + const size_t positionsByteLength = pointsLength * positionsByteStride; + positionData.resize(positionsByteLength); + + gsl::span outPositions( + reinterpret_cast(positionData.data()), + pointsLength); + + if (parsedContent.positionQuantized) { + // PERFORMANCE_IDEA: In the future, it might be more performant to detect + // if the recipient engine can handle dequantization itself, and if so, use + // the KHR_mesh_quantization extension to avoid dequantizing here. + const gsl::span quantizedPositions( + reinterpret_cast( + featureTableBinaryData.data() + parsedContent.position.byteOffset), + pointsLength); + + const glm::vec3 quantizedVolumeScale( + parsedContent.quantizedVolumeScale.value()); + const glm::vec3 quantizedVolumeOffset( + parsedContent.quantizedVolumeOffset.value()); + + const glm::vec3 quantizedPositionScalar = quantizedVolumeScale / 65535.0f; + + for (size_t i = 0; i < pointsLength; i++) { + const glm::vec3 quantizedPosition( + quantizedPositions[i].x, + quantizedPositions[i].y, + quantizedPositions[i].z); + + const glm::vec3 dequantizedPosition = + quantizedPosition * quantizedPositionScalar + quantizedVolumeOffset; + outPositions[i] = dequantizedPosition; + parsedContent.positionMin = + glm::min(parsedContent.positionMin, dequantizedPosition); + parsedContent.positionMax = + glm::max(parsedContent.positionMax, dequantizedPosition); + } + } else { + // The position accessor min / max is required by the glTF spec, so + // use a for loop instead of std::memcpy. + const gsl::span positions( + reinterpret_cast( + featureTableBinaryData.data() + parsedContent.position.byteOffset), + pointsLength); + for (size_t i = 0; i < pointsLength; i++) { + const glm::vec3 position = positions[i]; + outPositions[i] = position; + parsedContent.positionMin = glm::min(parsedContent.positionMin, position); + parsedContent.positionMax = glm::max(parsedContent.positionMax, position); + } + } +} + +void parseColorsFromFeatureTableBinary( + const gsl::span& featureTableBinaryData, + PntsContent& parsedContent) { + PntsSemantic& color = parsedContent.color.value(); + std::vector& colorData = color.data; + if (colorData.size() > 0) { + // If data isn't empty, it must have been decoded from Draco. + return; + } + + const uint32_t pointsLength = parsedContent.pointsLength; + const size_t colorsByteStride = parsedContent.colorType == PntsColorType::RGBA + ? sizeof(glm::vec4) + : sizeof(glm::vec3); + const size_t colorsByteLength = pointsLength * colorsByteStride; + colorData.resize(colorsByteLength); + + if (parsedContent.colorType == PntsColorType::RGBA) { + const gsl::span rgbaColors( + reinterpret_cast( + featureTableBinaryData.data() + color.byteOffset), + pointsLength); + gsl::span outColors( + reinterpret_cast(colorData.data()), + pointsLength); + + for (size_t i = 0; i < pointsLength; i++) { + glm::vec4 normalizedColor = glm::vec4(rgbaColors[i]) / 255.0f; + outColors[i] = srgbToLinear(normalizedColor); + } + } else if (parsedContent.colorType == PntsColorType::RGB) { + const gsl::span rgbColors( + reinterpret_cast( + featureTableBinaryData.data() + color.byteOffset), + pointsLength); + gsl::span outColors( + reinterpret_cast(colorData.data()), + pointsLength); + + for (size_t i = 0; i < pointsLength; i++) { + glm::vec3 normalizedColor = glm::vec3(rgbColors[i]) / 255.0f; + outColors[i] = srgbToLinear(normalizedColor); + } + } else if (parsedContent.colorType == PntsColorType::RGB565) { + + const gsl::span compressedColors( + reinterpret_cast( + featureTableBinaryData.data() + color.byteOffset), + pointsLength); + gsl::span outColors( + reinterpret_cast(colorData.data()), + pointsLength); + + for (size_t i = 0; i < pointsLength; i++) { + const uint16_t compressedColor = compressedColors[i]; + glm::vec3 decompressedColor = + glm::vec3(AttributeCompression::decodeRGB565(compressedColor)); + outColors[i] = srgbToLinear(decompressedColor); + } + } +} + +void parseNormalsFromFeatureTableBinary( + const gsl::span& featureTableBinaryData, + PntsContent& parsedContent) { + PntsSemantic& normal = parsedContent.normal.value(); + std::vector& normalData = normal.data; + if (normalData.size() > 0) { + // If data isn't empty, it must have been decoded from Draco. + return; + } + + const uint32_t pointsLength = parsedContent.pointsLength; + const size_t normalsByteStride = sizeof(glm::vec3); + const size_t normalsByteLength = pointsLength * normalsByteStride; + normalData.resize(normalsByteLength); + + if (parsedContent.normalOctEncoded) { + const gsl::span encodedNormals( + reinterpret_cast( + featureTableBinaryData.data() + normal.byteOffset), + pointsLength); + + gsl::span outNormals( + reinterpret_cast(normalData.data()), + pointsLength); + + for (size_t i = 0; i < pointsLength; i++) { + const glm::u8vec2 encodedNormal = encodedNormals[i]; + outNormals[i] = glm::vec3(CesiumUtility::AttributeCompression::octDecode( + encodedNormal.x, + encodedNormal.y)); + } + } else { + std::memcpy( + normalData.data(), + featureTableBinaryData.data() + normal.byteOffset, + normalsByteLength); + } +} + +void parseBatchIdsFromFeatureTableBinary( + const gsl::span& featureTableBinaryData, + PntsContent& parsedContent) { + PntsSemantic& batchId = parsedContent.batchId.value(); + std::vector& batchIdData = batchId.data; + if (batchIdData.size() > 0) { + // If data isn't empty, it must have been decoded from Draco. + return; + } + + const uint32_t pointsLength = parsedContent.pointsLength; + size_t batchIdsByteStride = sizeof(uint16_t); + if (parsedContent.batchIdComponentType) { + batchIdsByteStride = MetadataProperty::getSizeOfComponentType( + parsedContent.batchIdComponentType.value()); + } + const size_t batchIdsByteLength = pointsLength * batchIdsByteStride; + batchIdData.resize(batchIdsByteLength); + + std::memcpy( + batchIdData.data(), + featureTableBinaryData.data() + batchId.byteOffset, + batchIdsByteLength); +} + +void parseFeatureTableBinary( + const gsl::span& featureTableBinaryData, + rapidjson::Document& batchTableJson, + const gsl::span& batchTableBinaryData, + PntsContent& parsedContent) { + decodeDraco( + featureTableBinaryData, + batchTableJson, + batchTableBinaryData, + parsedContent); + if (parsedContent.errors) { + return; + } + + parsePositionsFromFeatureTableBinary(featureTableBinaryData, parsedContent); + + if (parsedContent.color) { + parseColorsFromFeatureTableBinary(featureTableBinaryData, parsedContent); + } + if (parsedContent.normal) { + parseNormalsFromFeatureTableBinary(featureTableBinaryData, parsedContent); + } + if (parsedContent.batchId) { + parseBatchIdsFromFeatureTableBinary(featureTableBinaryData, parsedContent); + } +} + +int32_t createBufferInGltf(Model& gltf, std::vector&& buffer) { + size_t bufferId = gltf.buffers.size(); + Buffer& gltfBuffer = gltf.buffers.emplace_back(); + gltfBuffer.byteLength = static_cast(buffer.size()); + gltfBuffer.cesium.data = std::move(buffer); + + return static_cast(bufferId); +} + +int32_t createBufferViewInGltf( + Model& gltf, + const int32_t bufferId, + const int64_t byteLength, + const int64_t byteStride) { + size_t bufferViewId = gltf.bufferViews.size(); + BufferView& bufferView = gltf.bufferViews.emplace_back(); + bufferView.buffer = bufferId; + bufferView.byteLength = byteLength; + bufferView.byteOffset = 0; + bufferView.byteStride = byteStride; + bufferView.target = BufferView::Target::ARRAY_BUFFER; + + return static_cast(bufferViewId); +} + +int32_t createAccessorInGltf( + Model& gltf, + const int32_t bufferViewId, + const int32_t componentType, + const int64_t count, + const std::string type) { + size_t accessorId = gltf.accessors.size(); + Accessor& accessor = gltf.accessors.emplace_back(); + accessor.bufferView = bufferViewId; + accessor.byteOffset = 0; + accessor.componentType = componentType; + accessor.count = count; + accessor.type = type; + + return static_cast(accessorId); +} + +void addPositionsToGltf(PntsContent& parsedContent, Model& gltf) { + const int64_t count = static_cast(parsedContent.pointsLength); + const int64_t byteStride = static_cast(sizeof(glm ::vec3)); + const int64_t byteLength = static_cast(byteStride * count); + int32_t bufferId = + createBufferInGltf(gltf, std::move(parsedContent.position.data)); + int32_t bufferViewId = + createBufferViewInGltf(gltf, bufferId, byteLength, byteStride); + int32_t accessorId = createAccessorInGltf( + gltf, + bufferViewId, + Accessor::ComponentType::FLOAT, + count, + Accessor::Type::VEC3); + + Accessor& accessor = gltf.accessors[static_cast(accessorId)]; + accessor.min = { + parsedContent.positionMin.x, + parsedContent.positionMin.y, + parsedContent.positionMin.z, + }; + accessor.max = { + parsedContent.positionMax.x, + parsedContent.positionMax.y, + parsedContent.positionMax.z, + }; + + MeshPrimitive& primitive = gltf.meshes[0].primitives[0]; + primitive.attributes.emplace("POSITION", accessorId); +} + +void addColorsToGltf(PntsContent& parsedContent, Model& gltf) { + PntsSemantic& color = parsedContent.color.value(); + + const int64_t count = static_cast(parsedContent.pointsLength); + int64_t byteStride = 0; + const int32_t componentType = Accessor::ComponentType::FLOAT; + std::string type; + bool isTranslucent = false; + + if (parsedContent.colorType == PntsColorType::RGBA) { + byteStride = static_cast(sizeof(glm::vec4)); + type = Accessor::Type::VEC4; + isTranslucent = true; + } else { + byteStride = static_cast(sizeof(glm::vec3)); + type = Accessor::Type::VEC3; + } + + const int64_t byteLength = static_cast(byteStride * count); + int32_t bufferId = createBufferInGltf(gltf, std::move(color.data)); + int32_t bufferViewId = + createBufferViewInGltf(gltf, bufferId, byteLength, byteStride); + int32_t accessorId = + createAccessorInGltf(gltf, bufferViewId, componentType, count, type); + + MeshPrimitive& primitive = gltf.meshes[0].primitives[0]; + primitive.attributes.emplace("COLOR_0", accessorId); + + if (isTranslucent) { + Material& material = + gltf.materials[static_cast(primitive.material)]; + material.alphaMode = Material::AlphaMode::BLEND; + } +} + +void addNormalsToGltf(PntsContent& parsedContent, Model& gltf) { + PntsSemantic& normal = parsedContent.normal.value(); + + const int64_t count = static_cast(parsedContent.pointsLength); + const int64_t byteStride = static_cast(sizeof(glm ::vec3)); + const int64_t byteLength = static_cast(byteStride * count); + + int32_t bufferId = createBufferInGltf(gltf, std::move(normal.data)); + int32_t bufferViewId = + createBufferViewInGltf(gltf, bufferId, byteLength, byteStride); + int32_t accessorId = createAccessorInGltf( + gltf, + bufferViewId, + Accessor::ComponentType::FLOAT, + count, + Accessor::Type::VEC3); + + MeshPrimitive& primitive = gltf.meshes[0].primitives[0]; + primitive.attributes.emplace("NORMAL", accessorId); +} + +void addBatchIdsToGltf(PntsContent& parsedContent, CesiumGltf::Model& gltf) { + PntsSemantic& batchId = parsedContent.batchId.value(); + + const int64_t count = static_cast(parsedContent.pointsLength); + int32_t componentType = Accessor::ComponentType::UNSIGNED_SHORT; + if (parsedContent.batchIdComponentType) { + switch (parsedContent.batchIdComponentType.value()) { + case MetadataProperty::ComponentType::UNSIGNED_BYTE: + componentType = Accessor::ComponentType::UNSIGNED_BYTE; + break; + case MetadataProperty::ComponentType::UNSIGNED_INT: + componentType = Accessor::ComponentType::UNSIGNED_INT; + break; + case MetadataProperty::ComponentType::UNSIGNED_SHORT: + default: + componentType = Accessor::ComponentType::UNSIGNED_SHORT; + break; + } + const int64_t byteStride = + Accessor::computeByteSizeOfComponent(componentType); + const int64_t byteLength = static_cast(byteStride * count); + + int32_t bufferId = createBufferInGltf(gltf, std::move(batchId.data)); + int32_t bufferViewId = + createBufferViewInGltf(gltf, bufferId, byteLength, byteStride); + int32_t accessorId = createAccessorInGltf( + gltf, + bufferViewId, + componentType, + count, + Accessor::Type::SCALAR); + + MeshPrimitive& primitive = gltf.meshes[0].primitives[0]; + // This will be renamed by BatchTableToGltfFeatureMetadata. + primitive.attributes.emplace("_BATCHID", accessorId); + } +} + +void createGltfFromParsedContent( + PntsContent& parsedContent, + GltfConverterResult& result) { + result.model = std::make_optional(); + Model& gltf = result.model.value(); + + // Create a single node with a single mesh, with a single primitive. + Node& node = gltf.nodes.emplace_back(); + std::memcpy( + node.matrix.data(), + &CesiumGeometry::AxisTransforms::Z_UP_TO_Y_UP, + sizeof(glm::dmat4)); + + size_t meshId = gltf.meshes.size(); + Mesh& mesh = gltf.meshes.emplace_back(); + node.mesh = static_cast(meshId); + + MeshPrimitive& primitive = mesh.primitives.emplace_back(); + primitive.mode = MeshPrimitive::Mode::POINTS; + + size_t materialId = gltf.materials.size(); + Material& material = gltf.materials.emplace_back(); + material.pbrMetallicRoughness = + std::make_optional(); + // These values are borrowed from CesiumJS. + material.pbrMetallicRoughness.value().metallicFactor = 0; + material.pbrMetallicRoughness.value().roughnessFactor = 0.9; + + primitive.material = static_cast(materialId); + + addPositionsToGltf(parsedContent, gltf); + + if (parsedContent.color) { + addColorsToGltf(parsedContent, gltf); + } else if (parsedContent.constantRgba) { + glm::vec4 materialColor(parsedContent.constantRgba.value()); + materialColor = srgbToLinear(materialColor / 255.0f); + + material.pbrMetallicRoughness.value().baseColorFactor = + {materialColor.x, materialColor.y, materialColor.z, materialColor.w}; + material.alphaMode = CesiumGltf::Material::AlphaMode::BLEND; + } + + if (parsedContent.normal) { + addNormalsToGltf(parsedContent, gltf); + } else { + // Points without normals should be rendered without lighting, which we + // can indicate with the KHR_materials_unlit extension. + material.addExtension(); + } + + if (parsedContent.batchId) { + addBatchIdsToGltf(parsedContent, gltf); + } + + if (parsedContent.rtcCenter) { + // Add the RTC_CENTER value to the glTF as a CESIUM_RTC extension. + // This matches what B3dmToGltfConverter does. In the future, + // this can be added instead to the translation component of + // the root node, as suggested in the 3D Tiles migration guide. + auto& cesiumRTC = + result.model->addExtension(); + glm::dvec3 rtcCenter = parsedContent.rtcCenter.value(); + cesiumRTC.center = {rtcCenter.x, rtcCenter.y, rtcCenter.z}; + } +} + +void convertPntsContentToGltf( + const gsl::span& pntsBinary, + const PntsHeader& header, + uint32_t headerLength, + GltfConverterResult& result) { + if (header.featureTableJsonByteLength > 0 && + header.featureTableBinaryByteLength > 0) { + PntsContent parsedContent; + + const gsl::span featureTableJsonData = + pntsBinary.subspan(headerLength, header.featureTableJsonByteLength); + rapidjson::Document featureTableJson = + parseFeatureTableJson(featureTableJsonData, parsedContent); + if (parsedContent.errors) { + result.errors.merge(parsedContent.errors); + return; + } + + // If the batch table contains the 3DTILES_draco_point_compression + // extension, the compressed metdata properties will be included in the + // feature table binary. Parse both JSONs first in case the extension is + // there. + const int64_t batchTableStart = headerLength + + header.featureTableJsonByteLength + + header.featureTableBinaryByteLength; + rapidjson::Document batchTableJson; + if (header.batchTableJsonByteLength > 0) { + const gsl::span batchTableJsonData = pntsBinary.subspan( + static_cast(batchTableStart), + header.batchTableJsonByteLength); + batchTableJson = parseBatchTableJson(batchTableJsonData, parsedContent); + } + + const gsl::span featureTableBinaryData = + pntsBinary.subspan( + static_cast( + headerLength + header.featureTableJsonByteLength), + header.featureTableBinaryByteLength); + + gsl::span batchTableBinaryData; + if (header.batchTableBinaryByteLength > 0) { + batchTableBinaryData = pntsBinary.subspan( + static_cast( + batchTableStart + header.batchTableJsonByteLength), + header.batchTableBinaryByteLength); + } + + parseFeatureTableBinary( + featureTableBinaryData, + batchTableJson, + batchTableBinaryData, + parsedContent); + + if (parsedContent.errors) { + result.errors.merge(parsedContent.errors); + return; + } + + createGltfFromParsedContent(parsedContent, result); + + if (!batchTableJson.IsObject() || batchTableJson.HasParseError() || + parsedContent.dracoMetadataHasErrors) { + result.errors.merge(parsedContent.errors); + return; + } + + if (!parsedContent.dracoBatchTableBinary.empty()) { + // If the point cloud has both compressed and uncompressed metadata + // values, then dracoBatchTableBinary will contain both the original + // batch table binary and the Draco decoded values. + batchTableBinaryData = + gsl::span(parsedContent.dracoBatchTableBinary); + } + + result.errors.merge(BatchTableToGltfFeatureMetadata::convertFromPnts( + featureTableJson, + batchTableJson, + batchTableBinaryData, + result.model.value())); + } +} +} // namespace + +GltfConverterResult PntsToGltfConverter::convert( + const gsl::span& pntsBinary, + const CesiumGltfReader::GltfReaderOptions& /*options*/) { + GltfConverterResult result; + PntsHeader header; + uint32_t headerLength = 0; + parsePntsHeader(pntsBinary, header, headerLength, result); + if (result.errors) { + return result; + } + + convertPntsContentToGltf(pntsBinary, header, headerLength, result); + return result; +} +} // namespace Cesium3DTilesSelection diff --git a/Cesium3DTilesSelection/src/PntsToGltfConverter.h b/Cesium3DTilesSelection/src/PntsToGltfConverter.h new file mode 100644 index 000000000..47e6aa07b --- /dev/null +++ b/Cesium3DTilesSelection/src/PntsToGltfConverter.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace Cesium3DTilesSelection { +struct PntsToGltfConverter { + static GltfConverterResult convert( + const gsl::span& pntsBinary, + const CesiumGltfReader::GltfReaderOptions& options); +}; +} // namespace Cesium3DTilesSelection diff --git a/Cesium3DTilesSelection/src/QuantizedMeshLoader.cpp b/Cesium3DTilesSelection/src/QuantizedMeshLoader.cpp index 7910342b5..326acbc11 100644 --- a/Cesium3DTilesSelection/src/QuantizedMeshLoader.cpp +++ b/Cesium3DTilesSelection/src/QuantizedMeshLoader.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -151,26 +152,6 @@ static T readValue( return defaultValue; } -static glm::dvec3 octDecode(uint8_t x, uint8_t y) { - constexpr uint8_t rangeMax = 255; - - glm::dvec3 result; - - result.x = CesiumUtility::Math::fromSNorm(x, rangeMax); - result.y = CesiumUtility::Math::fromSNorm(y, rangeMax); - result.z = 1.0 - (glm::abs(result.x) + glm::abs(result.y)); - - if (result.z < 0.0) { - const double oldVX = result.x; - result.x = - (1.0 - glm::abs(result.y)) * CesiumUtility::Math::signNotZero(oldVX); - result.y = - (1.0 - glm::abs(oldVX)) * CesiumUtility::Math::signNotZero(result.y); - } - - return glm::normalize(result); -} - static QuantizedMeshMetadataResult processMetadata(const QuadtreeTileID& tileID, gsl::span json); @@ -620,7 +601,7 @@ static void decodeNormals( size_t normalOutputIndex = 0; for (size_t i = 0; i < encoded.size(); i += 2) { - glm::dvec3 normal = octDecode( + glm::dvec3 normal = AttributeCompression::octDecode( static_cast(encoded[i]), static_cast(encoded[i + 1])); decoded[normalOutputIndex++] = static_cast(normal.x); diff --git a/Cesium3DTilesSelection/src/registerAllTileContentTypes.cpp b/Cesium3DTilesSelection/src/registerAllTileContentTypes.cpp index c43a53cb2..ce9a98020 100644 --- a/Cesium3DTilesSelection/src/registerAllTileContentTypes.cpp +++ b/Cesium3DTilesSelection/src/registerAllTileContentTypes.cpp @@ -1,6 +1,7 @@ #include "B3dmToGltfConverter.h" #include "BinaryToGltfConverter.h" #include "CmptToGltfConverter.h" +#include "PntsToGltfConverter.h" #include #include @@ -11,6 +12,7 @@ void registerAllTileContentTypes() { GltfConverters::registerMagic("glTF", BinaryToGltfConverter::convert); GltfConverters::registerMagic("b3dm", B3dmToGltfConverter::convert); GltfConverters::registerMagic("cmpt", CmptToGltfConverter::convert); + GltfConverters::registerMagic("pnts", PntsToGltfConverter::convert); GltfConverters::registerFileExtension( ".gltf", diff --git a/Cesium3DTilesSelection/test/ConvertTileToGltf.h b/Cesium3DTilesSelection/test/ConvertTileToGltf.h new file mode 100644 index 000000000..2011ca5cb --- /dev/null +++ b/Cesium3DTilesSelection/test/ConvertTileToGltf.h @@ -0,0 +1,21 @@ +#pragma once + +#include "B3dmToGltfConverter.h" +#include "PntsToGltfConverter.h" +#include "readFile.h" + +#include + +namespace Cesium3DTilesSelection { + +class ConvertTileToGltf { +public: + static GltfConverterResult fromB3dm(const std::filesystem::path& filePath) { + return B3dmToGltfConverter::convert(readFile(filePath), {}); + } + + static GltfConverterResult fromPnts(const std::filesystem::path& filePath) { + return PntsToGltfConverter::convert(readFile(filePath), {}); + } +}; +} // namespace Cesium3DTilesSelection diff --git a/Cesium3DTilesSelection/test/TestPntsToGltfConverter.cpp b/Cesium3DTilesSelection/test/TestPntsToGltfConverter.cpp new file mode 100644 index 000000000..543397b89 --- /dev/null +++ b/Cesium3DTilesSelection/test/TestPntsToGltfConverter.cpp @@ -0,0 +1,1245 @@ +#include "ConvertTileToGltf.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +using namespace CesiumGltf; +using namespace Cesium3DTilesSelection; +using namespace CesiumUtility; + +template +static void checkBufferContents( + const std::vector& buffer, + const std::vector& expected, + [[maybe_unused]] const double epsilon = Math::Epsilon6) { + REQUIRE(buffer.size() == expected.size() * sizeof(Type)); + const int32_t byteStride = sizeof(Type); + if constexpr (std::is_same_v) { + for (size_t i = 0; i < expected.size(); ++i) { + const glm::vec3& value = + *reinterpret_cast(buffer.data() + i * byteStride); + const glm::vec3& expectedValue = expected[i]; + REQUIRE(Math::equalsEpsilon( + static_cast(value), + static_cast(expectedValue), + epsilon)); + } + } else if constexpr (std::is_same_v) { + for (size_t i = 0; i < expected.size(); ++i) { + const glm::vec4& value = + *reinterpret_cast(buffer.data() + i * byteStride); + const glm::vec4& expectedValue = expected[i]; + REQUIRE(Math::equalsEpsilon( + static_cast(value), + static_cast(expectedValue), + epsilon)); + } + } else if constexpr (std::is_floating_point_v) { + for (size_t i = 0; i < expected.size(); ++i) { + const Type& value = + *reinterpret_cast(buffer.data() + i * byteStride); + const Type& expectedValue = expected[i]; + REQUIRE(value == Approx(expectedValue)); + } + } else if constexpr ( + std::is_integral_v || std::is_same_v || + std::is_same_v || std::is_same_v) { + for (size_t i = 0; i < expected.size(); ++i) { + const Type& value = + *reinterpret_cast(buffer.data() + i * byteStride); + const Type& expectedValue = expected[i]; + REQUIRE(value == expectedValue); + } + } else { + FAIL("Buffer check has not been implemented for the given type."); + } +} + +template +static void checkAttribute( + const Model& gltf, + const MeshPrimitive& primitive, + const std::string& attributeSemantic, + const uint32_t expectedCount) { + const auto& attributes = primitive.attributes; + REQUIRE(attributes.find(attributeSemantic) != attributes.end()); + + const int32_t accessorId = attributes.at(attributeSemantic); + REQUIRE(accessorId >= 0); + const uint32_t accessorIdUint = static_cast(accessorId); + const Accessor& accessor = gltf.accessors[accessorIdUint]; + + int32_t expectedComponentType = -1; + std::string expectedType; + + if constexpr (std::is_same_v) { + expectedComponentType = Accessor::ComponentType::FLOAT; + expectedType = Accessor::Type::VEC3; + } else if constexpr (std::is_same_v) { + expectedComponentType = Accessor::ComponentType::FLOAT; + expectedType = Accessor::Type::VEC4; + } else if constexpr (std::is_same_v) { + expectedComponentType = Accessor::ComponentType::UNSIGNED_BYTE; + expectedType = Accessor::Type::VEC3; + } else if constexpr (std::is_same_v) { + expectedComponentType = Accessor::ComponentType::UNSIGNED_BYTE; + expectedType = Accessor::Type::VEC4; + } else if constexpr (std::is_same_v) { + expectedComponentType = Accessor::ComponentType::UNSIGNED_BYTE; + expectedType = Accessor::Type::SCALAR; + } else { + FAIL("Accessor check has not been implemented for the given type."); + } + + CHECK(accessor.byteOffset == 0); + CHECK(accessor.componentType == expectedComponentType); + CHECK(accessor.count == expectedCount); + CHECK(accessor.type == expectedType); + + const int64_t expectedByteLength = + static_cast(expectedCount * sizeof(Type)); + + const int32_t bufferViewId = accessor.bufferView; + REQUIRE(bufferViewId >= 0); + const uint32_t bufferViewIdUint = static_cast(bufferViewId); + const BufferView& bufferView = gltf.bufferViews[bufferViewIdUint]; + CHECK(bufferView.byteLength == expectedByteLength); + CHECK(bufferView.byteOffset == 0); + + const int32_t bufferId = bufferView.buffer; + REQUIRE(bufferId >= 0); + const uint32_t bufferIdUint = static_cast(bufferId); + const Buffer& buffer = gltf.buffers[static_cast(bufferIdUint)]; + CHECK(buffer.byteLength == expectedByteLength); + CHECK(static_cast(buffer.cesium.data.size()) == buffer.byteLength); +} + +TEST_CASE("Converts simple point cloud to glTF") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = testFilePath / "PointCloud" / "pointCloudPositionsOnly.pnts"; + const int32_t pointsLength = 8; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + // Check for single mesh node + REQUIRE(gltf.nodes.size() == 1); + Node& node = gltf.nodes[0]; + // clang-format off + std::vector expectedMatrix = { + 1.0, 0.0, 0.0, 0.0, + 0.0, 0.0, -1.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 1.0}; + // clang-format on + CHECK(node.matrix == expectedMatrix); + CHECK(node.mesh == 0); + + // Check for single mesh primitive + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK(primitive.mode == MeshPrimitive::Mode::POINTS); + CHECK(primitive.material == 0); + + // Check for single material + REQUIRE(gltf.materials.size() == 1); + Material& material = gltf.materials[0]; + CHECK(material.pbrMetallicRoughness); + CHECK(material.hasExtension()); + + auto attributes = primitive.attributes; + REQUIRE(attributes.size() == 1); + REQUIRE(attributes.find("POSITION") != attributes.end()); + CHECK(attributes.at("POSITION") == 0); + + // Check for single accessor + REQUIRE(gltf.accessors.size() == 1); + Accessor& accessor = gltf.accessors[0]; + CHECK(accessor.bufferView == 0); + CHECK(accessor.byteOffset == 0); + CHECK(accessor.componentType == Accessor::ComponentType::FLOAT); + CHECK(accessor.count == pointsLength); + CHECK(accessor.type == Accessor::Type::VEC3); + + const glm::vec3 expectedMin(-3.2968313, -4.0330467, -3.5223078); + CHECK(accessor.min[0] == Approx(expectedMin.x)); + CHECK(accessor.min[1] == Approx(expectedMin.y)); + CHECK(accessor.min[2] == Approx(expectedMin.z)); + + const glm::vec3 expectedMax(3.2968313, 4.0330467, 3.5223078); + CHECK(accessor.max[0] == Approx(expectedMax.x)); + CHECK(accessor.max[1] == Approx(expectedMax.y)); + CHECK(accessor.max[2] == Approx(expectedMax.z)); + + // Check for single bufferView + REQUIRE(gltf.bufferViews.size() == 1); + BufferView& bufferView = gltf.bufferViews[0]; + CHECK(bufferView.buffer == 0); + CHECK(bufferView.byteLength == pointsLength * sizeof(glm::vec3)); + CHECK(bufferView.byteOffset == 0); + + // Check for single buffer + REQUIRE(gltf.buffers.size() == 1); + Buffer& buffer = gltf.buffers[0]; + CHECK(buffer.byteLength == pointsLength * sizeof(glm::vec3)); + CHECK(static_cast(buffer.cesium.data.size()) == buffer.byteLength); + + const std::vector expectedPositions = { + glm::vec3(-2.4975082, -0.3252686, -3.5223078), + glm::vec3(2.3456699, 0.9171584, -3.5223078), + glm::vec3(-3.2968313, 2.7906193, 0.3055275), + glm::vec3(1.5463469, 4.03304672, 0.3055275), + glm::vec3(-1.5463469, -4.03304672, -0.3055275), + glm::vec3(3.2968313, -2.7906193, -0.3055275), + glm::vec3(-2.3456699, -0.9171584, 3.5223078), + glm::vec3(2.4975082, 0.3252686, 3.5223078)}; + + checkBufferContents(buffer.cesium.data, expectedPositions); + + // Check for RTC extension + REQUIRE(gltf.hasExtension()); + const auto& rtcExtension = + result.model->getExtension(); + const glm::vec3 expectedRtcCenter( + 1215012.8828876, + -4736313.0511995, + 4081605.2212604); + CHECK(rtcExtension->center[0] == Approx(expectedRtcCenter.x)); + CHECK(rtcExtension->center[1] == Approx(expectedRtcCenter.y)); + CHECK(rtcExtension->center[2] == Approx(expectedRtcCenter.z)); +} + +TEST_CASE("Converts point cloud with RGBA to glTF") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = testFilePath / "PointCloud" / "pointCloudRGBA.pnts"; + const int32_t pointsLength = 8; + const int32_t expectedAttributeCount = 2; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + CHECK(gltf.hasExtension()); + CHECK(gltf.nodes.size() == 1); + + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK(primitive.mode == MeshPrimitive::Mode::POINTS); + + REQUIRE(gltf.materials.size() == 1); + Material& material = gltf.materials[0]; + CHECK(material.alphaMode == Material::AlphaMode::BLEND); + CHECK(material.hasExtension()); + + REQUIRE(gltf.accessors.size() == expectedAttributeCount); + REQUIRE(gltf.bufferViews.size() == expectedAttributeCount); + REQUIRE(gltf.buffers.size() == expectedAttributeCount); + + auto attributes = primitive.attributes; + REQUIRE(attributes.size() == expectedAttributeCount); + + // Check that position and color attributes are present + checkAttribute(gltf, primitive, "POSITION", pointsLength); + checkAttribute(gltf, primitive, "COLOR_0", pointsLength); + + // Check color attribute more thoroughly + uint32_t colorAccessorId = static_cast(attributes.at("COLOR_0")); + Accessor& colorAccessor = gltf.accessors[colorAccessorId]; + CHECK(!colorAccessor.normalized); + + uint32_t colorBufferViewId = static_cast(colorAccessor.bufferView); + BufferView& colorBufferView = gltf.bufferViews[colorBufferViewId]; + + uint32_t colorBufferId = static_cast(colorBufferView.buffer); + Buffer& colorBuffer = gltf.buffers[colorBufferId]; + + const std::vector expectedColors = { + glm::vec4(0.263174f, 0.315762f, 0.476177f, 0.423529f), + glm::vec4(0.325036f, 0.708297f, 0.259027f, 0.423529f), + glm::vec4(0.151058f, 0.353740f, 0.378676f, 0.192156f), + glm::vec4(0.160443f, 0.067724f, 0.774227f, 0.027450f), + glm::vec4(0.915750f, 0.056374f, 0.119264f, 0.239215f), + glm::vec4(0.592438f, 0.632042f, 0.242796f, 0.239215f), + glm::vec4(0.284452f, 0.127529f, 0.843369f, 0.419607f), + glm::vec4(0.002932f, 0.091518f, 0.004559f, 0.321568f)}; + + checkBufferContents(colorBuffer.cesium.data, expectedColors); +} + +TEST_CASE("Converts point cloud with RGB to glTF") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = testFilePath / "PointCloud" / "pointCloudRGB.pnts"; + const int32_t pointsLength = 8; + const int32_t expectedAttributeCount = 2; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + CHECK(gltf.hasExtension()); + CHECK(gltf.nodes.size() == 1); + + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK(primitive.mode == MeshPrimitive::Mode::POINTS); + + REQUIRE(gltf.materials.size() == 1); + Material& material = gltf.materials[0]; + CHECK(material.alphaMode == Material::AlphaMode::OPAQUE); + CHECK(material.hasExtension()); + + REQUIRE(gltf.accessors.size() == expectedAttributeCount); + REQUIRE(gltf.bufferViews.size() == expectedAttributeCount); + REQUIRE(gltf.buffers.size() == expectedAttributeCount); + + auto attributes = primitive.attributes; + REQUIRE(attributes.size() == expectedAttributeCount); + + // Check that position and color attributes are present + checkAttribute(gltf, primitive, "POSITION", pointsLength); + checkAttribute(gltf, primitive, "COLOR_0", pointsLength); + + // Check color attribute more thoroughly + uint32_t colorAccessorId = static_cast(attributes.at("COLOR_0")); + Accessor& colorAccessor = gltf.accessors[colorAccessorId]; + CHECK(!colorAccessor.normalized); + + uint32_t colorBufferViewId = static_cast(colorAccessor.bufferView); + BufferView& colorBufferView = gltf.bufferViews[colorBufferViewId]; + + uint32_t colorBufferId = static_cast(colorBufferView.buffer); + Buffer& colorBuffer = gltf.buffers[colorBufferId]; + + const std::vector expectedColors = { + glm::vec3(0.263174f, 0.315762f, 0.476177f), + glm::vec3(0.325036f, 0.708297f, 0.259027f), + glm::vec3(0.151058f, 0.353740f, 0.378676f), + glm::vec3(0.160443f, 0.067724f, 0.774227f), + glm::vec3(0.915750f, 0.056374f, 0.119264f), + glm::vec3(0.592438f, 0.632042f, 0.242796f), + glm::vec3(0.284452f, 0.127529f, 0.843369f), + glm::vec3(0.002932f, 0.091518f, 0.004559f)}; + + checkBufferContents(colorBuffer.cesium.data, expectedColors); +} + +TEST_CASE("Converts point cloud with RGB565 to glTF") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = testFilePath / "PointCloud" / "pointCloudRGB565.pnts"; + const int32_t pointsLength = 8; + const int32_t expectedAttributeCount = 2; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + CHECK(gltf.hasExtension()); + CHECK(gltf.nodes.size() == 1); + + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK(primitive.mode == MeshPrimitive::Mode::POINTS); + + REQUIRE(gltf.materials.size() == 1); + Material& material = gltf.materials[0]; + CHECK(material.alphaMode == Material::AlphaMode::OPAQUE); + CHECK(material.hasExtension()); + + REQUIRE(gltf.accessors.size() == expectedAttributeCount); + REQUIRE(gltf.bufferViews.size() == expectedAttributeCount); + REQUIRE(gltf.buffers.size() == expectedAttributeCount); + + auto attributes = primitive.attributes; + REQUIRE(attributes.size() == expectedAttributeCount); + + // Check that position and color attributes are present + checkAttribute(gltf, primitive, "POSITION", pointsLength); + checkAttribute(gltf, primitive, "COLOR_0", pointsLength); + + // Check color attribute more thoroughly + uint32_t colorAccessorId = static_cast(attributes.at("COLOR_0")); + Accessor& colorAccessor = gltf.accessors[colorAccessorId]; + CHECK(!colorAccessor.normalized); + + uint32_t colorBufferViewId = static_cast(colorAccessor.bufferView); + BufferView& colorBufferView = gltf.bufferViews[colorBufferViewId]; + + uint32_t colorBufferId = static_cast(colorBufferView.buffer); + Buffer& colorBuffer = gltf.buffers[colorBufferId]; + + const std::vector expectedColors = { + glm::vec3(0.2666808f, 0.3100948f, 0.4702556f), + glm::vec3(0.3024152f, 0.7123886f, 0.2333824f), + glm::vec3(0.1478017f, 0.3481712f, 0.3813029f), + glm::vec3(0.1478017f, 0.0635404f, 0.7379118f), + glm::vec3(0.8635347f, 0.0560322f, 0.1023452f), + glm::vec3(0.5694675f, 0.6282104f, 0.2333824f), + glm::vec3(0.2666808f, 0.1196507f, 0.7993773f), + glm::vec3(0.0024058f, 0.0891934f, 0.0024058f)}; + + checkBufferContents(colorBuffer.cesium.data, expectedColors); +} + +TEST_CASE("Converts point cloud with CONSTANT_RGBA") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = testFilePath / "PointCloud" / "pointCloudConstantRGBA.pnts"; + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + const int32_t pointsLength = 8; + + REQUIRE(result.model); + Model& gltf = *result.model; + + CHECK(gltf.hasExtension()); + CHECK(gltf.nodes.size() == 1); + + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK(primitive.mode == MeshPrimitive::Mode::POINTS); + CHECK(primitive.material == 0); + + CHECK(gltf.buffers.size() == 1); + CHECK(gltf.bufferViews.size() == 1); + CHECK(gltf.accessors.size() == 1); + + checkAttribute(gltf, primitive, "POSITION", pointsLength); + + REQUIRE(gltf.materials.size() == 1); + Material& material = gltf.materials[0]; + REQUIRE(material.pbrMetallicRoughness); + MaterialPBRMetallicRoughness& pbrMetallicRoughness = + material.pbrMetallicRoughness.value(); + const auto& baseColorFactor = pbrMetallicRoughness.baseColorFactor; + + // Check that CONSTANT_RGBA is stored in the material base color + const glm::vec4 expectedConstantRGBA = + glm::vec4(1.0f, 1.0f, 0.0f, 51.0f / 255.0f); + CHECK(baseColorFactor[0] == Approx(expectedConstantRGBA.x)); + CHECK(baseColorFactor[1] == Approx(expectedConstantRGBA.y)); + CHECK(baseColorFactor[2] == Approx(expectedConstantRGBA.z)); + CHECK(baseColorFactor[3] == Approx(expectedConstantRGBA.w)); + + CHECK(material.alphaMode == Material::AlphaMode::BLEND); + CHECK(material.hasExtension()); +} + +TEST_CASE("Converts point cloud with quantized positions to glTF") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = testFilePath / "PointCloud" / "pointCloudQuantized.pnts"; + const int32_t pointsLength = 8; + const int32_t expectedAttributeCount = 2; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + CHECK(!gltf.hasExtension()); + CHECK(gltf.nodes.size() == 1); + + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK(primitive.mode == MeshPrimitive::Mode::POINTS); + + REQUIRE(gltf.materials.size() == 1); + Material& material = gltf.materials[0]; + CHECK(material.hasExtension()); + + REQUIRE(gltf.accessors.size() == expectedAttributeCount); + REQUIRE(gltf.bufferViews.size() == expectedAttributeCount); + REQUIRE(gltf.buffers.size() == expectedAttributeCount); + + auto attributes = primitive.attributes; + REQUIRE(attributes.size() == expectedAttributeCount); + + // Check that position and color attributes are present + checkAttribute(gltf, primitive, "POSITION", pointsLength); + checkAttribute(gltf, primitive, "COLOR_0", pointsLength); + + // Check position attribute more thoroughly + uint32_t positionAccessorId = + static_cast(attributes.at("POSITION")); + Accessor& positionAccessor = gltf.accessors[positionAccessorId]; + CHECK(!positionAccessor.normalized); + + const glm::vec3 expectedMin(1215009.59, -4736317.08, 4081601.7); + CHECK(positionAccessor.min[0] == Approx(expectedMin.x)); + CHECK(positionAccessor.min[1] == Approx(expectedMin.y)); + CHECK(positionAccessor.min[2] == Approx(expectedMin.z)); + + const glm::vec3 expectedMax(1215016.18, -4736309.02, 4081608.74); + CHECK(positionAccessor.max[0] == Approx(expectedMax.x)); + CHECK(positionAccessor.max[1] == Approx(expectedMax.y)); + CHECK(positionAccessor.max[2] == Approx(expectedMax.z)); + + uint32_t positionBufferViewId = + static_cast(positionAccessor.bufferView); + BufferView& positionBufferView = gltf.bufferViews[positionBufferViewId]; + + uint32_t positionBufferId = static_cast(positionBufferView.buffer); + Buffer& positionBuffer = gltf.buffers[positionBufferId]; + + const std::vector expectedPositions = { + glm::vec3(1215010.39, -4736313.38, 4081601.7), + glm::vec3(1215015.23, -4736312.13, 4081601.7), + glm::vec3(1215009.59, -4736310.26, 4081605.53), + glm::vec3(1215014.43, -4736309.02, 4081605.53), + glm::vec3(1215011.34, -4736317.08, 4081604.92), + glm::vec3(1215016.18, -4736315.84, 4081604.92), + glm::vec3(1215010.54, -4736313.97, 4081608.74), + glm::vec3(1215015.38, -4736312.73, 4081608.74)}; + + checkBufferContents(positionBuffer.cesium.data, expectedPositions); +} + +TEST_CASE("Converts point cloud with normals to glTF") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = testFilePath / "PointCloud" / "pointCloudNormals.pnts"; + const int32_t pointsLength = 8; + const int32_t expectedAttributeCount = 3; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + CHECK(gltf.hasExtension()); + CHECK(gltf.nodes.size() == 1); + + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK(primitive.mode == MeshPrimitive::Mode::POINTS); + + REQUIRE(gltf.materials.size() == 1); + Material& material = gltf.materials[0]; + CHECK(!material.hasExtension()); + + REQUIRE(gltf.accessors.size() == expectedAttributeCount); + REQUIRE(gltf.bufferViews.size() == expectedAttributeCount); + REQUIRE(gltf.buffers.size() == expectedAttributeCount); + + auto attributes = primitive.attributes; + REQUIRE(attributes.size() == expectedAttributeCount); + + // Check that position, color, and normal attributes are present + checkAttribute(gltf, primitive, "POSITION", pointsLength); + checkAttribute(gltf, primitive, "COLOR_0", pointsLength); + checkAttribute(gltf, primitive, "NORMAL", pointsLength); + + // Check normal attribute more thoroughly + uint32_t normalAccessorId = static_cast(attributes.at("NORMAL")); + Accessor& normalAccessor = gltf.accessors[normalAccessorId]; + + uint32_t normalBufferViewId = + static_cast(normalAccessor.bufferView); + BufferView& normalBufferView = gltf.bufferViews[normalBufferViewId]; + + uint32_t normalBufferId = static_cast(normalBufferView.buffer); + Buffer& normalBuffer = gltf.buffers[normalBufferId]; + + const std::vector expectedNormals = { + glm::vec3(-0.9854088, 0.1667507, 0.0341110), + glm::vec3(-0.5957704, 0.5378777, 0.5964436), + glm::vec3(-0.5666092, -0.7828890, -0.2569800), + glm::vec3(-0.5804154, -0.7226123, 0.3754320), + glm::vec3(-0.8535281, -0.1291752, -0.5047805), + glm::vec3(0.7557975, 0.1243999, 0.6428800), + glm::vec3(0.1374090, -0.2333731, -0.9626296), + glm::vec3(-0.0633145, 0.9630424, 0.2618022)}; + + checkBufferContents(normalBuffer.cesium.data, expectedNormals); +} + +TEST_CASE("Converts point cloud with oct-encoded normals to glTF") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = + testFilePath / "PointCloud" / "pointCloudNormalsOctEncoded.pnts"; + const int32_t pointsLength = 8; + const int32_t expectedAttributeCount = 3; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + CHECK(gltf.hasExtension()); + CHECK(gltf.nodes.size() == 1); + + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK(primitive.mode == MeshPrimitive::Mode::POINTS); + + REQUIRE(gltf.materials.size() == 1); + Material& material = gltf.materials[0]; + CHECK(!material.hasExtension()); + + REQUIRE(gltf.accessors.size() == expectedAttributeCount); + REQUIRE(gltf.bufferViews.size() == expectedAttributeCount); + REQUIRE(gltf.buffers.size() == expectedAttributeCount); + + auto attributes = primitive.attributes; + REQUIRE(attributes.size() == expectedAttributeCount); + + // Check that position, color, and normal attributes are present + checkAttribute(gltf, primitive, "POSITION", pointsLength); + checkAttribute(gltf, primitive, "COLOR_0", pointsLength); + checkAttribute(gltf, primitive, "NORMAL", pointsLength); + + // Check normal attribute more thoroughly + uint32_t normalAccessorId = static_cast(attributes.at("NORMAL")); + Accessor& normalAccessor = gltf.accessors[normalAccessorId]; + CHECK(!normalAccessor.normalized); + + uint32_t normalBufferViewId = + static_cast(normalAccessor.bufferView); + BufferView& normalBufferView = gltf.bufferViews[normalBufferViewId]; + + uint32_t normalBufferId = static_cast(normalBufferView.buffer); + Buffer& normalBuffer = gltf.buffers[normalBufferId]; + + const std::vector expectedNormals = { + glm::vec3(-0.9856477, 0.1634960, 0.0420418), + glm::vec3(-0.5901730, 0.5359042, 0.6037402), + glm::vec3(-0.5674310, -0.7817938, -0.2584963), + glm::vec3(-0.5861990, -0.7179291, 0.3754308), + glm::vec3(-0.8519385, -0.1283743, -0.5076620), + glm::vec3(0.7587127, 0.1254564, 0.6392304), + glm::vec3(0.1354662, -0.2292506, -0.9638947), + glm::vec3(-0.0656172, 0.9640687, 0.2574214)}; + + checkBufferContents(normalBuffer.cesium.data, expectedNormals); +} + +std::set +getUniqueBufferIds(const std::vector& bufferViews) { + std::set result; + for (auto it = bufferViews.begin(); it != bufferViews.end(); it++) { + result.insert(it->buffer); + } + + return result; +} + +TEST_CASE( + "Converts point cloud with batch IDs to glTF with EXT_feature_metadata") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = testFilePath / "PointCloud" / "pointCloudBatched.pnts"; + const int32_t pointsLength = 8; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + // The correctness of the model extension is thoroughly tested in + // TestUpgradeBatchTableToExtFeatureMetadata + CHECK(gltf.hasExtension()); + + CHECK(gltf.nodes.size() == 1); + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK(primitive.mode == MeshPrimitive::Mode::POINTS); + + auto primitiveExtension = + primitive.getExtension(); + REQUIRE(primitiveExtension); + REQUIRE(primitiveExtension->featureIdAttributes.size() == 1); + FeatureIDAttribute& attribute = primitiveExtension->featureIdAttributes[0]; + CHECK(attribute.featureTable == "default"); + CHECK(attribute.featureIds.attribute == "_FEATURE_ID_0"); + + CHECK(gltf.materials.size() == 1); + + // The file has three metadata properties: + // - "name": string scalars in JSON + // - "dimensions": float vec3s in binary + // - "id": int scalars in binary + // There are three accessors (one per primitive attribute) + // and four additional buffer views: + // - "name" string data buffer view + // - "name" string offsets buffer view + // - "dimensions" buffer view + // - "id" buffer view + REQUIRE(gltf.accessors.size() == 3); + REQUIRE(gltf.bufferViews.size() == 7); + + // There are also three added buffers: + // - binary data in the batch table + // - string data of "name" + // - string offsets for the data for "name" + REQUIRE(gltf.buffers.size() == 6); + std::set bufferSet = getUniqueBufferIds(gltf.bufferViews); + CHECK(bufferSet.size() == 6); + + auto attributes = primitive.attributes; + REQUIRE(attributes.size() == 3); + + // Check that position, normal, and feature ID attributes are present + checkAttribute(gltf, primitive, "POSITION", pointsLength); + checkAttribute(gltf, primitive, "NORMAL", pointsLength); + checkAttribute(gltf, primitive, "_FEATURE_ID_0", pointsLength); + + // Check feature ID attribute more thoroughly + uint32_t featureIdAccessorId = + static_cast(attributes.at("_FEATURE_ID_0")); + Accessor& featureIdAccessor = gltf.accessors[featureIdAccessorId]; + + uint32_t featureIdBufferViewId = + static_cast(featureIdAccessor.bufferView); + BufferView& featureIdBufferView = gltf.bufferViews[featureIdBufferViewId]; + + uint32_t featureIdBufferId = + static_cast(featureIdBufferView.buffer); + Buffer& featureIdBuffer = gltf.buffers[featureIdBufferId]; + + const std::vector expectedFeatureIDs = {5, 5, 6, 6, 7, 0, 3, 1}; + checkBufferContents(featureIdBuffer.cesium.data, expectedFeatureIDs); +} + +TEST_CASE("Converts point cloud with per-point properties to glTF with " + "EXT_feature_metadata") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = + testFilePath / "PointCloud" / "pointCloudWithPerPointProperties.pnts"; + const int32_t pointsLength = 8; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + // The correctness of the model extension is thoroughly tested in + // TestUpgradeBatchTableToExtFeatureMetadata + CHECK(gltf.hasExtension()); + + CHECK(gltf.nodes.size() == 1); + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK(primitive.mode == MeshPrimitive::Mode::POINTS); + + auto primitiveExtension = + primitive.getExtension(); + REQUIRE(primitiveExtension); + REQUIRE(primitiveExtension->featureIdAttributes.size() == 1); + FeatureIDAttribute& attribute = primitiveExtension->featureIdAttributes[0]; + CHECK(attribute.featureTable == "default"); + // Check for implicit feature IDs + CHECK(!attribute.featureIds.attribute); + CHECK(attribute.featureIds.constant == 0); + CHECK(attribute.featureIds.divisor == 1); + + CHECK(gltf.materials.size() == 1); + + // The file has three binary metadata properties: + // - "temperature": float scalars + // - "secondaryColor": float vec3s + // - "id": unsigned short scalars + // There are two accessors (one per primitive attribute) + // and three additional buffer views: + // - temperature buffer view + // - secondary color buffer view + // - id buffer view + REQUIRE(gltf.accessors.size() == 2); + REQUIRE(gltf.bufferViews.size() == 5); + + // There is only one added buffer containing all the binary values. + REQUIRE(gltf.buffers.size() == 3); + std::set bufferSet = getUniqueBufferIds(gltf.bufferViews); + CHECK(bufferSet.size() == 3); + + auto attributes = primitive.attributes; + REQUIRE(attributes.size() == 2); + REQUIRE(attributes.find("_FEATURE_ID_0") == attributes.end()); + + // Check that position and color attributes are present + checkAttribute(gltf, primitive, "POSITION", pointsLength); + checkAttribute(gltf, primitive, "COLOR_0", pointsLength); +} + +TEST_CASE("Converts point cloud with Draco compression to glTF") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = testFilePath / "PointCloud" / "pointCloudDraco.pnts"; + const int32_t pointsLength = 8; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + CHECK(gltf.hasExtension()); + // The correctness of the model extension is thoroughly tested in + // TestUpgradeBatchTableToExtFeatureMetadata + CHECK(gltf.hasExtension()); + + CHECK(gltf.nodes.size() == 1); + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK(primitive.mode == MeshPrimitive::Mode::POINTS); + + auto primitiveExtension = + primitive.getExtension(); + REQUIRE(primitiveExtension); + REQUIRE(primitiveExtension->featureIdAttributes.size() == 1); + FeatureIDAttribute& attribute = primitiveExtension->featureIdAttributes[0]; + CHECK(attribute.featureTable == "default"); + // Check for implicit feature IDs + CHECK(!attribute.featureIds.attribute); + CHECK(attribute.featureIds.constant == 0); + CHECK(attribute.featureIds.divisor == 1); + + REQUIRE(gltf.materials.size() == 1); + Material& material = gltf.materials[0]; + CHECK(!material.hasExtension()); + + // The file has three binary metadata properties: + // - "temperature": float scalars + // - "secondaryColor": float vec3s + // - "id": unsigned short scalars + // There are three accessors (one per primitive attribute) + // and three additional buffer views: + // - temperature buffer view + // - secondary color buffer view + // - id buffer view + REQUIRE(gltf.accessors.size() == 3); + REQUIRE(gltf.bufferViews.size() == 6); + + // There is only one added buffer containing all the binary values. + REQUIRE(gltf.buffers.size() == 4); + + auto attributes = primitive.attributes; + REQUIRE(attributes.size() == 3); + + // Check that position, color, and normal attributes are present + checkAttribute(gltf, primitive, "POSITION", pointsLength); + checkAttribute(gltf, primitive, "COLOR_0", pointsLength); + checkAttribute(gltf, primitive, "NORMAL", pointsLength); + + // Check each attribute more thoroughly + { + uint32_t accessorId = static_cast(attributes.at("POSITION")); + Accessor& accessor = gltf.accessors[accessorId]; + + const glm::vec3 expectedMin(-4.9270443f, -3.9144449f, -4.8131480f); + CHECK(accessor.min[0] == Approx(expectedMin.x)); + CHECK(accessor.min[1] == Approx(expectedMin.y)); + CHECK(accessor.min[2] == Approx(expectedMin.z)); + + const glm::vec3 expectedMax(3.7791683f, 4.8152132f, 3.2142156f); + CHECK(accessor.max[0] == Approx(expectedMax.x)); + CHECK(accessor.max[1] == Approx(expectedMax.y)); + CHECK(accessor.max[2] == Approx(expectedMax.z)); + + uint32_t bufferViewId = static_cast(accessor.bufferView); + BufferView& bufferView = gltf.bufferViews[bufferViewId]; + + uint32_t bufferId = static_cast(bufferView.buffer); + Buffer& buffer = gltf.buffers[bufferId]; + + std::vector expected = { + glm::vec3(-4.9270443f, 0.8337686f, 0.1705846f), + glm::vec3(-2.9789500f, 2.6891474f, 2.9824265f), + glm::vec3(-2.8329495f, -3.9144449f, -1.2851576f), + glm::vec3(-2.9022198f, -3.6128526f, 1.8772986f), + glm::vec3(-4.2673778f, -0.6459517f, -2.5240305f), + glm::vec3(3.7791683f, 0.6222278f, 3.2142156f), + glm::vec3(0.6870481f, -1.1670776f, -4.8131480f), + glm::vec3(-0.3168385f, 4.8152132f, 1.3087492f), + }; + checkBufferContents(buffer.cesium.data, expected); + } + + { + uint32_t accessorId = static_cast(attributes.at("COLOR_0")); + Accessor& accessor = gltf.accessors[accessorId]; + CHECK(!accessor.normalized); + + uint32_t bufferViewId = static_cast(accessor.bufferView); + BufferView& bufferView = gltf.bufferViews[bufferViewId]; + + uint32_t bufferId = static_cast(bufferView.buffer); + Buffer& buffer = gltf.buffers[bufferId]; + + std::vector expected = { + glm::vec3(0.4761772f, 0.6870308f, 0.3250369f), + glm::vec3(0.1510580f, 0.3537409f, 0.3786762f), + glm::vec3(0.7742273f, 0.0016869f, 0.9157501f), + glm::vec3(0.5924380f, 0.6320426f, 0.2427963f), + glm::vec3(0.8433697f, 0.6730490f, 0.0029323f), + glm::vec3(0.0001751f, 0.1087111f, 0.6661169f), + glm::vec3(0.7299188f, 0.7299188f, 0.9489649f), + glm::vec3(0.1801442f, 0.2348952f, 0.5795466f), + }; + checkBufferContents(buffer.cesium.data, expected); + } + + { + uint32_t accessorId = static_cast(attributes.at("NORMAL")); + Accessor& accessor = gltf.accessors[accessorId]; + + uint32_t bufferViewId = static_cast(accessor.bufferView); + BufferView& bufferView = gltf.bufferViews[bufferViewId]; + + uint32_t bufferId = static_cast(bufferView.buffer); + Buffer& buffer = gltf.buffers[bufferId]; + + // The Draco-decoded normals are slightly different from the values + // derived by manually decoding the uncompressed oct-encoded normals, + // hence the less precise comparison. + std::vector expected{ + glm::vec3(-0.9824559f, 0.1803542f, 0.0474616f), + glm::vec3(-0.5766854f, 0.5427628f, 0.6106081f), + glm::vec3(-0.5725988f, -0.7802446f, -0.2516918f), + glm::vec3(-0.5705807f, -0.7345407f, 0.36727036f), + glm::vec3(-0.8560267f, -0.1281128f, -0.5008047f), + glm::vec3(0.7647877f, 0.11264316f, 0.63435888f), + glm::vec3(0.1301889f, -0.23434004f, -0.9633979f), + glm::vec3(-0.0450783f, 0.9616723f, 0.2704703f), + }; + checkBufferContents( + buffer.cesium.data, + expected, + Math::Epsilon1); + } +} + +TEST_CASE("Converts point cloud with partial Draco compression to glTF") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = testFilePath / "PointCloud" / "pointCloudDracoPartial.pnts"; + const int32_t pointsLength = 8; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + CHECK(gltf.hasExtension()); + CHECK(gltf.hasExtension()); + + CHECK(gltf.nodes.size() == 1); + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK(primitive.mode == MeshPrimitive::Mode::POINTS); + + auto primitiveExtension = + primitive.getExtension(); + REQUIRE(primitiveExtension); + REQUIRE(primitiveExtension->featureIdAttributes.size() == 1); + FeatureIDAttribute& attribute = primitiveExtension->featureIdAttributes[0]; + CHECK(attribute.featureTable == "default"); + // Check for implicit feature IDs + CHECK(!attribute.featureIds.attribute); + CHECK(attribute.featureIds.constant == 0); + CHECK(attribute.featureIds.divisor == 1); + + REQUIRE(gltf.materials.size() == 1); + Material& material = gltf.materials[0]; + CHECK(!material.hasExtension()); + + // The file has three binary metadata properties: + // - "temperature": float scalars + // - "secondaryColor": float vec3s + // - "id": unsigned short scalars + // There are three accessors (one per primitive attribute) + // and three additional buffer views: + // - temperature buffer view + // - secondary color buffer view + // - id buffer view + REQUIRE(gltf.accessors.size() == 3); + REQUIRE(gltf.bufferViews.size() == 6); + + // There is only one added buffer containing all the binary values. + REQUIRE(gltf.buffers.size() == 4); + + auto attributes = primitive.attributes; + REQUIRE(attributes.size() == 3); + + // Check that position, color, and normal attributes are present + checkAttribute(gltf, primitive, "POSITION", pointsLength); + checkAttribute(gltf, primitive, "COLOR_0", pointsLength); + checkAttribute(gltf, primitive, "NORMAL", pointsLength); + + // Check each attribute more thoroughly + { + uint32_t accessorId = static_cast(attributes.at("POSITION")); + Accessor& accessor = gltf.accessors[accessorId]; + + const glm::vec3 expectedMin(-4.9270443f, -3.9144449f, -4.8131480f); + CHECK(accessor.min[0] == Approx(expectedMin.x)); + CHECK(accessor.min[1] == Approx(expectedMin.y)); + CHECK(accessor.min[2] == Approx(expectedMin.z)); + + const glm::vec3 expectedMax(3.7791683f, 4.8152132f, 3.2142156f); + CHECK(accessor.max[0] == Approx(expectedMax.x)); + CHECK(accessor.max[1] == Approx(expectedMax.y)); + CHECK(accessor.max[2] == Approx(expectedMax.z)); + + uint32_t bufferViewId = static_cast(accessor.bufferView); + BufferView& bufferView = gltf.bufferViews[bufferViewId]; + + uint32_t bufferId = static_cast(bufferView.buffer); + Buffer& buffer = gltf.buffers[bufferId]; + + std::vector expected = { + glm::vec3(-4.9270443f, 0.8337686f, 0.1705846f), + glm::vec3(-2.9789500f, 2.6891474f, 2.9824265f), + glm::vec3(-2.8329495f, -3.9144449f, -1.2851576f), + glm::vec3(-2.9022198f, -3.6128526f, 1.8772986f), + glm::vec3(-4.2673778f, -0.6459517f, -2.5240305f), + glm::vec3(3.7791683f, 0.6222278f, 3.2142156f), + glm::vec3(0.6870481f, -1.1670776f, -4.8131480f), + glm::vec3(-0.3168385f, 4.8152132f, 1.3087492f), + }; + checkBufferContents(buffer.cesium.data, expected); + } + + { + uint32_t accessorId = static_cast(attributes.at("COLOR_0")); + Accessor& accessor = gltf.accessors[accessorId]; + CHECK(!accessor.normalized); + + uint32_t bufferViewId = static_cast(accessor.bufferView); + BufferView& bufferView = gltf.bufferViews[bufferViewId]; + + uint32_t bufferId = static_cast(bufferView.buffer); + Buffer& buffer = gltf.buffers[bufferId]; + + std::vector expected = { + glm::vec3(0.4761772f, 0.6870308f, 0.3250369f), + glm::vec3(0.1510580f, 0.3537409f, 0.3786762f), + glm::vec3(0.7742273f, 0.0016869f, 0.9157501f), + glm::vec3(0.5924380f, 0.6320426f, 0.2427963f), + glm::vec3(0.8433697f, 0.6730490f, 0.0029323f), + glm::vec3(0.0001751f, 0.1087111f, 0.6661169f), + glm::vec3(0.7299188f, 0.7299188f, 0.9489649f), + glm::vec3(0.1801442f, 0.2348952f, 0.5795466f), + }; + checkBufferContents(buffer.cesium.data, expected); + } + + { + uint32_t accessorId = static_cast(attributes.at("NORMAL")); + Accessor& accessor = gltf.accessors[accessorId]; + + uint32_t bufferViewId = static_cast(accessor.bufferView); + BufferView& bufferView = gltf.bufferViews[bufferViewId]; + + uint32_t bufferId = static_cast(bufferView.buffer); + Buffer& buffer = gltf.buffers[bufferId]; + + const std::vector expected = { + glm::vec3(-0.9854088, 0.1667507, 0.0341110), + glm::vec3(-0.5957704, 0.5378777, 0.5964436), + glm::vec3(-0.5666092, -0.7828890, -0.2569800), + glm::vec3(-0.5804154, -0.7226123, 0.3754320), + glm::vec3(-0.8535281, -0.1291752, -0.5047805), + glm::vec3(0.7557975, 0.1243999, 0.6428800), + glm::vec3(0.1374090, -0.2333731, -0.9626296), + glm::vec3(-0.0633145, 0.9630424, 0.2618022)}; + + checkBufferContents(buffer.cesium.data, expected); + } +} + +TEST_CASE("Converts batched point cloud with Draco compression to glTF") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = testFilePath / "PointCloud" / "pointCloudDracoBatched.pnts"; + const int32_t pointsLength = 8; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + // The correctness of the model extension is thoroughly tested in + // TestUpgradeBatchTableToExtFeatureMetadata + CHECK(gltf.hasExtension()); + + CHECK(gltf.nodes.size() == 1); + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK(primitive.mode == MeshPrimitive::Mode::POINTS); + + auto primitiveExtension = + primitive.getExtension(); + REQUIRE(primitiveExtension); + REQUIRE(primitiveExtension->featureIdAttributes.size() == 1); + FeatureIDAttribute& attribute = primitiveExtension->featureIdAttributes[0]; + CHECK(attribute.featureTable == "default"); + CHECK(attribute.featureIds.attribute == "_FEATURE_ID_0"); + + CHECK(gltf.materials.size() == 1); + + // The file has three metadata properties: + // - "name": string scalars in JSON + // - "dimensions": float vec3s in binary + // - "id": int scalars in binary + // There are four accessors (one per primitive attribute) + // and four additional buffer views: + // - "name" string data buffer view + // - "name" string offsets buffer view + // - "dimensions" buffer view + // - "id" buffer view + REQUIRE(gltf.accessors.size() == 4); + REQUIRE(gltf.bufferViews.size() == 8); + + // There are also three added buffers: + // - binary data in the batch table + // - string data of "name" + // - string offsets for the data for "name" + REQUIRE(gltf.buffers.size() == 7); + std::set bufferSet = getUniqueBufferIds(gltf.bufferViews); + CHECK(bufferSet.size() == 7); + + auto attributes = primitive.attributes; + REQUIRE(attributes.size() == 4); + + // Check that position, normal, and feature ID attributes are present + checkAttribute(gltf, primitive, "POSITION", pointsLength); + checkAttribute(gltf, primitive, "COLOR_0", pointsLength); + checkAttribute(gltf, primitive, "NORMAL", pointsLength); + checkAttribute(gltf, primitive, "_FEATURE_ID_0", pointsLength); + + // Check each attribute more thoroughly + { + uint32_t accessorId = static_cast(attributes.at("POSITION")); + Accessor& accessor = gltf.accessors[accessorId]; + + const glm::vec3 expectedMin(-4.9270443f, -3.9144449f, -4.8131480f); + CHECK(accessor.min[0] == Approx(expectedMin.x)); + CHECK(accessor.min[1] == Approx(expectedMin.y)); + CHECK(accessor.min[2] == Approx(expectedMin.z)); + + const glm::vec3 expectedMax(3.7791683f, 4.8152132f, 3.2142156f); + CHECK(accessor.max[0] == Approx(expectedMax.x)); + CHECK(accessor.max[1] == Approx(expectedMax.y)); + CHECK(accessor.max[2] == Approx(expectedMax.z)); + + uint32_t bufferViewId = static_cast(accessor.bufferView); + BufferView& bufferView = gltf.bufferViews[bufferViewId]; + + uint32_t bufferId = static_cast(bufferView.buffer); + Buffer& buffer = gltf.buffers[bufferId]; + + std::vector expected = { + glm::vec3(-4.9270443f, 0.8337686f, 0.1705846f), + glm::vec3(-2.9789500f, 2.6891474f, 2.9824265f), + glm::vec3(-2.8329495f, -3.9144449f, -1.2851576f), + glm::vec3(-2.9022198f, -3.6128526f, 1.8772986f), + glm::vec3(-4.2673778f, -0.6459517f, -2.5240305f), + glm::vec3(3.7791683f, 0.6222278f, 3.2142156f), + glm::vec3(0.6870481f, -1.1670776f, -4.8131480f), + glm::vec3(-0.3168385f, 4.8152132f, 1.3087492f), + }; + checkBufferContents(buffer.cesium.data, expected); + } + + { + uint32_t accessorId = static_cast(attributes.at("COLOR_0")); + Accessor& accessor = gltf.accessors[accessorId]; + CHECK(!accessor.normalized); + + uint32_t bufferViewId = static_cast(accessor.bufferView); + BufferView& bufferView = gltf.bufferViews[bufferViewId]; + + uint32_t bufferId = static_cast(bufferView.buffer); + Buffer& buffer = gltf.buffers[bufferId]; + + std::vector expected = { + glm::vec3(0.4761772f, 0.6870308f, 0.3250369f), + glm::vec3(0.1510580f, 0.3537409f, 0.3786762f), + glm::vec3(0.7742273f, 0.0016869f, 0.9157501f), + glm::vec3(0.5924380f, 0.6320426f, 0.2427963f), + glm::vec3(0.8433697f, 0.6730490f, 0.0029323f), + glm::vec3(0.0001751f, 0.1087111f, 0.6661169f), + glm::vec3(0.7299188f, 0.7299188f, 0.9489649f), + glm::vec3(0.1801442f, 0.2348952f, 0.5795466f), + }; + checkBufferContents(buffer.cesium.data, expected); + } + + { + uint32_t accessorId = static_cast(attributes.at("NORMAL")); + Accessor& accessor = gltf.accessors[accessorId]; + + uint32_t bufferViewId = static_cast(accessor.bufferView); + BufferView& bufferView = gltf.bufferViews[bufferViewId]; + + uint32_t bufferId = static_cast(bufferView.buffer); + Buffer& buffer = gltf.buffers[bufferId]; + + // The Draco-decoded normals are slightly different from the values + // derived by manually decoding the uncompressed oct-encoded normals, + // hence the less precise comparison. + std::vector expected{ + glm::vec3(-0.9824559f, 0.1803542f, 0.0474616f), + glm::vec3(-0.5766854f, 0.5427628f, 0.6106081f), + glm::vec3(-0.5725988f, -0.7802446f, -0.2516918f), + glm::vec3(-0.5705807f, -0.7345407f, 0.36727036f), + glm::vec3(-0.8560267f, -0.1281128f, -0.5008047f), + glm::vec3(0.7647877f, 0.11264316f, 0.63435888f), + glm::vec3(0.1301889f, -0.23434004f, -0.9633979f), + glm::vec3(-0.0450783f, 0.9616723f, 0.2704703f), + }; + checkBufferContents( + buffer.cesium.data, + expected, + Math::Epsilon1); + } + + { + uint32_t accessorId = static_cast(attributes.at("_FEATURE_ID_0")); + Accessor& accessor = gltf.accessors[accessorId]; + + uint32_t bufferViewId = static_cast(accessor.bufferView); + BufferView& bufferView = gltf.bufferViews[bufferViewId]; + + uint32_t bufferId = static_cast(bufferView.buffer); + Buffer& buffer = gltf.buffers[bufferId]; + + const std::vector expected = {5, 5, 6, 6, 7, 0, 3, 1}; + checkBufferContents(buffer.cesium.data, expected); + } +} diff --git a/Cesium3DTilesSelection/test/TestUpgradeBatchTableToExtFeatureMetadata.cpp b/Cesium3DTilesSelection/test/TestUpgradeBatchTableToExtFeatureMetadata.cpp index d8c31f9ee..3c2e62584 100644 --- a/Cesium3DTilesSelection/test/TestUpgradeBatchTableToExtFeatureMetadata.cpp +++ b/Cesium3DTilesSelection/test/TestUpgradeBatchTableToExtFeatureMetadata.cpp @@ -1,6 +1,5 @@ -#include "B3dmToGltfConverter.h" #include "BatchTableToGltfFeatureMetadata.h" -#include "readFile.h" +#include "ConvertTileToGltf.h" #include #include @@ -137,7 +136,7 @@ static void createTestForScalarJson( scalarProperty, batchTableJson.GetAllocator()); - auto errors = BatchTableToGltfFeatureMetadata::convert( + auto errors = BatchTableToGltfFeatureMetadata::convertFromB3dm( featureTableJson, batchTableJson, gsl::span(), @@ -213,7 +212,7 @@ static void createTestForArrayJson( fixedArrayProperties, batchTableJson.GetAllocator()); - auto errors = BatchTableToGltfFeatureMetadata::convert( + auto errors = BatchTableToGltfFeatureMetadata::convertFromB3dm( featureTableJson, batchTableJson, gsl::span(), @@ -246,15 +245,34 @@ static void createTestForArrayJson( totalInstances); } -GltfConverterResult loadB3dm(const std::filesystem::path& filePath) { - return B3dmToGltfConverter::convert(readFile(filePath), {}); +std::set getUniqueBufferViewIds( + const std::vector& accessors, + FeatureTable& featureTable) { + std::set result; + for (auto it = accessors.begin(); it != accessors.end(); it++) { + result.insert(it->bufferView); + } + + auto& properties = featureTable.properties; + for (auto it = properties.begin(); it != properties.end(); it++) { + auto& property = it->second; + result.insert(property.bufferView); + if (property.arrayOffsetBufferView >= 0) { + result.insert(property.arrayOffsetBufferView); + } + if (property.stringOffsetBufferView >= 0) { + result.insert(property.stringOffsetBufferView); + } + } + + return result; } -TEST_CASE("Converts simple batch table to EXT_feature_metadata") { +TEST_CASE("Converts JSON B3DM batch table to EXT_feature_metadata") { std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; testFilePath = testFilePath / "BatchTables" / "batchedWithJson.b3dm"; - GltfConverterResult result = loadB3dm(testFilePath); + GltfConverterResult result = ConvertTileToGltf::fromB3dm(testFilePath); REQUIRE(result.model); @@ -434,12 +452,12 @@ TEST_CASE("Converts simple batch table to EXT_feature_metadata") { } } -TEST_CASE("Convert binary batch table to EXT_feature_metadata") { +TEST_CASE("Convert binary B3DM batch table to EXT_feature_metadata") { std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; testFilePath = testFilePath / "BatchTables" / "batchedWithBatchTableBinary.b3dm"; - GltfConverterResult result = loadB3dm(testFilePath); + GltfConverterResult result = ConvertTileToGltf::fromB3dm(testFilePath); REQUIRE(!result.errors); REQUIRE(result.model != std::nullopt); @@ -578,12 +596,458 @@ TEST_CASE("Convert binary batch table to EXT_feature_metadata") { } } +TEST_CASE("Converts batched PNTS batch table to EXT_feature_metadata") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = testFilePath / "PointCloud" / "pointCloudBatched.pnts"; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + ExtensionModelExtFeatureMetadata* pExtension = + gltf.getExtension(); + REQUIRE(pExtension); + + // Check the schema + REQUIRE(pExtension->schema); + REQUIRE(pExtension->schema->classes.size() == 1); + + auto firstClassIt = pExtension->schema->classes.begin(); + CHECK(firstClassIt->first == "default"); + + CesiumGltf::Class& defaultClass = firstClassIt->second; + REQUIRE(defaultClass.properties.size() == 3); + + { + auto nameIt = defaultClass.properties.find("name"); + REQUIRE(nameIt != defaultClass.properties.end()); + auto dimensionsIt = defaultClass.properties.find("dimensions"); + REQUIRE(dimensionsIt != defaultClass.properties.end()); + auto idIt = defaultClass.properties.find("id"); + REQUIRE(idIt != defaultClass.properties.end()); + + CHECK(nameIt->second.type == "STRING"); + CHECK(dimensionsIt->second.type == "ARRAY"); + REQUIRE(dimensionsIt->second.componentType); + CHECK(dimensionsIt->second.componentType.value() == "FLOAT32"); + CHECK(idIt->second.type == "UINT32"); + } + + // Check the feature table + auto firstFeatureTableIt = pExtension->featureTables.begin(); + REQUIRE(firstFeatureTableIt != pExtension->featureTables.end()); + + FeatureTable& featureTable = firstFeatureTableIt->second; + CHECK(featureTable.classProperty == "default"); + REQUIRE(featureTable.properties.size() == 3); + + { + auto nameIt = featureTable.properties.find("name"); + REQUIRE(nameIt != featureTable.properties.end()); + auto dimensionsIt = featureTable.properties.find("dimensions"); + REQUIRE(dimensionsIt != featureTable.properties.end()); + auto idIt = featureTable.properties.find("id"); + REQUIRE(idIt != featureTable.properties.end()); + + CHECK(nameIt->second.bufferView >= 0); + CHECK( + nameIt->second.bufferView < + static_cast(gltf.bufferViews.size())); + CHECK(dimensionsIt->second.bufferView >= 0); + CHECK( + dimensionsIt->second.bufferView < + static_cast(gltf.bufferViews.size())); + CHECK(idIt->second.bufferView >= 0); + CHECK( + idIt->second.bufferView < + static_cast(gltf.bufferViews.size())); + } + + std::set bufferViewSet = + getUniqueBufferViewIds(gltf.accessors, featureTable); + CHECK(bufferViewSet.size() == gltf.bufferViews.size()); + + // Check the mesh primitive + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK( + primitive.attributes.find("_FEATURE_ID_0") != primitive.attributes.end()); + + ExtensionMeshPrimitiveExtFeatureMetadata* pPrimitiveExtension = + primitive.getExtension(); + REQUIRE(pPrimitiveExtension); + REQUIRE(pPrimitiveExtension->featureIdAttributes.size() == 1); + + FeatureIDAttribute& attribute = pPrimitiveExtension->featureIdAttributes[0]; + CHECK(attribute.featureTable == "default"); + CHECK(attribute.featureIds.attribute == "_FEATURE_ID_0"); + + // Check metadata values + { + std::vector expected = { + "section0", + "section1", + "section2", + "section3", + "section4", + "section5", + "section6", + "section7"}; + checkScalarProperty( + *result.model, + featureTable, + defaultClass, + "name", + "STRING", + expected, + expected.size()); + } + + { + std::vector> expected = { + {0.1182744f, 0.7206326f, 0.6399210f}, + {0.5820198f, 0.1433532f, 0.5373732f}, + {0.9446688f, 0.7586156f, 0.5218483f}, + {0.1059076f, 0.4146619f, 0.4736004f}, + {0.2645556f, 0.1863323f, 0.7742336f}, + {0.7369181f, 0.4561503f, 0.2165503f}, + {0.5684339f, 0.1352181f, 0.0187897f}, + {0.3241409f, 0.6176354f, 0.1496748f}}; + + checkArrayProperty( + *result.model, + featureTable, + defaultClass, + "dimensions", + 3, + "FLOAT32", + expected, + expected.size()); + } + + { + std::vector expected = {0, 1, 2, 3, 4, 5, 6, 7}; + checkScalarProperty( + *result.model, + featureTable, + defaultClass, + "id", + "UINT32", + expected, + expected.size()); + } +} + +TEST_CASE("Converts per-point PNTS batch table to EXT_feature_metadata") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = + testFilePath / "PointCloud" / "pointCloudWithPerPointProperties.pnts"; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + ExtensionModelExtFeatureMetadata* pExtension = + gltf.getExtension(); + REQUIRE(pExtension); + + // Check the schema + REQUIRE(pExtension->schema); + REQUIRE(pExtension->schema->classes.size() == 1); + + auto firstClassIt = pExtension->schema->classes.begin(); + CHECK(firstClassIt->first == "default"); + + CesiumGltf::Class& defaultClass = firstClassIt->second; + REQUIRE(defaultClass.properties.size() == 3); + + { + auto temperatureIt = defaultClass.properties.find("temperature"); + REQUIRE(temperatureIt != defaultClass.properties.end()); + auto secondaryColorIt = defaultClass.properties.find("secondaryColor"); + REQUIRE(secondaryColorIt != defaultClass.properties.end()); + auto idIt = defaultClass.properties.find("id"); + REQUIRE(idIt != defaultClass.properties.end()); + + CHECK(temperatureIt->second.type == "FLOAT32"); + CHECK(secondaryColorIt->second.type == "ARRAY"); + REQUIRE(secondaryColorIt->second.componentType); + CHECK(secondaryColorIt->second.componentType.value() == "FLOAT32"); + CHECK(idIt->second.type == "UINT16"); + } + + // Check the feature table + auto firstFeatureTableIt = pExtension->featureTables.begin(); + REQUIRE(firstFeatureTableIt != pExtension->featureTables.end()); + + FeatureTable& featureTable = firstFeatureTableIt->second; + CHECK(featureTable.classProperty == "default"); + REQUIRE(featureTable.properties.size() == 3); + + { + auto temperatureIt = featureTable.properties.find("temperature"); + REQUIRE(temperatureIt != featureTable.properties.end()); + auto secondaryColorIt = featureTable.properties.find("secondaryColor"); + REQUIRE(secondaryColorIt != featureTable.properties.end()); + auto idIt = featureTable.properties.find("id"); + REQUIRE(idIt != featureTable.properties.end()); + + CHECK(temperatureIt->second.bufferView >= 0); + CHECK( + temperatureIt->second.bufferView < + static_cast(gltf.bufferViews.size())); + CHECK(secondaryColorIt->second.bufferView >= 0); + CHECK( + secondaryColorIt->second.bufferView < + static_cast(gltf.bufferViews.size())); + CHECK(idIt->second.bufferView >= 0); + CHECK( + idIt->second.bufferView < + static_cast(gltf.bufferViews.size())); + } + + std::set bufferViewSet = + getUniqueBufferViewIds(gltf.accessors, featureTable); + CHECK(bufferViewSet.size() == gltf.bufferViews.size()); + + // Check the mesh primitive + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK( + primitive.attributes.find("_FEATURE_ID_0") == primitive.attributes.end()); + + ExtensionMeshPrimitiveExtFeatureMetadata* pPrimitiveExtension = + primitive.getExtension(); + REQUIRE(pPrimitiveExtension); + REQUIRE(pPrimitiveExtension->featureIdAttributes.size() == 1); + + FeatureIDAttribute& attribute = pPrimitiveExtension->featureIdAttributes[0]; + CHECK(attribute.featureTable == "default"); + // Check for implicit feature IDs + CHECK(!attribute.featureIds.attribute); + CHECK(attribute.featureIds.constant == 0); + CHECK(attribute.featureIds.divisor == 1); + + // Check metadata values + { + std::vector expected = { + 0.2883332f, + 0.4338732f, + 0.1750928f, + 0.1430827f, + 0.1156976f, + 0.3274261f, + 0.1337213f, + 0.0207673f}; + checkScalarProperty( + *result.model, + featureTable, + defaultClass, + "temperature", + "FLOAT32", + expected, + expected.size()); + } + + { + std::vector> expected = { + {0.0202183f, 0, 0}, + {0.3682415f, 0, 0}, + {0.8326198f, 0, 0}, + {0.9571551f, 0, 0}, + {0.7781567f, 0, 0}, + {0.1403507f, 0, 0}, + {0.8700121f, 0, 0}, + {0.8700872f, 0, 0}}; + + checkArrayProperty( + *result.model, + featureTable, + defaultClass, + "secondaryColor", + 3, + "FLOAT32", + expected, + expected.size()); + } + + { + std::vector expected = {0, 1, 2, 3, 4, 5, 6, 7}; + checkScalarProperty( + *result.model, + featureTable, + defaultClass, + "id", + "UINT16", + expected, + expected.size()); + } +} + +TEST_CASE("Converts Draco per-point PNTS batch table to " + "EXT_feature_metadata") { + std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; + testFilePath = testFilePath / "PointCloud" / "pointCloudDraco.pnts"; + + GltfConverterResult result = ConvertTileToGltf::fromPnts(testFilePath); + + REQUIRE(result.model); + Model& gltf = *result.model; + + ExtensionModelExtFeatureMetadata* pExtension = + gltf.getExtension(); + REQUIRE(pExtension); + + // Check the schema + REQUIRE(pExtension->schema); + REQUIRE(pExtension->schema->classes.size() == 1); + + auto firstClassIt = pExtension->schema->classes.begin(); + CHECK(firstClassIt->first == "default"); + + CesiumGltf::Class& defaultClass = firstClassIt->second; + REQUIRE(defaultClass.properties.size() == 3); + + { + auto temperatureIt = defaultClass.properties.find("temperature"); + REQUIRE(temperatureIt != defaultClass.properties.end()); + auto secondaryColorIt = defaultClass.properties.find("secondaryColor"); + REQUIRE(secondaryColorIt != defaultClass.properties.end()); + auto idIt = defaultClass.properties.find("id"); + REQUIRE(idIt != defaultClass.properties.end()); + + CHECK(temperatureIt->second.type == "FLOAT32"); + CHECK(secondaryColorIt->second.type == "ARRAY"); + REQUIRE(secondaryColorIt->second.componentType); + CHECK(secondaryColorIt->second.componentType.value() == "FLOAT32"); + CHECK(idIt->second.type == "UINT16"); + } + + // Check the feature table + auto firstFeatureTableIt = pExtension->featureTables.begin(); + REQUIRE(firstFeatureTableIt != pExtension->featureTables.end()); + + FeatureTable& featureTable = firstFeatureTableIt->second; + CHECK(featureTable.classProperty == "default"); + REQUIRE(featureTable.properties.size() == 3); + + { + auto temperatureIt = featureTable.properties.find("temperature"); + REQUIRE(temperatureIt != featureTable.properties.end()); + auto secondaryColorIt = featureTable.properties.find("secondaryColor"); + REQUIRE(secondaryColorIt != featureTable.properties.end()); + auto idIt = featureTable.properties.find("id"); + REQUIRE(idIt != featureTable.properties.end()); + + CHECK(temperatureIt->second.bufferView >= 0); + CHECK( + temperatureIt->second.bufferView < + static_cast(gltf.bufferViews.size())); + CHECK(secondaryColorIt->second.bufferView >= 0); + CHECK( + secondaryColorIt->second.bufferView < + static_cast(gltf.bufferViews.size())); + CHECK(idIt->second.bufferView >= 0); + CHECK( + idIt->second.bufferView < + static_cast(gltf.bufferViews.size())); + } + + std::set bufferViewSet = + getUniqueBufferViewIds(gltf.accessors, featureTable); + CHECK(bufferViewSet.size() == gltf.bufferViews.size()); + + // Check the mesh primitive + REQUIRE(gltf.meshes.size() == 1); + Mesh& mesh = gltf.meshes[0]; + REQUIRE(mesh.primitives.size() == 1); + + MeshPrimitive& primitive = mesh.primitives[0]; + CHECK( + primitive.attributes.find("_FEATURE_ID_0") == primitive.attributes.end()); + + ExtensionMeshPrimitiveExtFeatureMetadata* pPrimitiveExtension = + primitive.getExtension(); + REQUIRE(pPrimitiveExtension); + REQUIRE(pPrimitiveExtension->featureIdAttributes.size() == 1); + + FeatureIDAttribute& attribute = pPrimitiveExtension->featureIdAttributes[0]; + CHECK(attribute.featureTable == "default"); + // Check for implicit feature IDs + CHECK(!attribute.featureIds.attribute); + CHECK(attribute.featureIds.constant == 0); + CHECK(attribute.featureIds.divisor == 1); + + // Check metadata values + { + std::vector expected = { + 0.2883025f, + 0.4338731f, + 0.1751145f, + 0.1430345f, + 0.1156959f, + 0.3274441f, + 0.1337535f, + 0.0207673f}; + checkScalarProperty( + *result.model, + featureTable, + defaultClass, + "temperature", + "FLOAT32", + expected, + expected.size()); + } + + { + std::vector> expected = { + {0.1182744f, 0, 0}, + {0.7206645f, 0, 0}, + {0.6399421f, 0, 0}, + {0.5820239f, 0, 0}, + {0.1432983f, 0, 0}, + {0.5374249f, 0, 0}, + {0.9446688f, 0, 0}, + {0.7586040f, 0, 0}}; + + checkArrayProperty( + *result.model, + featureTable, + defaultClass, + "secondaryColor", + 3, + "FLOAT32", + expected, + expected.size()); + } + + { + std::vector expected = {0, 1, 2, 3, 4, 5, 6, 7}; + checkScalarProperty( + *result.model, + featureTable, + defaultClass, + "id", + "UINT16", + expected, + expected.size()); + } +} + TEST_CASE("Upgrade json nested json metadata to string") { std::filesystem::path testFilePath = Cesium3DTilesSelection_TEST_DATA_DIR; testFilePath = testFilePath / "BatchTables" / "batchedWithStringAndNestedJson.b3dm"; - GltfConverterResult result = loadB3dm(testFilePath); + GltfConverterResult result = ConvertTileToGltf::fromB3dm(testFilePath); REQUIRE(!result.errors); REQUIRE(result.model != std::nullopt); @@ -671,7 +1135,7 @@ TEST_CASE("Upgrade bool json to boolean binary") { boolProperties, batchTableJson.GetAllocator()); - auto errors = BatchTableToGltfFeatureMetadata::convert( + auto errors = BatchTableToGltfFeatureMetadata::convertFromB3dm( featureTableJson, batchTableJson, gsl::span(), @@ -1213,7 +1677,7 @@ TEST_CASE("Converts Feature Classes 3DTILES_batch_table_hierarchy example to " rapidjson::Document batchTableParsed; batchTableParsed.Parse(batchTableJson.data(), batchTableJson.size()); - auto errors = BatchTableToGltfFeatureMetadata::convert( + auto errors = BatchTableToGltfFeatureMetadata::convertFromB3dm( featureTableParsed, batchTableParsed, gsl::span(), @@ -1343,7 +1807,7 @@ TEST_CASE("Converts Feature Hierarchy 3DTILES_batch_table_hierarchy example to " rapidjson::Document batchTableParsed; batchTableParsed.Parse(batchTableJson.data(), batchTableJson.size()); - BatchTableToGltfFeatureMetadata::convert( + BatchTableToGltfFeatureMetadata::convertFromB3dm( featureTableParsed, batchTableParsed, gsl::span(), @@ -1537,7 +2001,7 @@ TEST_CASE( auto pLog = std::make_shared(3); spdlog::default_logger()->sinks().emplace_back(pLog); - BatchTableToGltfFeatureMetadata::convert( + BatchTableToGltfFeatureMetadata::convertFromB3dm( featureTableParsed, batchTableParsed, gsl::span(), @@ -1629,7 +2093,7 @@ TEST_CASE( auto pLog = std::make_shared(3); spdlog::default_logger()->sinks().emplace_back(pLog); - auto errors = BatchTableToGltfFeatureMetadata::convert( + auto errors = BatchTableToGltfFeatureMetadata::convertFromB3dm( featureTableParsed, batchTableParsed, gsl::span(), diff --git a/Cesium3DTilesSelection/test/data/PointCloud/pointCloudBatched.pnts b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudBatched.pnts new file mode 100644 index 000000000..db6b93950 Binary files /dev/null and b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudBatched.pnts differ diff --git a/Cesium3DTilesSelection/test/data/PointCloud/pointCloudConstantRGBA.pnts b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudConstantRGBA.pnts new file mode 100644 index 000000000..9a9efd835 Binary files /dev/null and b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudConstantRGBA.pnts differ diff --git a/Cesium3DTilesSelection/test/data/PointCloud/pointCloudDraco.pnts b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudDraco.pnts new file mode 100644 index 000000000..91c37703d Binary files /dev/null and b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudDraco.pnts differ diff --git a/Cesium3DTilesSelection/test/data/PointCloud/pointCloudDracoBatched.pnts b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudDracoBatched.pnts new file mode 100644 index 000000000..0aabaac25 Binary files /dev/null and b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudDracoBatched.pnts differ diff --git a/Cesium3DTilesSelection/test/data/PointCloud/pointCloudDracoPartial.pnts b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudDracoPartial.pnts new file mode 100644 index 000000000..83ddd17cd Binary files /dev/null and b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudDracoPartial.pnts differ diff --git a/Cesium3DTilesSelection/test/data/PointCloud/pointCloudNormals.pnts b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudNormals.pnts new file mode 100644 index 000000000..cb03c6bdb Binary files /dev/null and b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudNormals.pnts differ diff --git a/Cesium3DTilesSelection/test/data/PointCloud/pointCloudNormalsOctEncoded.pnts b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudNormalsOctEncoded.pnts new file mode 100644 index 000000000..28a801f7d Binary files /dev/null and b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudNormalsOctEncoded.pnts differ diff --git a/Cesium3DTilesSelection/test/data/PointCloud/pointCloudPositionsOnly.pnts b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudPositionsOnly.pnts new file mode 100644 index 000000000..58f2e7774 Binary files /dev/null and b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudPositionsOnly.pnts differ diff --git a/Cesium3DTilesSelection/test/data/PointCloud/pointCloudQuantized.pnts b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudQuantized.pnts new file mode 100644 index 000000000..3b1c9a2cc Binary files /dev/null and b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudQuantized.pnts differ diff --git a/Cesium3DTilesSelection/test/data/PointCloud/pointCloudRGB.pnts b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudRGB.pnts new file mode 100644 index 000000000..f6ebcd1ed Binary files /dev/null and b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudRGB.pnts differ diff --git a/Cesium3DTilesSelection/test/data/PointCloud/pointCloudRGB565.pnts b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudRGB565.pnts new file mode 100644 index 000000000..69e94b31f Binary files /dev/null and b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudRGB565.pnts differ diff --git a/Cesium3DTilesSelection/test/data/PointCloud/pointCloudRGBA.pnts b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudRGBA.pnts new file mode 100644 index 000000000..3d6859933 Binary files /dev/null and b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudRGBA.pnts differ diff --git a/Cesium3DTilesSelection/test/data/PointCloud/pointCloudWithPerPointProperties.pnts b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudWithPerPointProperties.pnts new file mode 100644 index 000000000..638bad7d5 Binary files /dev/null and b/Cesium3DTilesSelection/test/data/PointCloud/pointCloudWithPerPointProperties.pnts differ diff --git a/CesiumUtility/include/CesiumUtility/AttributeCompression.h b/CesiumUtility/include/CesiumUtility/AttributeCompression.h new file mode 100644 index 000000000..1c468b855 --- /dev/null +++ b/CesiumUtility/include/CesiumUtility/AttributeCompression.h @@ -0,0 +1,82 @@ +#pragma once + +#include "Library.h" +#include "Math.h" + +#include + +namespace CesiumUtility { +/** + * @brief Functions to handle compressed attributes in different formats + */ +class CESIUMUTILITY_API AttributeCompression final { + +public: + /** + * @brief Decodes a unit-length vector in 'oct' encoding to a normalized + * 3-component vector. + * + * @param x The x component of the oct-encoded unit length vector. + * @param y The y component of the oct-encoded unit length vector. + * @param rangeMax The maximum value of the SNORM range. The encoded vector is + * stored in log2(rangeMax+1) bits. + * @returns The decoded and normalized vector. + */ + template < + typename T, + class = typename std::enable_if::value>::type> + static glm::dvec3 octDecodeInRange(T x, T y, T rangeMax) { + glm::dvec3 result; + + result.x = CesiumUtility::Math::fromSNorm(x, rangeMax); + result.y = CesiumUtility::Math::fromSNorm(y, rangeMax); + result.z = 1.0 - (glm::abs(result.x) + glm::abs(result.y)); + + if (result.z < 0.0) { + const double oldVX = result.x; + result.x = + (1.0 - glm::abs(result.y)) * CesiumUtility::Math::signNotZero(oldVX); + result.y = + (1.0 - glm::abs(oldVX)) * CesiumUtility::Math::signNotZero(result.y); + } + + return glm::normalize(result); + } + + /** + * @brief Decodes a unit-length vector in 2 byte 'oct' encoding to a + * normalized 3-component vector. + * + * @param x The x component of the oct-encoded unit length vector. + * @param y The y component of the oct-encoded unit length vector. + * @returns The decoded and normalized vector. + * + * @see AttributeCompression::octDecodeInRange + */ + static glm::dvec3 octDecode(uint8_t x, uint8_t y) { + constexpr uint8_t rangeMax = 255; + return AttributeCompression::octDecodeInRange(x, y, rangeMax); + } + + /** + * @brief Decodes a RGB565-encoded color to a 3-component vector + * containing the normalized RGB values. + * + * @param value The RGB565-encoded value. + * @returns The normalized RGB values. + */ + static glm::dvec3 decodeRGB565(const uint16_t value) { + constexpr uint16_t mask5 = (1 << 5) - 1; + constexpr uint16_t mask6 = (1 << 6) - 1; + constexpr float normalize5 = 1.0f / 31.0f; // normalize [0, 31] to [0, 1] + constexpr float normalize6 = 1.0f / 63.0f; // normalize [0, 63] to [0, 1] + + const uint16_t red = static_cast(value >> 11); + const uint16_t green = static_cast((value >> 5) & mask6); + const uint16_t blue = value & mask5; + + return glm::dvec3(red * normalize5, green * normalize6, blue * normalize5); + }; +}; + +} // namespace CesiumUtility diff --git a/CesiumUtility/test/TestAttributeCompression.cpp b/CesiumUtility/test/TestAttributeCompression.cpp new file mode 100644 index 000000000..7f5f29b1f --- /dev/null +++ b/CesiumUtility/test/TestAttributeCompression.cpp @@ -0,0 +1,69 @@ +#include "CesiumUtility/AttributeCompression.h" +#include "CesiumUtility/Math.h" + +#include + +using namespace CesiumUtility; + +TEST_CASE("AttributeCompression::octDecode") { + const std::vector input{ + glm::u8vec2(128, 128), + glm::u8vec2(255, 255), + glm::u8vec2(128, 255), + glm::u8vec2(128, 0), + glm::u8vec2(255, 128), + glm::u8vec2(0, 128), + glm::u8vec2(170, 170), + glm::u8vec2(170, 85), + glm::u8vec2(85, 85), + glm::u8vec2(85, 170), + glm::u8vec2(213, 213), + glm::u8vec2(213, 42), + glm::u8vec2(42, 42), + glm::u8vec2(42, 213), + }; + const std::vector expected{ + glm::dvec3(0.0, 0.0, 1.0), + glm::dvec3(0.0, 0.0, -1.0), + glm::dvec3(0.0, 1.0, 0.0), + glm::dvec3(0.0, -1.0, 0.0), + glm::dvec3(1.0, 0.0, 0.0), + glm::dvec3(-1.0, 0.0, 0.0), + glm::normalize(glm::dvec3(1.0, 1.0, 1.0)), + glm::normalize(glm::dvec3(1.0, -1.0, 1.0)), + glm::normalize(glm::dvec3(-1.0, -1.0, 1.0)), + glm::normalize(glm::dvec3(-1.0, 1.0, 1.0)), + glm::normalize(glm::dvec3(1.0, 1.0, -1.0)), + glm::normalize(glm::dvec3(1.0, -1.0, -1.0)), + glm::normalize(glm::dvec3(-1.0, -1.0, -1.0)), + glm::normalize(glm::dvec3(-1.0, 1.0, -1.0)), + }; + + for (size_t i = 0; i < expected.size(); i++) { + const glm::dvec3 value = + AttributeCompression::octDecode(input[i].x, input[i].y); + CHECK(Math::equalsEpsilon(value, expected[i], Math::Epsilon1)); + } +} + +TEST_CASE("AttributeCompression::decodeRGB565") { + const std::vector input{ + 0, + // 0b00001_000001_00001 + 2081, + // 0b10000_100000_01000 + 33800, + // 0b11111_111111_11111 + 65535, + }; + const std::vector expected{ + glm::dvec3(0.0), + glm::dvec3(1.0 / 31.0, 1.0 / 63.0, 1.0 / 31.0), + glm::dvec3(16.0 / 31.0, 32.0 / 63.0, 8.0 / 31.0), + glm::dvec3(1.0)}; + + for (size_t i = 0; i < expected.size(); i++) { + const glm::dvec3 value = AttributeCompression::decodeRGB565(input[i]); + CHECK(Math::equalsEpsilon(value, expected[i], Math::Epsilon6)); + } +}