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

@@ -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");