From 379c9ed0e4f9670366d26cc584dbed267986b5b0 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Thu, 10 Sep 2026 22:12:17 +0800 Subject: [PATCH] core: add scalar vector field snapshots --- .../particle/field/field_data_adapter.hpp | 42 +++++- .../field/local_particle_field_feed.hpp | 1 + .../particle/field/field_data_adapter.cpp | 128 ++++++++++-------- .../field/local_particle_field_feed.cpp | 115 ++++++++-------- .../render_particle_validation_layer.cpp | 24 ++-- .../layers/render_weather_color_layer.cpp | 15 +- test/style/source.test.cpp | 113 ++++++++++++++-- 7 files changed, 294 insertions(+), 144 deletions(-) diff --git a/include/mbgl/particle/field/field_data_adapter.hpp b/include/mbgl/particle/field/field_data_adapter.hpp index 0afcc8f35..3a7473ee7 100644 --- a/include/mbgl/particle/field/field_data_adapter.hpp +++ b/include/mbgl/particle/field/field_data_adapter.hpp @@ -7,11 +7,27 @@ namespace mbgl::particle { +enum class VectorSemantics : uint8_t { Velocity, Direction }; + +// A v2 payload is deliberately split into unambiguous planes. vector is +// interleaved eastward-U/northward-V; scalar is one float per cell; validity +// is the one shared logical mask for every present channel. +struct ParticleFieldPayloadBundle { + std::optional> scalar; + std::optional> vector; + std::optional> validity; +}; + +struct ParticleFieldPlaneDescriptor { + uint64_t byteLength = 0; + std::string encoding = "float32-le"; + std::string layout; + std::optional noData; +}; + // Provider-neutral description of the canonical field bytes consumed by Core. -// maplibre-particle-field/v1 is a north-west-origin regular grid: rows run -// north-to-south, columns west-to-east, and cell = y * width + x. The first -// float of each interleaved pair is eastward U; the second is northward V. -// Provider names, URLs and transport metadata deliberately do not cross this boundary. +// v1 fields below are retained only for the thin vector-only compatibility +// ingress. v2 uses scalar/vector/validity plane descriptors instead. struct ParticleFieldDescriptor { std::string fieldID; std::string fieldType; @@ -32,6 +48,10 @@ struct ParticleFieldDescriptor { std::string layout = "interleaved-uv"; std::string encoding = "float32-le"; std::string fieldSha256; + std::optional scalar; + std::optional vector; + std::optional validity; + std::optional vectorSemantics; }; struct ParticleFieldSnapshot { @@ -49,11 +69,15 @@ struct ParticleFieldSnapshot { uint64_t dataVersion = 0; uint64_t generation = 0; std::string fieldSha256; - std::vector velocity; + std::vector scalar; + std::vector vector; std::vector valid; + std::optional vectorSemantics; bool isValid() const; bool isTimeInterpolationCompatibleWith(const ParticleFieldSnapshot&) const; + bool hasScalar() const { return !scalar.empty(); } + bool hasVector() const { return !vector.empty(); } }; inline bool fieldIdentityChanged(const ParticleFieldSnapshot& current, const ParticleFieldSnapshot& next) { @@ -62,8 +86,10 @@ inline bool fieldIdentityChanged(const ParticleFieldSnapshot& current, const Par } inline bool sameFieldGpuContent(const ParticleFieldSnapshot& current, const ParticleFieldSnapshot& next) { - return current.fieldID == next.fieldID && current.dataVersion == next.dataVersion && - current.fieldSha256 == next.fieldSha256 && current.isTimeInterpolationCompatibleWith(next); + // Content identity is the decoded plane bundle, not producer identity, + // generation, dataVersion, or an optional legacy aggregate hash. + return current.isTimeInterpolationCompatibleWith(next) && current.scalar == next.scalar && + current.vector == next.vector && current.valid == next.valid; } struct ParticleFieldTimeWindow { @@ -96,6 +122,8 @@ struct ParticleFieldAdapterResult { class ParticleFieldDataAdapter final { public: + static ParticleFieldAdapterResult decode(const ParticleFieldDescriptor&, const ParticleFieldPayloadBundle&); + // Legacy v1 vector-only normalization entry point. static ParticleFieldAdapterResult decode(const ParticleFieldDescriptor&, const std::vector&, uint64_t generation); }; diff --git a/include/mbgl/particle/field/local_particle_field_feed.hpp b/include/mbgl/particle/field/local_particle_field_feed.hpp index 18f26eb80..ff401beff 100644 --- a/include/mbgl/particle/field/local_particle_field_feed.hpp +++ b/include/mbgl/particle/field/local_particle_field_feed.hpp @@ -12,6 +12,7 @@ namespace mbgl::particle { class LocalParticleFieldFeed final { public: static ParticleFieldAdapterResult load(const std::string& metadataText, const std::vector& payload); + static ParticleFieldAdapterResult load(const std::string& metadataText, const ParticleFieldPayloadBundle& payloads); }; } // namespace mbgl::particle diff --git a/src/mbgl/particle/field/field_data_adapter.cpp b/src/mbgl/particle/field/field_data_adapter.cpp index 240ab763c..cb15372f0 100644 --- a/src/mbgl/particle/field/field_data_adapter.cpp +++ b/src/mbgl/particle/field/field_data_adapter.cpp @@ -10,9 +10,16 @@ namespace { ParticleFieldAdapterResult reject(ParticleFieldAdapterError error, std::string message) { return {.snapshot = std::nullopt, .error = error, .message = std::move(message)}; } +bool validCommonDescriptor(const ParticleFieldDescriptor& d) { + return !d.fieldID.empty() && d.generation && d.validTime && d.width && d.height && d.west < d.east && d.south < d.north; +} +bool finitePlane(const std::vector& values) { + return std::all_of(values.begin(), values.end(), [](float value) { return std::isfinite(value); }); +} +bool isNoData(const std::optional& noData, float value) { return noData && value == *noData; } } // namespace -const char* particleFieldAdapterErrorName(const ParticleFieldAdapterError error) { +const char* particleFieldAdapterErrorName(ParticleFieldAdapterError error) { switch (error) { case ParticleFieldAdapterError::None: return "NONE"; case ParticleFieldAdapterError::InvalidDescriptor: return "INVALID_DESCRIPTOR"; @@ -27,67 +34,80 @@ const char* particleFieldAdapterErrorName(const ParticleFieldAdapterError error) } bool ParticleFieldSnapshot::isValid() const { - return !fieldID.empty() && fieldType == "WIND" && displayDomain == "GLOBAL" && width != 0 && height != 0 && - west < east && south < north && velocity.size() == static_cast(width) * height * 2 && - valid.size() == static_cast(width) * height && - std::all_of(velocity.begin(), velocity.end(), [](float value) { return std::isfinite(value); }); + const auto cells = static_cast(width) * height; + if (fieldID.empty() || !generation || !validTime || !width || !height || !(west < east) || !(south < north) || + valid.size() != cells || (!hasScalar() && !hasVector())) return false; + if (hasScalar() && scalar.size() != cells) return false; + if (hasVector() && (vector.size() != cells * 2 || !vectorSemantics)) return false; + return (!hasVector() ? !vectorSemantics : true) && finitePlane(scalar) && finitePlane(vector); } bool ParticleFieldSnapshot::isTimeInterpolationCompatibleWith(const ParticleFieldSnapshot& other) const { - return width == other.width && height == other.height && west == other.west && south == other.south && - east == other.east && north == other.north && noData == other.noData && fieldType == other.fieldType && - displayDomain == other.displayDomain; + return width == other.width && height == other.height && west == other.west && south == other.south && east == other.east && + north == other.north && hasScalar() == other.hasScalar() && hasVector() == other.hasVector() && + vectorSemantics == other.vectorSemantics; } -ParticleFieldAdapterResult ParticleFieldDataAdapter::decode(const ParticleFieldDescriptor& descriptor, - const std::vector& bytes, - const uint64_t generation) { - if (descriptor.fieldID.empty() || descriptor.fieldType.empty() || descriptor.displayDomain.empty() || - descriptor.generation == 0 || generation == 0 || descriptor.generation != generation) { - return reject(ParticleFieldAdapterError::InvalidTimestampOrIdentity, "field identity or generation is invalid"); - } - if (descriptor.width == 0 || descriptor.height == 0 || !(descriptor.west < descriptor.east) || - !(descriptor.south < descriptor.north) || !std::isfinite(descriptor.noData)) { - return reject(ParticleFieldAdapterError::InvalidDescriptor, "field dimensions, bounds or noData are invalid"); - } - if (descriptor.layout != "interleaved-uv" || descriptor.encoding != "float32-le") { - return reject(ParticleFieldAdapterError::UnsupportedFormat, "only interleaved-uv float32-le is supported"); - } - const uint64_t cells = static_cast(descriptor.width) * descriptor.height; - const uint64_t expected = cells * 2 * sizeof(float); - if (descriptor.stride != 0 && descriptor.stride != 2 * sizeof(float)) { - return reject(ParticleFieldAdapterError::DimensionMismatch, "field stride does not match interleaved UV"); - } - if (descriptor.byteLength != expected || bytes.size() != expected) { - return reject(ParticleFieldAdapterError::PayloadSizeMismatch, "field payload length does not match descriptor"); - } +ParticleFieldAdapterResult ParticleFieldDataAdapter::decode(const ParticleFieldDescriptor& d, + const ParticleFieldPayloadBundle& payloads) { + if (!validCommonDescriptor(d)) return reject(ParticleFieldAdapterError::InvalidTimestampOrIdentity, "field identity, time or grid is invalid"); + const bool scalar = d.scalar.has_value(), vector = d.vector.has_value(); + if ((!scalar && !vector) || scalar != payloads.scalar.has_value() || vector != payloads.vector.has_value() || !d.validity || !payloads.validity) + return reject(ParticleFieldAdapterError::InvalidDescriptor, "v2 channel declaration and payload presence differ"); + if (vector != d.vectorSemantics.has_value()) + return reject(ParticleFieldAdapterError::InvalidDescriptor, "vector semantics must be present exactly with vector"); + const uint64_t cells = static_cast(d.width) * d.height; + const auto planeMatches = [](const ParticleFieldPlaneDescriptor& p, const std::optional>& bytes, + uint64_t expected, const char* encoding, const char* layout) { + return p.byteLength == expected && bytes && bytes->size() == expected && p.encoding == encoding && + (!layout[0] || p.layout == layout) && (!p.noData || std::isfinite(*p.noData)); + }; + if (scalar && !planeMatches(*d.scalar, payloads.scalar, cells * sizeof(float), "float32-le", "")) + return reject(ParticleFieldAdapterError::PayloadSizeMismatch, "scalar plane length or encoding is invalid"); + if (vector && !planeMatches(*d.vector, payloads.vector, cells * 2 * sizeof(float), "float32-le", "interleaved-uv")) + return reject(ParticleFieldAdapterError::PayloadSizeMismatch, "vector plane length or encoding is invalid"); + if (d.validity->byteLength != cells || payloads.validity->size() != cells || d.validity->encoding != "uint8") + return reject(ParticleFieldAdapterError::PayloadSizeMismatch, "validity plane length or encoding is invalid"); + ParticleFieldSnapshot field; - field.fieldID = descriptor.fieldID; - field.fieldType = descriptor.fieldType; - field.displayDomain = descriptor.displayDomain; - field.validTime = descriptor.validTime; - field.width = descriptor.width; - field.height = descriptor.height; - field.west = descriptor.west; - field.south = descriptor.south; - field.east = descriptor.east; - field.north = descriptor.north; - field.noData = descriptor.noData; - field.dataVersion = descriptor.dataVersion; - field.generation = descriptor.generation; - field.fieldSha256 = descriptor.fieldSha256; - field.velocity.resize(static_cast(cells) * 2); - std::memcpy(field.velocity.data(), bytes.data(), bytes.size()); - field.valid.resize(static_cast(cells)); + field.fieldID = d.fieldID; field.fieldType = d.fieldType; field.displayDomain = d.displayDomain; field.validTime = d.validTime; + field.width = d.width; field.height = d.height; field.west = d.west; field.south = d.south; field.east = d.east; field.north = d.north; + field.dataVersion = d.dataVersion; field.generation = d.generation; field.fieldSha256 = d.fieldSha256; field.vectorSemantics = d.vectorSemantics; + field.valid.assign(payloads.validity->begin(), payloads.validity->end()); + if (scalar) { field.scalar.resize(cells); std::memcpy(field.scalar.data(), payloads.scalar->data(), payloads.scalar->size()); } + if (vector) { field.vector.resize(cells * 2); std::memcpy(field.vector.data(), payloads.vector->data(), payloads.vector->size()); } + if (!finitePlane(field.scalar) || !finitePlane(field.vector)) return reject(ParticleFieldAdapterError::NonFiniteData, "field contains non-finite data"); for (size_t cell = 0; cell < field.valid.size(); ++cell) { - const float u = field.velocity[cell * 2]; - const float v = field.velocity[cell * 2 + 1]; - if (!std::isfinite(u) || !std::isfinite(v)) return reject(ParticleFieldAdapterError::NonFiniteData, "field contains non-finite data"); - field.valid[cell] = (u == field.noData || v == field.noData) ? 0 : 1; - } - if (field.fieldType != "WIND" || field.displayDomain != "GLOBAL" || !field.isValid()) { - return reject(ParticleFieldAdapterError::DecodeValidationFailure, "decoded field is invalid"); + if (field.valid[cell] > 1) return reject(ParticleFieldAdapterError::DecodeValidationFailure, "validity values must be 0 or 1"); + if (field.valid[cell]) { + if (scalar && isNoData(d.scalar->noData, field.scalar[cell])) return reject(ParticleFieldAdapterError::DecodeValidationFailure, "valid scalar cell contains noData"); + if (vector && (isNoData(d.vector->noData, field.vector[cell * 2]) || isNoData(d.vector->noData, field.vector[cell * 2 + 1]))) + return reject(ParticleFieldAdapterError::DecodeValidationFailure, "valid vector cell contains noData"); + } } + if (!field.isValid()) return reject(ParticleFieldAdapterError::DecodeValidationFailure, "decoded field is invalid"); return {.snapshot = std::move(field), .error = ParticleFieldAdapterError::None, .message = {}}; } + +ParticleFieldAdapterResult ParticleFieldDataAdapter::decode(const ParticleFieldDescriptor& d, const std::vector& bytes, uint64_t generation) { + if (!d.generation || !generation || d.generation != generation || d.layout != "interleaved-uv" || d.encoding != "float32-le" || + (d.stride && d.stride != 2 * sizeof(float)) || !std::isfinite(d.noData)) + return reject(ParticleFieldAdapterError::InvalidDescriptor, "legacy v1 descriptor is invalid"); + ParticleFieldDescriptor normalized = d; + normalized.vector = ParticleFieldPlaneDescriptor{.byteLength = d.byteLength, .encoding = d.encoding, .layout = d.layout, .noData = d.noData}; + normalized.vectorSemantics = VectorSemantics::Velocity; + normalized.validity = ParticleFieldPlaneDescriptor{.byteLength = static_cast(d.width) * d.height, + .encoding = "uint8", .layout = {}, .noData = std::nullopt}; + std::vector valid(static_cast(normalized.validity->byteLength)); + if (bytes.size() == static_cast(d.width) * d.height * 2 * sizeof(float)) { + for (size_t cell = 0; cell < valid.size(); ++cell) { + float pair[2]; std::memcpy(pair, bytes.data() + cell * sizeof(pair), sizeof(pair)); + if (!std::isfinite(pair[0]) || !std::isfinite(pair[1])) return reject(ParticleFieldAdapterError::NonFiniteData, "legacy v1 field contains non-finite data"); + valid[cell] = (pair[0] == d.noData || pair[1] == d.noData) ? 0 : 1; + } + } + return decode(normalized, ParticleFieldPayloadBundle{.scalar = std::nullopt, + .vector = bytes, + .validity = std::vector(valid.begin(), valid.end())}); +} } // namespace mbgl::particle diff --git a/src/mbgl/particle/field/local_particle_field_feed.cpp b/src/mbgl/particle/field/local_particle_field_feed.cpp index a56d9c992..fb228c9b2 100644 --- a/src/mbgl/particle/field/local_particle_field_feed.cpp +++ b/src/mbgl/particle/field/local_particle_field_feed.cpp @@ -2,73 +2,70 @@ #include -#include - namespace mbgl::particle { namespace { - -ParticleFieldAdapterResult reject(const ParticleFieldAdapterError error, std::string message) { +ParticleFieldAdapterResult reject(ParticleFieldAdapterError error, std::string message) { return {.snapshot = std::nullopt, .error = error, .message = std::move(message)}; } - -bool hasString(const rapidjson::Value& object, const char* name) { - return object.HasMember(name) && object[name].IsString(); +bool stringMember(const rapidjson::Value& v, const char* n) { return v.HasMember(n) && v[n].IsString(); } +bool numberMember(const rapidjson::Value& v, const char* n) { return v.HasMember(n) && v[n].IsNumber(); } +bool common(const rapidjson::Value& m) { + return m.IsObject() && stringMember(m, "fieldId") && m.HasMember("generation") && m["generation"].IsUint64() && + m.HasMember("validTime") && m["validTime"].IsUint64() && m.HasMember("grid") && m["grid"].IsObject() && + m["grid"].HasMember("width") && m["grid"]["width"].IsUint() && m["grid"].HasMember("height") && m["grid"]["height"].IsUint() && + m.HasMember("bounds") && m["bounds"].IsObject() && numberMember(m["bounds"], "west") && numberMember(m["bounds"], "south") && + numberMember(m["bounds"], "east") && numberMember(m["bounds"], "north"); } -bool hasNumber(const rapidjson::Value& object, const char* name) { - return object.HasMember(name) && object[name].IsNumber(); +void fillCommon(ParticleFieldDescriptor& d, const rapidjson::Value& m) { + d.fieldID = m["fieldId"].GetString(); d.generation = m["generation"].GetUint64(); d.validTime = m["validTime"].GetUint64(); + d.width = m["grid"]["width"].GetUint(); d.height = m["grid"]["height"].GetUint(); + d.west = m["bounds"]["west"].GetDouble(); d.south = m["bounds"]["south"].GetDouble(); + d.east = m["bounds"]["east"].GetDouble(); d.north = m["bounds"]["north"].GetDouble(); + if (m.HasMember("dataVersion") && m["dataVersion"].IsUint64()) d.dataVersion = m["dataVersion"].GetUint64(); + if (stringMember(m, "fieldType")) d.fieldType = m["fieldType"].GetString(); + if (stringMember(m, "displayDomain")) d.displayDomain = m["displayDomain"].GetString(); +} +std::optional plane(const rapidjson::Value& m, const char* name, bool vector) { + if (!m.HasMember(name)) return std::nullopt; + const auto& p = m[name]; + if (!p.IsObject() || !stringMember(p, "encoding") || !p.HasMember("byteLength") || !p["byteLength"].IsUint64() || + (vector && !stringMember(p, "layout"))) return std::nullopt; + ParticleFieldPlaneDescriptor result{.byteLength = p["byteLength"].GetUint64(), .encoding = p["encoding"].GetString(), + .layout = {}, .noData = std::nullopt}; + if (vector) result.layout = p["layout"].GetString(); + if (p.HasMember("noData") && p["noData"].IsNumber()) result.noData = static_cast(p["noData"].GetDouble()); + return result; } - } // namespace -ParticleFieldAdapterResult LocalParticleFieldFeed::load(const std::string& metadataText, - const std::vector& payload) { - rapidjson::Document metadata; - metadata.Parse(metadataText.c_str()); - if (metadata.HasParseError() || !metadata.IsObject() || !hasString(metadata, "schema") || - std::string(metadata["schema"].GetString()) != "maplibre-particle-field/v1" || - !hasString(metadata, "fieldId") || !hasString(metadata, "fieldType") || - !hasString(metadata, "displayDomain") || !metadata.HasMember("generation") || - !metadata["generation"].IsUint64() || !metadata.HasMember("validTime") || - !metadata["validTime"].IsUint64()) { - return reject(ParticleFieldAdapterError::InvalidDescriptor, "invalid local field metadata"); - } - const auto& grid = metadata["grid"]; - const auto& bounds = metadata["bounds"]; - const auto& vector = metadata["vector"]; - const auto& payloadInfo = metadata["payload"]; - if (!grid.IsObject() || !bounds.IsObject() || !vector.IsObject() || !payloadInfo.IsObject() || - !grid.HasMember("width") || !grid["width"].IsUint() || !grid.HasMember("height") || - !grid["height"].IsUint() || !hasNumber(bounds, "west") || !hasNumber(bounds, "south") || - !hasNumber(bounds, "east") || !hasNumber(bounds, "north") || !hasString(vector, "layout") || - !hasString(vector, "encoding") || !hasNumber(vector, "noData") || !payloadInfo.HasMember("byteLength") || - !payloadInfo["byteLength"].IsUint64()) { - return reject(ParticleFieldAdapterError::InvalidDescriptor, "invalid local field grid or payload metadata"); - } - ParticleFieldDescriptor descriptor; - descriptor.fieldID = metadata["fieldId"].GetString(); - descriptor.fieldType = metadata["fieldType"].GetString(); - descriptor.displayDomain = metadata["displayDomain"].GetString(); - descriptor.generation = metadata["generation"].GetUint64(); - descriptor.validTime = metadata["validTime"].GetUint64(); - if (descriptor.generation == 0 || descriptor.validTime == 0) { - return reject(ParticleFieldAdapterError::InvalidTimestampOrIdentity, - "local field generation and validTime must be non-zero"); - } - descriptor.width = grid["width"].GetUint(); - descriptor.height = grid["height"].GetUint(); - descriptor.west = bounds["west"].GetDouble(); - descriptor.south = bounds["south"].GetDouble(); - descriptor.east = bounds["east"].GetDouble(); - descriptor.north = bounds["north"].GetDouble(); - descriptor.layout = vector["layout"].GetString(); - descriptor.encoding = vector["encoding"].GetString(); - descriptor.noData = static_cast(vector["noData"].GetDouble()); - descriptor.byteLength = payloadInfo["byteLength"].GetUint64(); - if (metadata.HasMember("dataVersion") && metadata["dataVersion"].IsUint64()) { - descriptor.dataVersion = metadata["dataVersion"].GetUint64(); - } - if (metadata.HasMember("stride") && metadata["stride"].IsUint()) descriptor.stride = metadata["stride"].GetUint(); - return ParticleFieldDataAdapter::decode(descriptor, payload, descriptor.generation); +ParticleFieldAdapterResult LocalParticleFieldFeed::load(const std::string& metadataText, const std::vector& payload) { + rapidjson::Document m; m.Parse(metadataText.c_str()); + if (m.HasParseError() || !m.IsObject() || !stringMember(m, "schema") || std::string(m["schema"].GetString()) != "maplibre-particle-field/v1" || + !common(m) || !stringMember(m, "fieldType") || !stringMember(m, "displayDomain") || !m.HasMember("vector") || !m["vector"].IsObject() || + !stringMember(m["vector"], "layout") || !stringMember(m["vector"], "encoding") || !numberMember(m["vector"], "noData") || + !m.HasMember("payload") || !m["payload"].IsObject() || !m["payload"].HasMember("byteLength") || !m["payload"]["byteLength"].IsUint64()) + return reject(ParticleFieldAdapterError::InvalidDescriptor, "invalid v1 local field metadata"); + ParticleFieldDescriptor d; fillCommon(d, m); + d.layout = m["vector"]["layout"].GetString(); d.encoding = m["vector"]["encoding"].GetString(); + d.noData = static_cast(m["vector"]["noData"].GetDouble()); d.byteLength = m["payload"]["byteLength"].GetUint64(); + if (m.HasMember("stride") && m["stride"].IsUint()) d.stride = m["stride"].GetUint(); + return ParticleFieldDataAdapter::decode(d, payload, d.generation); } +ParticleFieldAdapterResult LocalParticleFieldFeed::load(const std::string& metadataText, const ParticleFieldPayloadBundle& payloads) { + rapidjson::Document m; m.Parse(metadataText.c_str()); + if (m.HasParseError() || !m.IsObject() || !stringMember(m, "schema") || std::string(m["schema"].GetString()) != "maplibre-particle-field/v2" || !common(m)) + return reject(ParticleFieldAdapterError::InvalidDescriptor, "invalid v2 local field metadata"); + ParticleFieldDescriptor d; fillCommon(d, m); + d.scalar = plane(m, "scalar", false); d.vector = plane(m, "vector", true); d.validity = plane(m, "validity", false); + if (!d.validity || d.validity->encoding != "uint8") return reject(ParticleFieldAdapterError::InvalidDescriptor, "invalid v2 validity metadata"); + if (m.HasMember("vector")) { + if (!stringMember(m["vector"], "semantics")) return reject(ParticleFieldAdapterError::InvalidDescriptor, "v2 vector semantics missing"); + const std::string semantics = m["vector"]["semantics"].GetString(); + if (semantics == "VELOCITY") d.vectorSemantics = VectorSemantics::Velocity; + else if (semantics == "DIRECTION") d.vectorSemantics = VectorSemantics::Direction; + else return reject(ParticleFieldAdapterError::InvalidDescriptor, "unsupported vector semantics"); + } + return ParticleFieldDataAdapter::decode(d, payloads); +} } // namespace mbgl::particle diff --git a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp index d44737350..3e8db3d55 100644 --- a/src/mbgl/renderer/layers/render_particle_validation_layer.cpp +++ b/src/mbgl/renderer/layers/render_particle_validation_layer.cpp @@ -155,18 +155,18 @@ std::shared_ptr makeVelocityFieldImage(const particle::Parti float scale = 1.0f; for (size_t index = 0; index < field.valid.size(); ++index) { if (!field.valid[index]) continue; - scale = std::max(scale, std::abs(field.velocity[index * 2])); - scale = std::max(scale, std::abs(field.velocity[index * 2 + 1])); + scale = std::max(scale, std::abs(field.vector[index * 2])); + scale = std::max(scale, std::abs(field.vector[index * 2 + 1])); } auto image = std::make_shared(Size{field.width, field.height}); for (uint32_t y = 0; y < field.height; ++y) { for (uint32_t x = 0; x < field.width; ++x) { const auto cell = (static_cast(y) * field.width + x) * 2; const auto pixel = (static_cast(y) * field.width + x) * PremultipliedImage::channels; - image->data[pixel + 0] = static_cast(std::clamp(field.velocity[cell] / scale * 0.5f + 0.5f, 0.0f, 1.0f) * + image->data[pixel + 0] = static_cast(std::clamp(field.vector[cell] / scale * 0.5f + 0.5f, 0.0f, 1.0f) * 255.0f); image->data[pixel + 1] = static_cast( - std::clamp(field.velocity[cell + 1] / scale * 0.5f + 0.5f, 0.0f, 1.0f) * 255.0f); + std::clamp(field.vector[cell + 1] / scale * 0.5f + 0.5f, 0.0f, 1.0f) * 255.0f); image->data[pixel + 2] = 0; image->data[pixel + 3] = field.valid[y * field.width + x] ? 255 : 0; } @@ -178,8 +178,8 @@ float fieldVelocityScale(const particle::ParticleFieldSnapshot& field) { float scale = 1.0f; for (size_t index = 0; index < field.valid.size(); ++index) { if (!field.valid[index]) continue; - scale = std::max(scale, std::abs(field.velocity[index * 2])); - scale = std::max(scale, std::abs(field.velocity[index * 2 + 1])); + scale = std::max(scale, std::abs(field.vector[index * 2])); + scale = std::max(scale, std::abs(field.vector[index * 2 + 1])); } return scale; } @@ -506,8 +506,8 @@ public: const auto latitude = resources->field.north + (resources->field.south - resources->field.north) * (static_cast(row) / std::max(1u, resources->field.height - 1)); - const auto u = resources->field.velocity[cell * 2]; - const auto v = resources->field.velocity[cell * 2 + 1]; + const auto u = resources->field.vector[cell * 2]; + const auto v = resources->field.vector[cell * 2 + 1]; const auto cosLatitude = std::max(0.01f, std::cos(static_cast(latitude * M_PI / 180.0))); const auto dx = u * simulationStep / EarthCircumferenceMeters; const auto dy = -v * simulationStep / (EarthCircumferenceMeters * cosLatitude); @@ -1077,6 +1077,12 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, } return; } + // Scalar-only is a valid V3 Core snapshot, but this velocity-only + // renderer stays fail-closed until the V3-03 texture contract exists. + if (formalSource && (!sourceField->hasVector() || !sourceNextField->hasVector())) { + if (layerGroup) static_cast(*layerGroup).clearDrawables(); + return; + } if (!layerGroup || !isLayerRenderable()) return; const auto& properties = static_cast(*evaluatedProperties); @@ -1228,7 +1234,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders, Log::Info(Event::Render, "ParticleField: field validated id=" + resources->field.fieldID + " noData=" + std::to_string(resources->field.noData) + - " velocityCount=" + std::to_string(resources->field.velocity.size())); + " vectorCount=" + std::to_string(resources->field.vector.size())); Log::Info(Event::Render, "ParticleField: created id=" + resources->field.fieldID + " generation=" + std::to_string(resources->fieldGeneration)); diff --git a/src/mbgl/renderer/layers/render_weather_color_layer.cpp b/src/mbgl/renderer/layers/render_weather_color_layer.cpp index 26a7d2a11..46238392d 100644 --- a/src/mbgl/renderer/layers/render_weather_color_layer.cpp +++ b/src/mbgl/renderer/layers/render_weather_color_layer.cpp @@ -38,8 +38,8 @@ std::array bounds(const particle::ParticleFieldSnapshot& field) { float scale(const particle::ParticleFieldSnapshot& field) { float result = 1.0f; for (size_t index = 0; index < field.valid.size(); ++index) if (field.valid[index]) { - result = std::max(result, std::abs(field.velocity[index * 2])); - result = std::max(result, std::abs(field.velocity[index * 2 + 1])); + result = std::max(result, std::abs(field.vector[index * 2])); + result = std::max(result, std::abs(field.vector[index * 2 + 1])); } return result; } @@ -49,8 +49,8 @@ std::shared_ptr image(const particle::ParticleFieldSnapshot& for (uint32_t y = 0; y < field.height; ++y) for (uint32_t x = 0; x < field.width; ++x) { const auto cell = (static_cast(y) * field.width + x) * 2; const auto pixel = (static_cast(y) * field.width + x) * PremultipliedImage::channels; - result->data[pixel] = static_cast(std::clamp(field.velocity[cell] / fieldScale * .5f + .5f, 0.0f, 1.0f) * 255.0f); - result->data[pixel + 1] = static_cast(std::clamp(field.velocity[cell + 1] / fieldScale * .5f + .5f, 0.0f, 1.0f) * 255.0f); + result->data[pixel] = static_cast(std::clamp(field.vector[cell] / fieldScale * .5f + .5f, 0.0f, 1.0f) * 255.0f); + result->data[pixel + 1] = static_cast(std::clamp(field.vector[cell + 1] / fieldScale * .5f + .5f, 0.0f, 1.0f) * 255.0f); result->data[pixel + 2] = 0; result->data[pixel + 3] = field.valid[y * field.width + x] ? 255 : 0; } @@ -173,6 +173,13 @@ void RenderWeatherColorLayer::update(gfx::ShaderRegistry& shaders, gfx::Context& resources.reset(); return; } + // V3-02 accepts scalar snapshots into the Source timeline, but the + // existing velocity-only renderer must remain unavailable until V3-03. + if (!sourceField->hasVector() || !sourceNextField->hasVector()) { + if (layerGroup) static_cast(*layerGroup).clearDrawables(); + resources.reset(); + return; + } if (!layerGroup || !isLayerRenderable()) return; style::RasterArrayTimelineFrame timelineFrame; if (sourceTimeline) { diff --git a/test/style/source.test.cpp b/test/style/source.test.cpp index 0290ef311..c5e79f497 100644 --- a/test/style/source.test.cpp +++ b/test/style/source.test.cpp @@ -58,6 +58,7 @@ #include #include +#include namespace { @@ -73,6 +74,26 @@ std::string localMetadata(const int index) { ",\"bounds\":{\"west\":-180,\"south\":-85,\"east\":180,\"north\":85},\"grid\":{\"width\":16,\"height\":16},\"vector\":{\"layout\":\"interleaved-uv\",\"encoding\":\"float32-le\",\"noData\":-9999},\"payload\":{\"byteLength\":2048}}"; } +std::vector floatBytes(std::initializer_list values) { + std::vector copy(values); + std::vector bytes(copy.size() * sizeof(float)); + std::memcpy(bytes.data(), copy.data(), bytes.size()); + return bytes; +} + +std::string v2Metadata(bool scalar, bool vector, const char* semantics = "VELOCITY") { + std::string result = "{\"schema\":\"maplibre-particle-field/v2\",\"fieldId\":\"v2-field\",\"generation\":1,\"validTime\":1,\"grid\":{\"width\":2,\"height\":1},\"bounds\":{\"west\":0,\"south\":0,\"east\":1,\"north\":1},\"validity\":{\"encoding\":\"uint8\",\"byteLength\":2}"; + if (scalar) result += ",\"scalar\":{\"encoding\":\"float32-le\",\"byteLength\":8,\"noData\":-9999}"; + if (vector) result += std::string(",\"vector\":{\"layout\":\"interleaved-uv\",\"encoding\":\"float32-le\",\"byteLength\":16,\"noData\":-9999,\"semantics\":\"") + semantics + "\"}"; + return result + "}"; +} + +particle::ParticleFieldPayloadBundle v2Payload(std::optional> scalar, + std::optional> vector, + std::vector validity) { + return {.scalar = std::move(scalar), .vector = std::move(vector), .validity = std::move(validity)}; +} + } // namespace using namespace mbgl; @@ -256,6 +277,7 @@ TEST(Source, ParticleFieldDataAdapterAcceptsNeutralDescriptor) { descriptor.fieldType = "WIND"; descriptor.displayDomain = "GLOBAL"; descriptor.generation = 1; + descriptor.validTime = 1; descriptor.width = 1; descriptor.height = 1; descriptor.byteLength = 2 * sizeof(float); @@ -276,6 +298,7 @@ TEST(Source, ParticleFieldDataAdapterReturnsStructuredValidationErrors) { descriptor.fieldType = "WIND"; descriptor.displayDomain = "GLOBAL"; descriptor.generation = 1; + descriptor.validTime = 1; descriptor.width = 2; descriptor.height = 1; descriptor.byteLength = 4 * sizeof(float); @@ -292,6 +315,74 @@ TEST(Source, ParticleFieldDataAdapterReturnsStructuredValidationErrors) { EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::UnsupportedFormat); } +TEST(Source, ParticleFieldV3V2DecodesAllSupportedChannelCombinations) { + const std::vector valid{1, 0}; + const auto vectorOnly = particle::LocalParticleFieldFeed::load( + v2Metadata(false, true), v2Payload(std::nullopt, floatBytes({1, 2, -9999, -9999}), valid)); + ASSERT_TRUE(vectorOnly) << vectorOnly.message; + EXPECT_TRUE(vectorOnly.snapshot->hasVector()); + EXPECT_FALSE(vectorOnly.snapshot->hasScalar()); + EXPECT_EQ(vectorOnly.snapshot->vectorSemantics, particle::VectorSemantics::Velocity); + + const auto scalarOnly = particle::LocalParticleFieldFeed::load( + v2Metadata(true, false), v2Payload(floatBytes({3, -9999}), std::nullopt, valid)); + ASSERT_TRUE(scalarOnly) << scalarOnly.message; + EXPECT_TRUE(scalarOnly.snapshot->hasScalar()); + EXPECT_FALSE(scalarOnly.snapshot->hasVector()); + + const auto combined = particle::LocalParticleFieldFeed::load( + v2Metadata(true, true), v2Payload(floatBytes({3, -9999}), floatBytes({1, 2, -9999, -9999}), valid)); + ASSERT_TRUE(combined) << combined.message; + EXPECT_TRUE(combined.snapshot->hasScalar()); + EXPECT_TRUE(combined.snapshot->hasVector()); +} + +TEST(Source, ParticleFieldV3V2RejectsInvalidPlaneAndSemanticsContracts) { + const std::vector valid{1, 1}; + auto neither = particle::LocalParticleFieldFeed::load(v2Metadata(false, false), v2Payload(std::nullopt, std::nullopt, valid)); + EXPECT_FALSE(neither); + auto scalarLength = particle::LocalParticleFieldFeed::load(v2Metadata(true, false), v2Payload(floatBytes({1}), std::nullopt, valid)); + EXPECT_FALSE(scalarLength); + auto vectorLength = particle::LocalParticleFieldFeed::load(v2Metadata(false, true), v2Payload(std::nullopt, floatBytes({1, 2}), valid)); + EXPECT_FALSE(vectorLength); + auto validityLength = particle::LocalParticleFieldFeed::load(v2Metadata(true, false), v2Payload(floatBytes({1, 2}), std::nullopt, std::vector{1})); + EXPECT_FALSE(validityLength); + auto missingSemantics = v2Metadata(false, true); + const auto position = missingSemantics.find(",\"semantics\":\"VELOCITY\""); + ASSERT_NE(position, std::string::npos); + missingSemantics.erase(position, std::string(",\"semantics\":\"VELOCITY\"").size()); + EXPECT_FALSE(particle::LocalParticleFieldFeed::load(missingSemantics, v2Payload(std::nullopt, floatBytes({1, 2, 3, 4}), valid))); + auto directionWithoutVector = v2Metadata(true, false); + directionWithoutVector.pop_back(); + directionWithoutVector += ",\"vector\":{\"semantics\":\"DIRECTION\"}}"; + EXPECT_FALSE(particle::LocalParticleFieldFeed::load(directionWithoutVector, v2Payload(floatBytes({1, 2}), std::nullopt, valid))); +} + +TEST(Source, ParticleFieldV3V2RejectsNonFiniteValidDataAndAcceptsInvalidFiniteNoData) { + const auto nonFinite = particle::LocalParticleFieldFeed::load( + v2Metadata(true, false), v2Payload(floatBytes({std::numeric_limits::infinity(), 2}), std::nullopt, std::vector{1, 1})); + EXPECT_FALSE(nonFinite); + const auto placeholder = particle::LocalParticleFieldFeed::load( + v2Metadata(true, false), v2Payload(floatBytes({1, -9999}), std::nullopt, std::vector{1, 0})); + EXPECT_TRUE(placeholder) << placeholder.message; +} + +TEST(Source, ParticleFieldV3CompatibilityAndContentIdentityAreChannelAware) { + const std::vector valid{1, 1}; + auto scalar = *particle::LocalParticleFieldFeed::load(v2Metadata(true, false), v2Payload(floatBytes({1, 2}), std::nullopt, valid)).snapshot; + auto scalarChanged = scalar; scalarChanged.scalar[0] = 3; + auto validityChanged = scalar; validityChanged.valid[0] = 0; + EXPECT_TRUE(scalar.isTimeInterpolationCompatibleWith(scalarChanged)); + EXPECT_FALSE(particle::sameFieldGpuContent(scalar, scalarChanged)); + EXPECT_FALSE(particle::sameFieldGpuContent(scalar, validityChanged)); + auto vector = *particle::LocalParticleFieldFeed::load(v2Metadata(false, true), v2Payload(std::nullopt, floatBytes({1, 2, 3, 4}), valid)).snapshot; + auto direction = *particle::LocalParticleFieldFeed::load(v2Metadata(false, true, "DIRECTION"), v2Payload(std::nullopt, floatBytes({1, 2, 3, 4}), valid)).snapshot; + auto vectorChanged = vector; vectorChanged.vector[0] = 9; + EXPECT_FALSE(scalar.isTimeInterpolationCompatibleWith(vector)); + EXPECT_FALSE(vector.isTimeInterpolationCompatibleWith(direction)); + EXPECT_FALSE(particle::sameFieldGpuContent(vector, vectorChanged)); +} + TEST(Source, LocalProductionFeedSubmitsSnapshotsWithProducerTime) { const auto field = fixtureBytes("uniform-east", "field.f32"); const std::vector payload(field.begin(), field.end()); @@ -369,17 +460,17 @@ TEST(Source, CanonicalFieldUsesEastwardUAndNorthwardV) { ASSERT_TRUE(vortexResult); ASSERT_TRUE(eastResult.snapshot); ASSERT_TRUE(northResult.snapshot); - ASSERT_GE(eastResult.snapshot->velocity.size(), 2u); - ASSERT_GE(northResult.snapshot->velocity.size(), 2u); - EXPECT_FLOAT_EQ(eastResult.snapshot->velocity[0], 1.0f); - EXPECT_FLOAT_EQ(eastResult.snapshot->velocity[1], 0.0f); - EXPECT_FLOAT_EQ(northResult.snapshot->velocity[0], 0.0f); - EXPECT_FLOAT_EQ(northResult.snapshot->velocity[1], 1.0f); + ASSERT_GE(eastResult.snapshot->vector.size(), 2u); + ASSERT_GE(northResult.snapshot->vector.size(), 2u); + EXPECT_FLOAT_EQ(eastResult.snapshot->vector[0], 1.0f); + EXPECT_FLOAT_EQ(eastResult.snapshot->vector[1], 0.0f); + EXPECT_FLOAT_EQ(northResult.snapshot->vector[0], 0.0f); + EXPECT_FLOAT_EQ(northResult.snapshot->vector[1], 1.0f); ASSERT_TRUE(vortexResult.snapshot); - EXPECT_FLOAT_EQ(vortexResult.snapshot->velocity[0], 0.9375f); - EXPECT_FLOAT_EQ(vortexResult.snapshot->velocity[1], -0.9375f); - EXPECT_FLOAT_EQ(vortexResult.snapshot->velocity[vortexResult.snapshot->velocity.size() - 2], -0.9375f); - EXPECT_FLOAT_EQ(vortexResult.snapshot->velocity.back(), 0.9375f); + EXPECT_FLOAT_EQ(vortexResult.snapshot->vector[0], 0.9375f); + EXPECT_FLOAT_EQ(vortexResult.snapshot->vector[1], -0.9375f); + EXPECT_FLOAT_EQ(vortexResult.snapshot->vector[vortexResult.snapshot->vector.size() - 2], -0.9375f); + EXPECT_FLOAT_EQ(vortexResult.snapshot->vector.back(), 0.9375f); } TEST(Source, HostFieldSubmissionBuildsOneCoreOwnedTimeline) { @@ -707,7 +798,7 @@ TEST(Source, RasterArrayRuntimeFieldWindowAdvancesWithoutReplacingTheSource) { EXPECT_EQ(source.updateRuntimeFieldWindow(vortex), style::RasterArrayRuntimeFieldUpdateResult::Noop); auto invalid = nodata; - invalid.velocity.clear(); + invalid.vector.clear(); EXPECT_EQ(source.updateRuntimeFieldWindow(invalid), style::RasterArrayRuntimeFieldUpdateResult::RejectedInvalidSnapshot); EXPECT_EQ(source.impl().field->fieldID, "uniform-north");