core: add scalar vector field snapshots

This commit is contained in:
OpenAI Codex
2026-09-10 22:12:17 +08:00
parent d23ae2079d
commit 379c9ed0e4
7 changed files with 294 additions and 144 deletions

View File

@@ -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<std::vector<char>> scalar;
std::optional<std::vector<char>> vector;
std::optional<std::vector<char>> validity;
};
struct ParticleFieldPlaneDescriptor {
uint64_t byteLength = 0;
std::string encoding = "float32-le";
std::string layout;
std::optional<float> 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<ParticleFieldPlaneDescriptor> scalar;
std::optional<ParticleFieldPlaneDescriptor> vector;
std::optional<ParticleFieldPlaneDescriptor> validity;
std::optional<VectorSemantics> vectorSemantics;
};
struct ParticleFieldSnapshot {
@@ -49,11 +69,15 @@ struct ParticleFieldSnapshot {
uint64_t dataVersion = 0;
uint64_t generation = 0;
std::string fieldSha256;
std::vector<float> velocity;
std::vector<float> scalar;
std::vector<float> vector;
std::vector<uint8_t> valid;
std::optional<VectorSemantics> 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<char>&, uint64_t generation);
};

View File

@@ -12,6 +12,7 @@ namespace mbgl::particle {
class LocalParticleFieldFeed final {
public:
static ParticleFieldAdapterResult load(const std::string& metadataText, const std::vector<char>& payload);
static ParticleFieldAdapterResult load(const std::string& metadataText, const ParticleFieldPayloadBundle& payloads);
};
} // namespace mbgl::particle

View File

@@ -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<float>& values) {
return std::all_of(values.begin(), values.end(), [](float value) { return std::isfinite(value); });
}
bool isNoData(const std::optional<float>& 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<size_t>(width) * height * 2 &&
valid.size() == static_cast<size_t>(width) * height &&
std::all_of(velocity.begin(), velocity.end(), [](float value) { return std::isfinite(value); });
const auto cells = static_cast<size_t>(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<char>& 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<uint64_t>(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<uint64_t>(d.width) * d.height;
const auto planeMatches = [](const ParticleFieldPlaneDescriptor& p, const std::optional<std::vector<char>>& 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<size_t>(cells) * 2);
std::memcpy(field.velocity.data(), bytes.data(), bytes.size());
field.valid.resize(static_cast<size_t>(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<char>& 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<uint64_t>(d.width) * d.height,
.encoding = "uint8", .layout = {}, .noData = std::nullopt};
std::vector<uint8_t> valid(static_cast<size_t>(normalized.validity->byteLength));
if (bytes.size() == static_cast<size_t>(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<char>(valid.begin(), valid.end())});
}
} // namespace mbgl::particle

View File

@@ -2,73 +2,70 @@
#include <rapidjson/document.h>
#include <cmath>
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<ParticleFieldPlaneDescriptor> 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<float>(p["noData"].GetDouble());
return result;
}
} // namespace
ParticleFieldAdapterResult LocalParticleFieldFeed::load(const std::string& metadataText,
const std::vector<char>& 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<float>(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<char>& 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<float>(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

View File

@@ -155,18 +155,18 @@ std::shared_ptr<PremultipliedImage> 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<PremultipliedImage>(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<size_t>(y) * field.width + x) * 2;
const auto pixel = (static_cast<size_t>(y) * field.width + x) * PremultipliedImage::channels;
image->data[pixel + 0] = static_cast<uint8_t>(std::clamp(field.velocity[cell] / scale * 0.5f + 0.5f, 0.0f, 1.0f) *
image->data[pixel + 0] = static_cast<uint8_t>(std::clamp(field.vector[cell] / scale * 0.5f + 0.5f, 0.0f, 1.0f) *
255.0f);
image->data[pixel + 1] = static_cast<uint8_t>(
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<float>(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<float>(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&>(*layerGroup).clearDrawables();
return;
}
if (!layerGroup || !isLayerRenderable()) return;
const auto& properties = static_cast<const style::ParticleRenderLayerProperties&>(*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));

View File

@@ -38,8 +38,8 @@ std::array<float, 4> 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<PremultipliedImage> 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<size_t>(y) * field.width + x) * 2;
const auto pixel = (static_cast<size_t>(y) * field.width + x) * PremultipliedImage::channels;
result->data[pixel] = static_cast<uint8_t>(std::clamp(field.velocity[cell] / fieldScale * .5f + .5f, 0.0f, 1.0f) * 255.0f);
result->data[pixel + 1] = static_cast<uint8_t>(std::clamp(field.velocity[cell + 1] / fieldScale * .5f + .5f, 0.0f, 1.0f) * 255.0f);
result->data[pixel] = static_cast<uint8_t>(std::clamp(field.vector[cell] / fieldScale * .5f + .5f, 0.0f, 1.0f) * 255.0f);
result->data[pixel + 1] = static_cast<uint8_t>(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&>(*layerGroup).clearDrawables();
resources.reset();
return;
}
if (!layerGroup || !isLayerRenderable()) return;
style::RasterArrayTimelineFrame timelineFrame;
if (sourceTimeline) {

View File

@@ -58,6 +58,7 @@
#include <optional>
#include <cstring>
#include <limits>
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<char> floatBytes(std::initializer_list<float> values) {
std::vector<float> copy(values);
std::vector<char> 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<std::vector<char>> scalar,
std::optional<std::vector<char>> vector,
std::vector<char> 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<char> 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<char> 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<char>{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<float>::infinity(), 2}), std::nullopt, std::vector<char>{1, 1}));
EXPECT_FALSE(nonFinite);
const auto placeholder = particle::LocalParticleFieldFeed::load(
v2Metadata(true, false), v2Payload(floatBytes({1, -9999}), std::nullopt, std::vector<char>{1, 0}));
EXPECT_TRUE(placeholder) << placeholder.message;
}
TEST(Source, ParticleFieldV3CompatibilityAndContentIdentityAreChannelAware) {
const std::vector<char> 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<char> 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");