feat: add provider-neutral particle field adapter contract
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
This commit is contained in:
@@ -212,10 +212,12 @@ list(APPEND SRC_FILES
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/renderer/layers/collision_layer_tweaker.hpp
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/offline_fixture_particle_adapter.cpp
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/offline_fixture_particle_adapter.hpp
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_data_adapter.cpp
|
||||
${PROJECT_SOURCE_DIR}/src/mbgl/shaders/shader_program_base.cpp
|
||||
)
|
||||
|
||||
list(APPEND INCLUDE_FILES
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_data_adapter.hpp
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor_ref.hpp
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor.hpp
|
||||
${PROJECT_SOURCE_DIR}/include/mbgl/actor/aspiring_actor.hpp
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# STEP-049 Provider-neutral Field Data Adapter 与 Runtime 输入契约
|
||||
|
||||
- 状态:进行中(等待 Android 综合验收)
|
||||
- 目标:建立不包含 provider/network/vendor 语义的 C++ Field descriptor、payload adapter 与 immutable Snapshot 输入边界,并复用既有 Runtime Field Window。
|
||||
- 基线:`f0954476783b60c6ad4fdde7229657abed2e70f6`,`particle/main`,与 `origin/particle/main` 一致。
|
||||
|
||||
## 实际实现结构
|
||||
|
||||
新增 `ParticleFieldDescriptor`、`ParticleFieldSnapshot`、`ParticleFieldDataAdapter` 与 `ParticleFieldAdapterResult`,位于 `include/mbgl/particle/field/field_data_adapter.hpp` / `src/mbgl/particle/field/field_data_adapter.cpp`。descriptor 仅描述 field identity、类型/域、有效时间、网格 bounds、U/V canonical layout、format、stride/byte length、no-data、版本和可选 checksum;不包含 URL、token、provider 或网络对象。
|
||||
|
||||
`ParticleFieldDataAdapter::decode()` 校验 descriptor、`interleaved-uv` + `float32-le`、尺寸/stride、payload 长度和 finite data,生成拥有自有 `std::vector` 的 Snapshot。`OfflineFixtureParticleAdapter` 仍保留 `fixture-v1` schema、fixture whitelist、metadata 与 checksum 校验,但最终通过正式 adapter 输出 Snapshot;因此 fixture 不是 production adapter 的限制。
|
||||
|
||||
## 错误与接线
|
||||
|
||||
结构化错误包括 `InvalidDescriptor`、`UnsupportedFormat`、`DimensionMismatch`、`PayloadSizeMismatch`、`InvalidTimestampOrIdentity`、`NonFiniteData` 和 `DecodeValidationFailure`,错误码与 message 分离。`RasterArraySource` 继续只调用 `updateRuntimeFieldWindow()`;没有第二套 T0/T1 状态机,`OLD_T1 → NEW_T0`、正常只上传 NEW_T1、Duplicate NOOP、MIX property-only 和 fail-closed 语义未改。
|
||||
|
||||
Renderer 仅将日志字段从 fixture identity 改为 neutral `fieldID`,未引入 provider/network/vendor、decode、Timeline 或缓存业务。Timeline、Playback、WeatherColorLayer、Android JNI 和 GPU simulation 未改。
|
||||
|
||||
## 验证与待验收
|
||||
|
||||
- Linux OpenGL `mbgl-test-runner` 构建:PASS。
|
||||
- 定向测试:`Source.ParticleFieldDataAdapter*`、`Source.RasterArray*` 共 11 项,PASS。
|
||||
- `git diff --check`:PASS。
|
||||
- Android Gradle/APK/ADB/设备测试:按任务要求未执行,等待用户一次综合验收,覆盖 EAST/NORTH/VORTEX、Playback、neutral valid/invalid、payload mismatch、reuse、MIX、no black screen。
|
||||
- Jest:未新增、未执行。
|
||||
|
||||
## 必做项
|
||||
|
||||
- [x] neutral descriptor + immutable Snapshot
|
||||
- [x] C++ Data Adapter 与 structured error
|
||||
- [x] fixture wrapper 复用正式 adapter
|
||||
- [x] RasterArraySource 继续复用 Runtime Field Window
|
||||
- [x] 未新增 Provider Framework / network / vendor 逻辑
|
||||
- [x] Linux 定向构建与测试
|
||||
- [x] Step、开发记录与完成报告
|
||||
- [ ] Android 综合验收与 `CLOSED / PASS`
|
||||
|
||||
## 阻塞与下一步
|
||||
|
||||
当前无代码阻塞;正式关闭依赖用户执行任务书规定的一次 Android 综合验收。Codex 不执行 Android build/install/ADB。
|
||||
@@ -22,3 +22,11 @@ Gitea API 与 Git remote 均确认调查基线为 `46dceb5cb6110c94393b7413465f8
|
||||
## 输出与收尾
|
||||
|
||||
生成:本调查报告及本完成报告。未修改功能代码、测试、Android、Renderer 或 build 文件;未执行 Android Gradle、ADB、Jest。`git diff --check` 通过后仅提交两份调查文档并 push `origin/particle/main`。
|
||||
|
||||
## Git 收尾补充
|
||||
|
||||
- commit hash:`f0954476783b60c6ad4fdde7229657abed2e70f6`
|
||||
- commit message:`docs: investigate next particle runtime stage`
|
||||
- push result:`SUCCESS`
|
||||
- remote branch:`origin/particle/main`
|
||||
- Final HEAD:`f0954476783b60c6ad4fdde7229657abed2e70f6`
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# STEP-049 Provider-neutral Field Data Adapter 与 Runtime 输入契约 — Codex 完成报告
|
||||
|
||||
## 结果
|
||||
|
||||
已完成 C++ provider-neutral Field descriptor、payload adapter、structured error 和 immutable Snapshot 接线。状态:`IMPLEMENTED / WAITING FOR ANDROID VALIDATION`;不能在用户 Android 验收前宣称 `CLOSED / PASS`。
|
||||
|
||||
## Git 基线与修改
|
||||
|
||||
- 起始 HEAD:`f0954476783b60c6ad4fdde7229657abed2e70f6`
|
||||
- branch:`particle/main`
|
||||
- origin 起始 HEAD:`f0954476783b60c6ad4fdde7229657abed2e70f6`
|
||||
- 实际修改:`CMakeLists.txt`、Field adapter 新增头/源文件、fixture adapter、`RasterArraySource`、validation renderer 日志、`test/style/source.test.cpp`、STEP-048 报告、STEP-049 Step 记录、粒子开发记录。
|
||||
- 未修改/未 stage:工作区已有的无关 untracked 任务文件与 `STEP-046-e48fa087-review.diff`。
|
||||
|
||||
## 设计结论
|
||||
|
||||
`ParticleFieldDescriptor` 是最小 canonical 输入契约,包含 field identity、类型/显示域、valid time、网格 bounds、dimensions、layout/encoding、stride、byte length、no-data、data version 和可选 checksum。`ParticleFieldDataAdapter::decode()` 只接收 descriptor、bytes、runtime generation,校验后生成自持有 vectors 的 Snapshot;不持有 Android、Renderer、Timeline、provider 或 network 对象。
|
||||
|
||||
`OfflineFixtureParticleAdapter` 继续负责 fixture-v1 metadata/whitelist/checksum,并调用正式 adapter。`RasterArraySource` 仍复用唯一的 `updateRuntimeFieldWindow()`;没有改变 T0/T1、Timeline、Playback、MIX、GPU reuse 或 Renderer 业务边界。
|
||||
|
||||
## 验证
|
||||
|
||||
- C++ / Core / OpenGL:`cmake --build build-linux-opengl --target mbgl-test-runner -j3`,PASS。
|
||||
- 定向已有测试:`./mbgl-test-runner --gtest_filter='Source.ParticleFieldDataAdapter*:Source.RasterArray*'`,11/11 PASS。
|
||||
- `git diff --check`:PASS。
|
||||
- Android Gradle、APK copy/install、ADB、设备:未执行(任务明确由用户承担)。
|
||||
- Jest:未新增、未执行。
|
||||
|
||||
## Git 收尾
|
||||
|
||||
- commit hash:`3c9c58b57`
|
||||
- commit message:`feat: add provider-neutral particle field adapter contract`
|
||||
- push result:待执行
|
||||
- remote branch:`origin/particle/main`
|
||||
- Final HEAD:`3c9c58b57`(push 前)
|
||||
|
||||
## 未完成项与风险
|
||||
|
||||
- 用户尚未执行 Android 一次综合验收,因此 EAST/NORTH/VORTEX、Playback、neutral valid/invalid、fail-closed、reuse、MIX 与 no-black-screen 的设备结果待补。
|
||||
- 当前 adapter canonical format 仍只支持 `interleaved-uv` + `float32-le`,这是本阶段现有 Runtime 的明确边界,不是 provider schema。
|
||||
- `ParticleFieldSnapshot::isValid()` 继续按当前 Wind/Global renderer 语义校验;Wave/Tide/Current 的 Ocean-only 语义不在本阶段实现。
|
||||
95
include/mbgl/particle/field/field_data_adapter.hpp
Normal file
95
include/mbgl/particle/field/field_data_adapter.hpp
Normal file
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mbgl::particle {
|
||||
|
||||
// Provider-neutral description of the canonical field bytes consumed by Core.
|
||||
// Provider names, URLs and transport metadata deliberately do not cross this boundary.
|
||||
struct ParticleFieldDescriptor {
|
||||
std::string fieldID;
|
||||
std::string fieldType;
|
||||
std::string displayDomain;
|
||||
uint64_t validTime = 0;
|
||||
uint32_t width = 0;
|
||||
uint32_t height = 0;
|
||||
double west = -180.0;
|
||||
double south = -85.0;
|
||||
double east = 180.0;
|
||||
double north = 85.0;
|
||||
float noData = -9999.0f;
|
||||
uint64_t dataVersion = 0;
|
||||
uint64_t byteLength = 0;
|
||||
uint32_t stride = 0;
|
||||
std::string layout = "interleaved-uv";
|
||||
std::string encoding = "float32-le";
|
||||
std::string fieldSha256;
|
||||
};
|
||||
|
||||
struct ParticleFieldSnapshot {
|
||||
std::string fieldID;
|
||||
std::string fieldType;
|
||||
std::string displayDomain;
|
||||
uint64_t validTime = 0;
|
||||
uint32_t width = 0;
|
||||
uint32_t height = 0;
|
||||
double west = -180.0;
|
||||
double south = -85.0;
|
||||
double east = 180.0;
|
||||
double north = 85.0;
|
||||
float noData = -9999.0f;
|
||||
uint64_t dataVersion = 0;
|
||||
uint64_t generation = 0;
|
||||
std::string fieldSha256;
|
||||
std::vector<float> velocity;
|
||||
std::vector<uint8_t> valid;
|
||||
|
||||
bool isValid() const;
|
||||
bool isTimeInterpolationCompatibleWith(const ParticleFieldSnapshot&) const;
|
||||
};
|
||||
|
||||
inline bool fieldIdentityChanged(const ParticleFieldSnapshot& current, const ParticleFieldSnapshot& next) {
|
||||
return current.generation != next.generation || current.fieldID != next.fieldID ||
|
||||
current.dataVersion != next.dataVersion;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
struct ParticleFieldTimeWindow {
|
||||
ParticleFieldSnapshot t0;
|
||||
ParticleFieldSnapshot t1;
|
||||
|
||||
bool isValid() const { return t0.isValid() && t1.isValid() && t0.isTimeInterpolationCompatibleWith(t1); }
|
||||
};
|
||||
|
||||
enum class ParticleFieldAdapterError : uint8_t {
|
||||
None,
|
||||
InvalidDescriptor,
|
||||
UnsupportedFormat,
|
||||
DimensionMismatch,
|
||||
PayloadSizeMismatch,
|
||||
InvalidTimestampOrIdentity,
|
||||
NonFiniteData,
|
||||
DecodeValidationFailure,
|
||||
};
|
||||
|
||||
struct ParticleFieldAdapterResult {
|
||||
std::optional<ParticleFieldSnapshot> snapshot;
|
||||
ParticleFieldAdapterError error = ParticleFieldAdapterError::None;
|
||||
std::string message;
|
||||
|
||||
explicit operator bool() const { return snapshot.has_value(); }
|
||||
};
|
||||
|
||||
class ParticleFieldDataAdapter final {
|
||||
public:
|
||||
static ParticleFieldAdapterResult decode(const ParticleFieldDescriptor&, const std::vector<char>&, uint64_t generation);
|
||||
};
|
||||
|
||||
} // namespace mbgl::particle
|
||||
78
src/mbgl/particle/field/field_data_adapter.cpp
Normal file
78
src/mbgl/particle/field/field_data_adapter.cpp
Normal file
@@ -0,0 +1,78 @@
|
||||
#include <mbgl/particle/field/field_data_adapter.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
|
||||
namespace mbgl::particle {
|
||||
namespace {
|
||||
ParticleFieldAdapterResult reject(ParticleFieldAdapterError error, std::string message) {
|
||||
return {.snapshot = std::nullopt, .error = error, .message = std::move(message)};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
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); });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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() || generation == 0) {
|
||||
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");
|
||||
}
|
||||
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 = 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));
|
||||
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");
|
||||
}
|
||||
return {.snapshot = std::move(field), .error = ParticleFieldAdapterError::None, .message = {}};
|
||||
}
|
||||
} // namespace mbgl::particle
|
||||
@@ -107,26 +107,6 @@ std::string sha256(const std::vector<char>& input) {
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ParticleFieldSnapshot::isValid() const {
|
||||
if (fixtureID.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) {
|
||||
return false;
|
||||
}
|
||||
for (const float component : velocity) {
|
||||
if (!std::isfinite(component)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
ParticleFieldSnapshot OfflineFixtureParticleAdapter::load(const std::string& fixtureID,
|
||||
const std::string& metadataText,
|
||||
const std::vector<char>& bytes,
|
||||
@@ -173,45 +153,35 @@ ParticleFieldSnapshot OfflineFixtureParticleAdapter::load(const std::string& fix
|
||||
std::string(vector["file"].GetString()) != "field.f32") {
|
||||
throw std::invalid_argument("unsupported fixture v1 layout: " + fixtureID);
|
||||
}
|
||||
ParticleFieldSnapshot field;
|
||||
field.fixtureID = fixtureID;
|
||||
field.fieldType = member(metadata, "fieldType").GetString();
|
||||
field.displayDomain = member(metadata, "displayDomain").GetString();
|
||||
field.width = grid["width"].GetUint();
|
||||
field.height = grid["height"].GetUint();
|
||||
field.west = bounds["west"].GetDouble();
|
||||
field.south = bounds["south"].GetDouble();
|
||||
field.east = bounds["east"].GetDouble();
|
||||
field.north = bounds["north"].GetDouble();
|
||||
field.noData = static_cast<float>(vector["noData"].GetDouble());
|
||||
if (!std::isfinite(field.noData)) {
|
||||
ParticleFieldDescriptor descriptor;
|
||||
descriptor.fieldID = fixtureID;
|
||||
descriptor.fieldType = member(metadata, "fieldType").GetString();
|
||||
descriptor.displayDomain = member(metadata, "displayDomain").GetString();
|
||||
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.noData = static_cast<float>(vector["noData"].GetDouble());
|
||||
if (!std::isfinite(descriptor.noData)) {
|
||||
throw std::invalid_argument("non-finite fixture noData: " + fixtureID);
|
||||
}
|
||||
field.dataVersion = member(metadata, "dataVersion").GetUint64();
|
||||
field.generation = generation;
|
||||
const size_t expected = static_cast<size_t>(field.width) * field.height * 2 * sizeof(float);
|
||||
descriptor.dataVersion = member(metadata, "dataVersion").GetUint64();
|
||||
descriptor.byteLength = static_cast<uint64_t>(descriptor.width) * descriptor.height * 2 * sizeof(float);
|
||||
descriptor.fieldSha256 = member(integrity, "fieldSha256").GetString();
|
||||
const size_t expected = static_cast<size_t>(descriptor.byteLength);
|
||||
if (bytes.size() != expected || member(integrity, "fieldByteLength").GetUint64() != expected) {
|
||||
throw std::invalid_argument("fixture field length mismatch: " + fixtureID);
|
||||
}
|
||||
field.fieldSha256 = member(integrity, "fieldSha256").GetString();
|
||||
if (sha256(bytes) != field.fieldSha256) {
|
||||
if (sha256(bytes) != descriptor.fieldSha256) {
|
||||
throw std::invalid_argument("fixture field checksum mismatch: " + fixtureID);
|
||||
}
|
||||
field.velocity.resize(expected / sizeof(float));
|
||||
std::memcpy(field.velocity.data(), bytes.data(), expected);
|
||||
field.valid.resize(static_cast<size_t>(field.width) * field.height);
|
||||
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)) {
|
||||
throw std::invalid_argument("non-finite fixture velocity: " + fixtureID);
|
||||
}
|
||||
field.valid[cell] = (u == field.noData || v == field.noData) ? 0 : 1;
|
||||
auto result = ParticleFieldDataAdapter::decode(descriptor, bytes, generation);
|
||||
if (!result) {
|
||||
throw std::invalid_argument("field adapter rejected fixture: " + result.message);
|
||||
}
|
||||
if (!field.isValid()) {
|
||||
throw std::invalid_argument("invalid offline particle fixture: " + fixtureID);
|
||||
}
|
||||
return field;
|
||||
return std::move(*result.snapshot);
|
||||
}
|
||||
|
||||
ParticleFieldSnapshot OfflineFixtureParticleAdapter::load(const std::string& fixtureID, const uint64_t generation) {
|
||||
|
||||
@@ -1,76 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <mbgl/particle/field/field_data_adapter.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mbgl::particle {
|
||||
|
||||
// Immutable, backend-neutral Field snapshot used by the first offline particle
|
||||
// implementation. The renderer owns only a GPU representation of this value.
|
||||
struct ParticleFieldSnapshot {
|
||||
std::string fixtureID;
|
||||
std::string fieldType;
|
||||
std::string displayDomain;
|
||||
uint32_t width = 0;
|
||||
uint32_t height = 0;
|
||||
double west = -180.0;
|
||||
double south = -85.0;
|
||||
double east = 180.0;
|
||||
double north = 85.0;
|
||||
float noData = -9999.0f;
|
||||
uint64_t dataVersion = 0;
|
||||
uint64_t generation = 0;
|
||||
// Verified integrity.fieldSha256 identifies immutable GPU-upload content
|
||||
// across source reloads; generation remains a separate runtime revision.
|
||||
std::string fieldSha256;
|
||||
// Row-major, little-endian contract decoded as interleaved canonical
|
||||
// U-east/V-north values. A noData component makes the cell invalid.
|
||||
std::vector<float> velocity;
|
||||
std::vector<uint8_t> valid;
|
||||
|
||||
bool isValid() const;
|
||||
// Two snapshots can be interpolated only when their geographic sampling
|
||||
// contract is identical. Fixture identity/version intentionally do not
|
||||
// participate: they identify different values in the same grid.
|
||||
bool isTimeInterpolationCompatibleWith(const ParticleFieldSnapshot&) const;
|
||||
};
|
||||
|
||||
// A snapshot revision identifies a runtime load, while fixtureID and
|
||||
// dataVersion identify its declared content. Keep all three checks at the GPU
|
||||
// upload boundary so a recreated source cannot silently reuse an old field.
|
||||
inline bool fieldIdentityChanged(const ParticleFieldSnapshot& current, const ParticleFieldSnapshot& next) {
|
||||
return current.generation != next.generation || current.fixtureID != next.fixtureID ||
|
||||
current.dataVersion != next.dataVersion;
|
||||
}
|
||||
|
||||
inline bool sameFieldGpuContent(const ParticleFieldSnapshot& current, const ParticleFieldSnapshot& next) {
|
||||
return current.fixtureID == next.fixtureID && current.dataVersion == next.dataVersion &&
|
||||
current.fieldSha256 == next.fieldSha256 && current.isTimeInterpolationCompatibleWith(next);
|
||||
}
|
||||
|
||||
// A formal source time window. T0/T1 remain independent immutable snapshots;
|
||||
// the renderer uploads and samples them independently on the GPU.
|
||||
struct ParticleFieldTimeWindow {
|
||||
ParticleFieldSnapshot t0;
|
||||
ParticleFieldSnapshot t1;
|
||||
|
||||
bool isValid() const { return t0.isValid() && t1.isValid() && t0.isTimeInterpolationCompatibleWith(t1); }
|
||||
};
|
||||
|
||||
// Phase-one OfflineFixtureParticleAdapter. Its public contract deliberately
|
||||
// contains no Android/UI/GPU objects, allowing the same decoded snapshot to be
|
||||
// used by future Android Vulkan and iOS renderers.
|
||||
// Validation-only wrapper for the frozen offline fixture-v1 metadata schema.
|
||||
// The resulting snapshot is always produced by ParticleFieldDataAdapter.
|
||||
class OfflineFixtureParticleAdapter final {
|
||||
public:
|
||||
// Generic loader boundary used by FileSource-backed platform inputs. It
|
||||
// intentionally receives bytes only: no Android asset API crosses Core.
|
||||
static ParticleFieldSnapshot load(const std::string& fixtureID,
|
||||
const std::string& metadata,
|
||||
const std::vector<char>& fieldBytes,
|
||||
uint64_t generation);
|
||||
|
||||
// Linux fixture smoke-test helper. Android must use the byte loader above.
|
||||
static ParticleFieldSnapshot load(const std::string& fixtureID, uint64_t generation);
|
||||
};
|
||||
|
||||
|
||||
@@ -513,7 +513,7 @@ public:
|
||||
Log::Info(Event::Render,
|
||||
"ParticleRender: stateSeed=" + std::string(seedState ? "INITIALIZE" : "CONTINUE") +
|
||||
" generation=" + std::to_string(resources->generation) + " state=" + slotName(readSlot) +
|
||||
"->" + slotName(writeSlot) + " fixture=" + resources->field.fixtureID);
|
||||
"->" + slotName(writeSlot) + " field=" + resources->field.fieldID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,7 +734,7 @@ void RenderRasterParticleLayer::evaluate(const PropertyEvaluationParameters&) {
|
||||
Log::Info(Event::Render,
|
||||
"ParticleRender: state=" + std::string(settings.active ? "ACTIVE" : "STATIC") +
|
||||
" maskMode=" + std::to_string(settings.maskMode) + " id=" + getID() + " fixture=" +
|
||||
(formalSource && sourceField ? sourceField->fixtureID : fixtureID(settings.fixtureMode)) +
|
||||
(formalSource && sourceField ? sourceField->fieldID : fixtureID(settings.fixtureMode)) +
|
||||
" layerIndex=" + std::to_string(getLayerIndex()) + " timeMix=" + std::to_string(settings.timeMix) +
|
||||
" particleCount=" + std::to_string(settings.particleCount) + " lineWidth=" +
|
||||
std::to_string(settings.lineWidth) + " streakLength=" + std::to_string(settings.particleLength) +
|
||||
@@ -951,27 +951,27 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
|
||||
" fullParticleDraw=true resourceGeneration=" + std::to_string(resources->generation));
|
||||
Log::Info(
|
||||
Event::Render,
|
||||
"ParticleField: fixture loaded id=" + resources->field.fixtureID + " type=" + resources->field.fieldType +
|
||||
"ParticleField: field loaded id=" + resources->field.fieldID + " type=" + resources->field.fieldType +
|
||||
" bounds=[" + std::to_string(resources->field.west) + ',' + std::to_string(resources->field.east) +
|
||||
"]x[" + std::to_string(resources->field.south) + ',' + std::to_string(resources->field.north) +
|
||||
"] grid=" + std::to_string(resources->field.width) + 'x' + std::to_string(resources->field.height) +
|
||||
" dataVersion=" + std::to_string(resources->field.dataVersion) +
|
||||
" fieldGeneration=" + std::to_string(resources->fieldGeneration));
|
||||
Log::Info(Event::Render,
|
||||
"ParticleField: fixture validated id=" + resources->field.fixtureID +
|
||||
"ParticleField: field validated id=" + resources->field.fieldID +
|
||||
" noData=" + std::to_string(resources->field.noData) +
|
||||
" velocityCount=" + std::to_string(resources->field.velocity.size()));
|
||||
Log::Info(Event::Render,
|
||||
"ParticleField: created id=" + resources->field.fixtureID +
|
||||
"ParticleField: created id=" + resources->field.fieldID +
|
||||
" generation=" + std::to_string(resources->fieldGeneration));
|
||||
Log::Info(Event::Render,
|
||||
"ParticleGPU: field upload reason=RESOURCE_RECREATE fixture=" + resources->field.fixtureID +
|
||||
"ParticleGPU: field upload reason=RESOURCE_RECREATE field=" + resources->field.fieldID +
|
||||
" fieldGeneration=" + std::to_string(resources->fieldGeneration) +
|
||||
" gpuFieldGeneration=" + std::to_string(resources->gpuFieldGeneration) +
|
||||
" resourceGeneration=" + std::to_string(resources->generation));
|
||||
Log::Info(Event::Render,
|
||||
"ParticleGPU: time window T0=" + resources->field.fixtureID + "@" +
|
||||
std::to_string(resources->field.dataVersion) + " T1=" + resources->nextField.fixtureID + "@" +
|
||||
"ParticleGPU: time window T0=" + resources->field.fieldID + "@" +
|
||||
std::to_string(resources->field.dataVersion) + " T1=" + resources->nextField.fieldID + "@" +
|
||||
std::to_string(resources->nextField.dataVersion) +
|
||||
" uploads=" + std::to_string(resources->fieldUploadCount));
|
||||
Log::Info(
|
||||
@@ -1126,8 +1126,8 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
|
||||
resources->fixtureMode = fixtureMode;
|
||||
if (!windowOverlap) resources->resetSequence("FIELD_NO_OVERLAP");
|
||||
Log::Info(Event::Render,
|
||||
"ParticleGPU: time window advance T0=" + oldT0.fixtureID + " T1=" + oldT1.fixtureID +
|
||||
" -> T0=" + resources->field.fixtureID + " T1=" + resources->nextField.fixtureID +
|
||||
"ParticleGPU: time window advance T0=" + oldT0.fieldID + " T1=" + oldT1.fieldID +
|
||||
" -> T0=" + resources->field.fieldID + " T1=" + resources->nextField.fieldID +
|
||||
" reuse=" + (windowOverlap ? "true" : "false") +
|
||||
" uploads=" + std::to_string(resources->fieldUploadCount) + " simulationRebuild=" +
|
||||
(windowOverlap ? "false" : "true") + " trailCleared=" + (windowOverlap ? "false" : "true"));
|
||||
@@ -1145,7 +1145,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
|
||||
if (!sourceTimeline || std::abs(resources->timeMix * 4.0f - std::round(resources->timeMix * 4.0f)) < 0.01f) {
|
||||
Log::Info(Event::Render,
|
||||
"ParticleGPU: event=TIME_MIX_UPDATE value=" + std::to_string(resources->timeMix) +
|
||||
" T0=" + resources->field.fixtureID + " T1=" + resources->nextField.fixtureID +
|
||||
" T0=" + resources->field.fieldID + " T1=" + resources->nextField.fieldID +
|
||||
" renderLayerInstanceId=" + std::to_string(resources->renderLayerInstanceId) +
|
||||
" resourceGeneration=" + std::to_string(resources->generation) +
|
||||
" fieldUploadCount=" + std::to_string(resources->fieldUploadCount) +
|
||||
@@ -1182,7 +1182,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
|
||||
Log::Info(Event::Render,
|
||||
"ParticleRender: lifecycle transition=" + std::string(active ? "ACTIVE" : "STATIC") +
|
||||
" resourceGeneration=" + std::to_string(resources->generation) + " fixture=" +
|
||||
resources->field.fixtureID + " simulationFrame=" + std::to_string(resources->committedFrame) +
|
||||
resources->field.fieldID + " simulationFrame=" + std::to_string(resources->committedFrame) +
|
||||
" stateRead=" + slotName(resources->stateRead) + " trailRead=" + slotName(resources->trailRead));
|
||||
resources->lifecycleModeKnown = true;
|
||||
resources->lifecycleActive = active;
|
||||
@@ -1316,7 +1316,7 @@ void RenderRasterParticleLayer::update(gfx::ShaderRegistry& shaders,
|
||||
resources->pending = true;
|
||||
Log::Info(Event::Render,
|
||||
"ParticleRender: STATIC bootstrap scheduled resourceGeneration=" +
|
||||
std::to_string(resources->generation) + " fixture=" + resources->field.fixtureID +
|
||||
std::to_string(resources->generation) + " field=" + resources->field.fieldID +
|
||||
" simulationFrame=" + std::to_string(resources->committedFrame));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,18 +78,18 @@ RasterArrayTimelineFrame RasterArrayTimelineRuntime::tick() {
|
||||
state = RasterArrayPlaybackState::Waiting;
|
||||
Log::Warning(Event::Render,
|
||||
std::string("ParticleTimeline: event=TIMELINE_REJECTED reason=") +
|
||||
(invalid ? "INVALID_NEXT" : "GAP_HOLD") + " t1=" + entries[t0 + 1].field->fixtureID);
|
||||
(invalid ? "INVALID_NEXT" : "GAP_HOLD") + " t1=" + entries[t0 + 1].field->fieldID);
|
||||
return frameLocked();
|
||||
}
|
||||
if (!moveToLocked(t0 + 1)) {
|
||||
mix = 1.0f;
|
||||
state = RasterArrayPlaybackState::Ended;
|
||||
Log::Info(Event::Render, "ParticleTimeline: event=END t1=" + entries[t0 + 1].field->fixtureID);
|
||||
Log::Info(Event::Render, "ParticleTimeline: event=END t1=" + entries[t0 + 1].field->fieldID);
|
||||
return frameLocked();
|
||||
}
|
||||
Log::Info(Event::Render,
|
||||
"ParticleTimeline: event=WINDOW_BOUNDARY oldT1=" + entries[t0].field->fixtureID +
|
||||
" newT1=" + entries[t0 + 1].field->fixtureID + " result=APPLIED reuse=OLD_T1_TO_NEW_T0");
|
||||
"ParticleTimeline: event=WINDOW_BOUNDARY oldT1=" + entries[t0].field->fieldID +
|
||||
" newT1=" + entries[t0 + 1].field->fieldID + " result=APPLIED reuse=OLD_T1_TO_NEW_T0");
|
||||
Log::Info(Event::Render,
|
||||
"ParticleTimeline: event=TIMELINE_CACHE_HIT index=" + std::to_string(t0 + 1) +
|
||||
" cacheSize=" + std::to_string(entries.size()) + " capacity=" + std::to_string(cacheCapacity_));
|
||||
@@ -332,7 +332,7 @@ void RasterArraySource::tryLoad() {
|
||||
" dataVersion=" + std::to_string(loadedField.dataVersion));
|
||||
if (impl().nextField) {
|
||||
Log::Info(Event::Render,
|
||||
"ParticleField: source loaded next fixtureId=" + impl().nextField->fixtureID +
|
||||
"ParticleField: source loaded next fieldId=" + impl().nextField->fieldID +
|
||||
" generation=" + std::to_string(impl().nextField->generation) +
|
||||
" dataVersion=" + std::to_string(impl().nextField->dataVersion));
|
||||
}
|
||||
@@ -351,7 +351,7 @@ void RasterArraySource::tryLoadRuntimeField(const size_t index) {
|
||||
candidate.snapshot = particle::OfflineFixtureParticleAdapter::load(
|
||||
candidate.descriptor.fixtureID, *candidate.metadata, *candidate.fieldBytes, 0);
|
||||
Log::Info(Event::Render,
|
||||
"ParticleField: runtime candidate ready fixtureId=" + candidate.snapshot->fixtureID +
|
||||
"ParticleField: runtime candidate ready fieldId=" + candidate.snapshot->fieldID +
|
||||
" dataVersion=" + std::to_string(candidate.snapshot->dataVersion));
|
||||
tryInitializeTimeline();
|
||||
} catch (const std::exception& error) {
|
||||
@@ -414,7 +414,7 @@ RasterArrayRuntimeFieldUpdateResult RasterArraySource::updateRuntimeFieldWindow(
|
||||
if (particle::sameFieldGpuContent(*impl().nextField, newT1)) {
|
||||
Log::Info(Event::Render,
|
||||
"ParticleField: event=FIELD_WINDOW_UPDATE_APPLIED result=NOOP sourceId=" + getID() +
|
||||
" T0=" + impl().field->fixtureID + " T1=" + impl().nextField->fixtureID);
|
||||
" T0=" + impl().field->fieldID + " T1=" + impl().nextField->fieldID);
|
||||
return RasterArrayRuntimeFieldUpdateResult::Noop;
|
||||
}
|
||||
if (newT1.generation <= impl().nextField->generation) {
|
||||
@@ -422,20 +422,20 @@ RasterArrayRuntimeFieldUpdateResult RasterArraySource::updateRuntimeFieldWindow(
|
||||
"NON_MONOTONIC_GENERATION");
|
||||
}
|
||||
|
||||
const auto oldT0 = impl().field->fixtureID;
|
||||
const auto oldT1 = impl().nextField->fixtureID;
|
||||
const auto oldT0 = impl().field->fieldID;
|
||||
const auto oldT1 = impl().nextField->fieldID;
|
||||
Log::Info(Event::Render,
|
||||
"ParticleField: event=FIELD_WINDOW_UPDATE_REQUEST sourceId=" + getID() +
|
||||
" sequence=" + std::to_string(runtimeUpdateSequence + 1) + " oldT0=" + oldT0 + " oldT1=" + oldT1 +
|
||||
" newT1=" + newT1.fixtureID + " generation=" + std::to_string(newT1.generation));
|
||||
" newT1=" + newT1.fieldID + " generation=" + std::to_string(newT1.generation));
|
||||
auto newT0 = *impl().nextField;
|
||||
baseImpl = makeMutable<Impl>(impl(), std::move(newT0), std::move(newT1));
|
||||
++runtimeUpdateSequence;
|
||||
observer->onSourceChanged(*this);
|
||||
Log::Info(Event::Render,
|
||||
"ParticleField: event=FIELD_WINDOW_UPDATE_APPLIED result=APPLIED sourceId=" + getID() +
|
||||
" sequence=" + std::to_string(runtimeUpdateSequence) + " newT0=" + impl().field->fixtureID +
|
||||
" newT1=" + impl().nextField->fixtureID +
|
||||
" sequence=" + std::to_string(runtimeUpdateSequence) + " newT0=" + impl().field->fieldID +
|
||||
" newT1=" + impl().nextField->fieldID +
|
||||
" generation=" + std::to_string(impl().nextField->generation));
|
||||
return RasterArrayRuntimeFieldUpdateResult::Applied;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include <mbgl/style/sources/raster_source.hpp>
|
||||
#include <mbgl/style/sources/vector_source.hpp>
|
||||
#include <mbgl/style/style.hpp>
|
||||
#include <mbgl/particle/field/field_data_adapter.hpp>
|
||||
|
||||
#include <mbgl/renderer/sources/render_geojson_source.hpp>
|
||||
#include <mbgl/renderer/sources/render_raster_array_source.hpp>
|
||||
@@ -53,6 +54,7 @@
|
||||
#include <gmock/gmock.h>
|
||||
|
||||
#include <optional>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -231,6 +233,46 @@ TEST(Source, RasterArrayGpuContentIdentityExcludesRuntimeGeneration) {
|
||||
EXPECT_FALSE(particle::sameFieldGpuContent(east, changed));
|
||||
}
|
||||
|
||||
TEST(Source, ParticleFieldDataAdapterAcceptsNeutralDescriptor) {
|
||||
particle::ParticleFieldDescriptor descriptor;
|
||||
descriptor.fieldID = "weather-service-wind-20260904T0000Z";
|
||||
descriptor.fieldType = "WIND";
|
||||
descriptor.displayDomain = "GLOBAL";
|
||||
descriptor.width = 1;
|
||||
descriptor.height = 1;
|
||||
descriptor.byteLength = 2 * sizeof(float);
|
||||
float values[] = {1.0f, 2.0f};
|
||||
std::vector<char> bytes(sizeof(values));
|
||||
std::memcpy(bytes.data(), values, sizeof(values));
|
||||
|
||||
const auto result = particle::ParticleFieldDataAdapter::decode(descriptor, bytes, 1);
|
||||
ASSERT_TRUE(result);
|
||||
ASSERT_TRUE(result.snapshot);
|
||||
EXPECT_EQ(result.snapshot->fieldID, descriptor.fieldID);
|
||||
EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::None);
|
||||
}
|
||||
|
||||
TEST(Source, ParticleFieldDataAdapterReturnsStructuredValidationErrors) {
|
||||
particle::ParticleFieldDescriptor descriptor;
|
||||
descriptor.fieldID = "field";
|
||||
descriptor.fieldType = "WIND";
|
||||
descriptor.displayDomain = "GLOBAL";
|
||||
descriptor.width = 2;
|
||||
descriptor.height = 1;
|
||||
descriptor.byteLength = 4 * sizeof(float);
|
||||
|
||||
std::vector<char> bytes(3 * sizeof(float));
|
||||
auto result = particle::ParticleFieldDataAdapter::decode(descriptor, bytes, 1);
|
||||
EXPECT_FALSE(result);
|
||||
EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::PayloadSizeMismatch);
|
||||
|
||||
descriptor.byteLength = bytes.size();
|
||||
descriptor.encoding = "provider-packed";
|
||||
result = particle::ParticleFieldDataAdapter::decode(descriptor, bytes, 1);
|
||||
EXPECT_FALSE(result);
|
||||
EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::UnsupportedFormat);
|
||||
}
|
||||
|
||||
TEST(Source, RasterArrayGpuReuseConsumesNewRevision) {
|
||||
const auto oldT1 = particle::OfflineFixtureParticleAdapter::load("uniform-north", 100);
|
||||
auto wantedNewT0 = oldT1;
|
||||
@@ -306,9 +348,9 @@ TEST(Source, RasterArrayRuntimeFieldWindowAdvancesWithoutReplacingTheSource) {
|
||||
const auto nodata = particle::OfflineFixtureParticleAdapter::load("nodata", vortex.generation + 1);
|
||||
|
||||
EXPECT_EQ(source.updateRuntimeFieldWindow(vortex), style::RasterArrayRuntimeFieldUpdateResult::Applied);
|
||||
EXPECT_EQ(source.impl().field->fixtureID, "uniform-north");
|
||||
EXPECT_EQ(source.impl().field->fieldID, "uniform-north");
|
||||
EXPECT_EQ(source.impl().field->generation, initialT1Generation);
|
||||
EXPECT_EQ(source.impl().nextField->fixtureID, "vortex");
|
||||
EXPECT_EQ(source.impl().nextField->fieldID, "vortex");
|
||||
EXPECT_EQ(source.impl().nextField->generation, vortex.generation);
|
||||
EXPECT_EQ(sourceChangedCount, 2u);
|
||||
|
||||
@@ -317,18 +359,18 @@ TEST(Source, RasterArrayRuntimeFieldWindowAdvancesWithoutReplacingTheSource) {
|
||||
invalid.velocity.clear();
|
||||
EXPECT_EQ(source.updateRuntimeFieldWindow(invalid),
|
||||
style::RasterArrayRuntimeFieldUpdateResult::RejectedInvalidSnapshot);
|
||||
EXPECT_EQ(source.impl().field->fixtureID, "uniform-north");
|
||||
EXPECT_EQ(source.impl().nextField->fixtureID, "vortex");
|
||||
EXPECT_EQ(source.impl().field->fieldID, "uniform-north");
|
||||
EXPECT_EQ(source.impl().nextField->fieldID, "vortex");
|
||||
|
||||
EXPECT_EQ(source.updateRuntimeFieldWindow(nodata), style::RasterArrayRuntimeFieldUpdateResult::Applied);
|
||||
EXPECT_EQ(source.impl().field->fixtureID, "vortex");
|
||||
EXPECT_EQ(source.impl().nextField->fixtureID, "nodata");
|
||||
EXPECT_EQ(source.impl().field->fieldID, "vortex");
|
||||
EXPECT_EQ(source.impl().nextField->fieldID, "nodata");
|
||||
EXPECT_EQ(sourceChangedCount, 3u);
|
||||
EXPECT_NE(source.impl().field->generation, initialT0Generation);
|
||||
|
||||
EXPECT_EQ(source.repeatRuntimeFieldWindow(), style::RasterArrayRuntimeFieldUpdateResult::Noop);
|
||||
EXPECT_EQ(source.impl().field->fixtureID, "vortex");
|
||||
EXPECT_EQ(source.impl().nextField->fixtureID, "nodata");
|
||||
EXPECT_EQ(source.impl().field->fieldID, "vortex");
|
||||
EXPECT_EQ(source.impl().nextField->fieldID, "nodata");
|
||||
EXPECT_EQ(sourceChangedCount, 3u);
|
||||
}
|
||||
|
||||
|
||||
@@ -348,3 +348,10 @@
|
||||
- Android 最终验收在 `1343929474796b1313489ee9fecbf23b949580a7` 功能基线上全部 PASS,STEP-047 正式 `CLOSED / PASS`。
|
||||
- Playback tick、Pause/Resume、0.5x/1x/2x、Seek/Step、Gap/Invalid fail-closed 与 Recover、Preload/Cache、`OLD_T1 → NEW_T0` reuse、仅上传 NEW_T1、无正常 reset/clear trail、VIS/MAP/MORE 与紧凑 UI 均已验证。
|
||||
- snapshots=6/cacheCapacity=8 下没有 CACHE_EVICT 是正常容量现象。状态栏对自动 MIX/WAITING/INVALID 的展示记录为非阻塞 Validation UI observability 技术债;TimeStats 上限信息为 Android RenderEngine 环境噪音。此次仅文档收尾,未修改功能代码。
|
||||
## 2026-09-04 — STEP-049 Provider-neutral C++ Field Data Adapter 与 Runtime 输入契约
|
||||
|
||||
- 新增 `ParticleFieldDescriptor`、`ParticleFieldDataAdapter`、structured error result 与 provider-neutral `ParticleFieldSnapshot`;descriptor/payload 边界不携带 provider、URL、token、network 或 Renderer 对象。
|
||||
- `OfflineFixtureParticleAdapter` 保留 fixture-v1 whitelist、metadata/checksum 校验,但最终调用正式 Data Adapter;EAST/NORTH/VORTEX/NODATA validation fixture 继续工作,fixture identity 改为 neutral `fieldID`。
|
||||
- `RasterArraySource` 继续复用唯一 `updateRuntimeFieldWindow()`;没有第二套 T0/T1、Timeline、Playback、GPU upload policy 或 Renderer 业务语义。`OLD_T1 → NEW_T0`、NEW_T1-only、Duplicate NOOP、MIX property-only 和 fail-closed 保持。
|
||||
- Linux OpenGL `mbgl-test-runner` 构建通过,adapter + RasterArray 定向 11 项测试通过,`git diff --check` 通过;未执行 Android Gradle/APK/ADB/设备/Jest。
|
||||
- 状态:`IMPLEMENTED / WAITING FOR ANDROID VALIDATION`,等待用户一次 Android 综合验收后再 docs-only closeout。
|
||||
|
||||
Reference in New Issue
Block a user