feat: add production-like particle field feed
This commit is contained in:
@@ -213,11 +213,13 @@ list(APPEND SRC_FILES
|
|||||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/offline_fixture_particle_adapter.cpp
|
${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/offline_fixture_particle_adapter.hpp
|
||||||
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_data_adapter.cpp
|
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_data_adapter.cpp
|
||||||
|
${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/local_particle_field_feed.cpp
|
||||||
${PROJECT_SOURCE_DIR}/src/mbgl/shaders/shader_program_base.cpp
|
${PROJECT_SOURCE_DIR}/src/mbgl/shaders/shader_program_base.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND INCLUDE_FILES
|
list(APPEND INCLUDE_FILES
|
||||||
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_data_adapter.hpp
|
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_data_adapter.hpp
|
||||||
|
${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/local_particle_field_feed.hpp
|
||||||
${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor_ref.hpp
|
${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor_ref.hpp
|
||||||
${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor.hpp
|
${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor.hpp
|
||||||
${PROJECT_SOURCE_DIR}/include/mbgl/actor/aspiring_actor.hpp
|
${PROJECT_SOURCE_DIR}/include/mbgl/actor/aspiring_actor.hpp
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# STEP-051 Local Production-like Field Feed 与 Core Snapshot Submit Bridge
|
||||||
|
|
||||||
|
- 状态:`IMPLEMENTED / WAITING_ANDROID_VALIDATION`
|
||||||
|
- Starting HEAD:`885e7aebef7fb5d02acaed57f9dcf98b8d9e9ba3`
|
||||||
|
- 分支:`particle/main`
|
||||||
|
|
||||||
|
## 范围与数据链路
|
||||||
|
|
||||||
|
本阶段建立了不依赖网络、provider 或 vendor 的本地 production-like 入口:
|
||||||
|
|
||||||
|
`local metadata + binary payload → ParticleFieldDescriptor → ParticleFieldDataAdapter → ParticleFieldSnapshot → RasterArraySource → Core Timeline/Runtime Window`。
|
||||||
|
|
||||||
|
入口类为 `mbgl::particle::LocalParticleFieldFeed`(`include/mbgl/particle/field/local_particle_field_feed.hpp`)。它只解析 provider-neutral metadata 和内存中的 payload,不负责文件系统、asset、HTTP 或缓存。
|
||||||
|
|
||||||
|
新增的 `maplibre-particle-field/v1` metadata contract 位于 `particle-production/field-00.json` 至 `field-03.json`,Android 对应资源位于 `platform/android/MapLibreAndroidTestApp/src/main/assets/integration/particle-production/`。字段包括 `fieldId`、`generation`、`validTime`、`dataVersion`、`bounds`、`grid.width/height`、`vector.layout/encoding/noData` 和 `payload.byteLength`。payload 使用既有 canonical float32 binary asset 作为最小离线载荷,但不再经过 fixture schema/wrapper。
|
||||||
|
|
||||||
|
## Ownership 与 Core bridge
|
||||||
|
|
||||||
|
- `fieldId`:producer 在 metadata 中稳定赋值;Renderer、Timeline、Android 不生成。
|
||||||
|
- `generation`:producer/source 赋值。`ParticleFieldDescriptor.generation` 必须非零,并且必须与 Adapter 参数一致;Adapter 不把 0 改成 1,Timeline/Runtime 不猜测默认值。
|
||||||
|
- `validTime`:producer 赋值,`LocalParticleFieldFeed` 要求非零;Timeline 直接消费 Snapshot 的真实时间并要求严格递增。
|
||||||
|
- `ParticleFieldSnapshot` 继续拥有 immutable canonical field 内容和 Core metadata,不含 provider/network/GPU ownership。
|
||||||
|
|
||||||
|
`RasterArraySource` 新增 `RasterArrayLocalFieldDescriptor` 和 local resource candidate。`submitInitialRuntimeFieldWindow(T0, T1)` 是薄的 Core submit bridge:检查 Snapshot、兼容性和严格时间顺序后建立 authoritative 初始窗口;后续仍通过现有 `updateRuntimeFieldWindow()`,保持 `OLD_T1 → NEW_T0` reuse、only `NEW_T1` upload 和 Duplicate NOOP。没有复制 Timeline 或 Runtime Window。
|
||||||
|
|
||||||
|
Initial T0/T1 由同一 Source 的 local candidates 形成;Timeline preload 使用所有 Snapshot 的 `validTime`。正常连续 playback 仍保持 `simulationRebuild=false`、`trailCleared=false`,MIX 为 property-only。
|
||||||
|
|
||||||
|
## Android validation
|
||||||
|
|
||||||
|
TIME/NORMAL validation 入口现可选择 production-like source(UI 状态标出 `PRODUCTION` 和 `TL` 状态);旧 fixture EAST/NORTH/VORTEX 路径保留。Android Host 只提交 style/resource 命令,不实现解析、Timeline 或 Window 状态机。
|
||||||
|
|
||||||
|
本阶段未执行 Android Gradle、APK build/install、ADB 或 App 测试,等待用户一次综合验收。MIX 文本滞后仍是 `NON-BLOCKING observability`;Rotation/Viewport 自动恢复仍保留为技术债,本阶段未触及 lifecycle/surface 路径。
|
||||||
|
|
||||||
|
## 测试与边界
|
||||||
|
|
||||||
|
新增 `Source.LocalProductionFeedSubmitsSnapshotsWithProducerTime` 和 `Source.LocalProductionFeedRejectsMissingGeneration`,覆盖四个 production-like 时间片、Adapter、初始 submit、Timeline READY/PLAY 及正式非法校验。原有 READY/PLAY、Pause/Resume、Window Boundary、Gap、Invalid、Recover、Duplicate NOOP 测试继续运行。
|
||||||
|
|
||||||
|
未修改 Renderer 业务边界、WeatherColorLayer、Timeline state machine、Runtime Field Window 语义;未引入 Weather Service、Provider Framework、Network、第二套 Adapter/Timeline/Window。
|
||||||
|
|
||||||
|
## 验证结果
|
||||||
|
|
||||||
|
- Linux OpenGL `mbgl-test-runner` 精确构建:PASS。
|
||||||
|
- STEP-051 及既有相关定向测试:14/14 PASS。
|
||||||
|
- `git diff --check`:PASS(提交前执行)。
|
||||||
|
- Jest:未执行。
|
||||||
|
|
||||||
|
当前状态只能记为:`IMPLEMENTED / WAITING_ANDROID_VALIDATION`。
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# STEP-051 Local Production-like Field Feed 与 Core Snapshot Submit Bridge — Codex 完成报告
|
||||||
|
|
||||||
|
## 基线与范围
|
||||||
|
|
||||||
|
- Starting HEAD:`885e7aebef7fb5d02acaed57f9dcf98b8d9e9ba3`
|
||||||
|
- branch:`particle/main`
|
||||||
|
- origin/particle/main 起点:`885e7aebef7fb5d02acaed57f9dcf98b8d9e9ba3`
|
||||||
|
- 任务范围:local production-like field feed、Core Snapshot submit bridge、定向测试、Android validation style/resource、文档。
|
||||||
|
|
||||||
|
## 实际修改
|
||||||
|
|
||||||
|
- `include/mbgl/particle/field/field_data_adapter.hpp`
|
||||||
|
- `src/mbgl/particle/field/field_data_adapter.cpp`
|
||||||
|
- `include/mbgl/particle/field/local_particle_field_feed.hpp`
|
||||||
|
- `src/mbgl/particle/field/local_particle_field_feed.cpp`
|
||||||
|
- `src/mbgl/particle/field/offline_fixture_particle_adapter.cpp`
|
||||||
|
- `include/mbgl/style/sources/raster_array_source.hpp`
|
||||||
|
- `src/mbgl/style/sources/raster_array_source_impl.hpp`
|
||||||
|
- `src/mbgl/style/sources/raster_array_source.cpp`
|
||||||
|
- `src/mbgl/style/conversion/source.cpp`
|
||||||
|
- `test/style/source.test.cpp`
|
||||||
|
- `CMakeLists.txt`
|
||||||
|
- `particle-production/field-00.json` 至 `field-03.json`
|
||||||
|
- Android production metadata assets
|
||||||
|
- `ParticleCoreLayerValidationActivity.kt`
|
||||||
|
- 本 STEP 记录与本报告
|
||||||
|
|
||||||
|
未修改 Renderer、WeatherColorLayer、Timeline state machine、Runtime Field Window 核心语义、Gradle、iOS 或网络代码。未新增 Provider Framework、Weather Service、第二套 Adapter/Timeline/Window。
|
||||||
|
|
||||||
|
## Contract 与 architecture impact
|
||||||
|
|
||||||
|
`ParticleFieldDescriptor.generation` 现在是 producer contract 的显式字段。正式 Adapter 要求 descriptor generation 非零且与 decode 参数一致;不会静默修正非法值。`LocalParticleFieldFeed` 解析 `maplibre-particle-field/v1`,生产者拥有 `fieldId/generation/validTime`,Snapshot 保持 provider-neutral。
|
||||||
|
|
||||||
|
`RasterArraySource` 的 `RasterArrayLocalFieldDescriptor` 只传输 metadata/payload resource;`submitInitialRuntimeFieldWindow(T0,T1)` 是薄 bridge,后续继续使用既有 `updateRuntimeFieldWindow()`。Timeline 使用 Snapshot `validTime`,fixture 的旧 null timestamp 保留历史 fallback。未修改 Playback ownership。
|
||||||
|
|
||||||
|
production-like Android 入口使用四个 local metadata asset 和现有 canonical binary asset;旧 fixture validation 保留。未将解析、normalization、network 或 Timeline 状态放入 Kotlin/Renderer。
|
||||||
|
|
||||||
|
## 测试与构建
|
||||||
|
|
||||||
|
- `cmake --build build-linux-opengl --target mbgl-test-runner -j3`:PASS。
|
||||||
|
- `./build-linux-opengl/mbgl-test-runner --gtest_filter='Source.LocalProductionFeed*:Source.ParticleFieldDataAdapter*:Source.RasterArray*'`:14/14 PASS。
|
||||||
|
- 覆盖:metadata→descriptor→Snapshot、identity/generation/validTime、严格时间递增、initial T0/T1 READY、PLAY、正式非法 generation 拒绝,以及既有 Window/Boundary/GAP/INVALID/Recover/Duplicate 回归。
|
||||||
|
- `git diff --check`:PASS。
|
||||||
|
|
||||||
|
## Android / Jest 边界
|
||||||
|
|
||||||
|
- Android Gradle:未执行。
|
||||||
|
- APK build/copy/install:未执行。
|
||||||
|
- ADB:未执行。
|
||||||
|
- App launch / Android 实机或虚拟机测试:未执行。
|
||||||
|
- Jest:未执行、未修改。
|
||||||
|
- MIX UI 文本滞后:未扩大处理,继续作为 `NON-BLOCKING observability`。
|
||||||
|
- Rotation/Viewport:未修改,继续作为技术债。
|
||||||
|
|
||||||
|
## Unrelated 文件保护
|
||||||
|
|
||||||
|
开始时已有的 `STEP-046-e48fa087-review.diff`、用户任务书、Android log、临时文件及其他 untracked 文件未修改、未 stage、未提交。
|
||||||
|
|
||||||
|
## Git 收尾
|
||||||
|
|
||||||
|
- commit hash:待本次提交完成后回填。
|
||||||
|
- commit message:`feat: add production-like particle field feed`
|
||||||
|
- push result:待执行。
|
||||||
|
- remote branch:`origin/particle/main`
|
||||||
|
- Final HEAD:待 push 后回填。
|
||||||
|
|
||||||
|
## 当前状态与 Android 验收
|
||||||
|
|
||||||
|
当前状态:`IMPLEMENTED / WAITING_ANDROID_VALIDATION`。用户应在一次 Android build/install 后验证 production feed load、Timeline READY/PLAY、MIX、至少两个 Window Boundary、reuse/upload 计数、`simulationRebuild=false`、`trailCleared=false`、Pause/Resume、Duplicate NOOP、非法 local metadata fail-closed、Renderer 持续且不黑屏。Android 验收前不得写 CLOSED/PASS。
|
||||||
@@ -13,6 +13,8 @@ struct ParticleFieldDescriptor {
|
|||||||
std::string fieldID;
|
std::string fieldID;
|
||||||
std::string fieldType;
|
std::string fieldType;
|
||||||
std::string displayDomain;
|
std::string displayDomain;
|
||||||
|
// Assigned by the producer before decoding. The adapter never invents a revision.
|
||||||
|
uint64_t generation = 0;
|
||||||
uint64_t validTime = 0;
|
uint64_t validTime = 0;
|
||||||
uint32_t width = 0;
|
uint32_t width = 0;
|
||||||
uint32_t height = 0;
|
uint32_t height = 0;
|
||||||
|
|||||||
17
include/mbgl/particle/field/local_particle_field_feed.hpp
Normal file
17
include/mbgl/particle/field/local_particle_field_feed.hpp
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <mbgl/particle/field/field_data_adapter.hpp>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace mbgl::particle {
|
||||||
|
|
||||||
|
// Decodes the small provider-neutral local feed contract. File/asset loading
|
||||||
|
// remains outside this class; callers provide metadata text and payload bytes.
|
||||||
|
class LocalParticleFieldFeed final {
|
||||||
|
public:
|
||||||
|
static ParticleFieldAdapterResult load(const std::string& metadataText, const std::vector<char>& payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace mbgl::particle
|
||||||
@@ -26,6 +26,13 @@ struct RasterArrayRuntimeFieldDescriptor {
|
|||||||
uint64_t timestamp = 0;
|
uint64_t timestamp = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A provider-neutral local feed pair. The source only transports the two
|
||||||
|
// resources; LocalParticleFieldFeed owns metadata decoding and validation.
|
||||||
|
struct RasterArrayLocalFieldDescriptor {
|
||||||
|
std::string metadataURL;
|
||||||
|
std::string fieldURL;
|
||||||
|
};
|
||||||
|
|
||||||
enum class RasterArrayPlaybackState : uint8_t {
|
enum class RasterArrayPlaybackState : uint8_t {
|
||||||
Ready,
|
Ready,
|
||||||
Playing,
|
Playing,
|
||||||
@@ -105,6 +112,7 @@ public:
|
|||||||
std::string nextMetadataURL = {},
|
std::string nextMetadataURL = {},
|
||||||
std::string nextFieldURL = {},
|
std::string nextFieldURL = {},
|
||||||
std::vector<RasterArrayRuntimeFieldDescriptor> runtimeFields = {});
|
std::vector<RasterArrayRuntimeFieldDescriptor> runtimeFields = {});
|
||||||
|
RasterArraySource(std::string id, std::vector<RasterArrayLocalFieldDescriptor> localFields);
|
||||||
~RasterArraySource() override;
|
~RasterArraySource() override;
|
||||||
|
|
||||||
class Impl;
|
class Impl;
|
||||||
@@ -114,6 +122,9 @@ public:
|
|||||||
// The caller transfers an immutable, already decoded Field snapshot; the
|
// The caller transfers an immutable, already decoded Field snapshot; the
|
||||||
// renderer observes the resulting Source change and owns GPU reuse/upload.
|
// renderer observes the resulting Source change and owns GPU reuse/upload.
|
||||||
RasterArrayRuntimeFieldUpdateResult updateRuntimeFieldWindow(particle::ParticleFieldSnapshot newT1);
|
RasterArrayRuntimeFieldUpdateResult updateRuntimeFieldWindow(particle::ParticleFieldSnapshot newT1);
|
||||||
|
// Thin Core-owned submit point for a producer-supplied initial window.
|
||||||
|
RasterArrayRuntimeFieldUpdateResult submitInitialRuntimeFieldWindow(particle::ParticleFieldSnapshot t0,
|
||||||
|
particle::ParticleFieldSnapshot t1);
|
||||||
// Validation convenience: advance using the next Source-owned preloaded
|
// Validation convenience: advance using the next Source-owned preloaded
|
||||||
// candidate. Android supplies no T0/T1 state and no GPU resource here.
|
// candidate. Android supplies no T0/T1 state and no GPU resource here.
|
||||||
RasterArrayRuntimeFieldUpdateResult advanceRuntimeFieldWindow();
|
RasterArrayRuntimeFieldUpdateResult advanceRuntimeFieldWindow();
|
||||||
@@ -137,9 +148,18 @@ private:
|
|||||||
std::optional<std::vector<char>> fieldBytes;
|
std::optional<std::vector<char>> fieldBytes;
|
||||||
std::optional<particle::ParticleFieldSnapshot> snapshot;
|
std::optional<particle::ParticleFieldSnapshot> snapshot;
|
||||||
};
|
};
|
||||||
|
struct LocalFieldCandidate {
|
||||||
|
RasterArrayLocalFieldDescriptor descriptor;
|
||||||
|
std::unique_ptr<AsyncRequest> metadataRequest;
|
||||||
|
std::unique_ptr<AsyncRequest> fieldRequest;
|
||||||
|
std::optional<std::string> metadata;
|
||||||
|
std::optional<std::vector<char>> fieldBytes;
|
||||||
|
std::optional<particle::ParticleFieldSnapshot> snapshot;
|
||||||
|
};
|
||||||
|
|
||||||
void tryLoad();
|
void tryLoad();
|
||||||
void tryLoadRuntimeField(size_t);
|
void tryLoadRuntimeField(size_t);
|
||||||
|
void tryLoadLocalField(size_t);
|
||||||
void tryInitializeTimeline();
|
void tryInitializeTimeline();
|
||||||
RasterArrayRuntimeFieldUpdateResult rejectRuntimeFieldUpdate(RasterArrayRuntimeFieldUpdateResult,
|
RasterArrayRuntimeFieldUpdateResult rejectRuntimeFieldUpdate(RasterArrayRuntimeFieldUpdateResult,
|
||||||
const std::string& reason) const;
|
const std::string& reason) const;
|
||||||
@@ -153,6 +173,7 @@ private:
|
|||||||
std::optional<std::string> nextMetadata;
|
std::optional<std::string> nextMetadata;
|
||||||
std::optional<std::vector<char>> nextFieldBytes;
|
std::optional<std::vector<char>> nextFieldBytes;
|
||||||
std::vector<RuntimeFieldCandidate> runtimeFields;
|
std::vector<RuntimeFieldCandidate> runtimeFields;
|
||||||
|
std::vector<LocalFieldCandidate> localFields;
|
||||||
size_t nextRuntimeField = 0;
|
size_t nextRuntimeField = 0;
|
||||||
uint64_t runtimeUpdateSequence = 0;
|
uint64_t runtimeUpdateSequence = 0;
|
||||||
std::shared_ptr<RasterArrayTimelineRuntime> timeline;
|
std::shared_ptr<RasterArrayTimelineRuntime> timeline;
|
||||||
|
|||||||
13
particle-production/field-00.json
Normal file
13
particle-production/field-00.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"schema": "maplibre-particle-field/v1",
|
||||||
|
"fieldId": "production-wind-t00",
|
||||||
|
"fieldType": "WIND",
|
||||||
|
"displayDomain": "GLOBAL",
|
||||||
|
"generation": 1,
|
||||||
|
"validTime": 202609040000,
|
||||||
|
"dataVersion": 1,
|
||||||
|
"bounds": {"west": -180.0, "south": -85.0, "east": 180.0, "north": 85.0},
|
||||||
|
"grid": {"width": 16, "height": 16},
|
||||||
|
"vector": {"layout": "interleaved-uv", "encoding": "float32-le", "noData": -9999.0},
|
||||||
|
"payload": {"byteLength": 2048}
|
||||||
|
}
|
||||||
13
particle-production/field-01.json
Normal file
13
particle-production/field-01.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"schema": "maplibre-particle-field/v1",
|
||||||
|
"fieldId": "production-wind-t01",
|
||||||
|
"fieldType": "WIND",
|
||||||
|
"displayDomain": "GLOBAL",
|
||||||
|
"generation": 2,
|
||||||
|
"validTime": 202609040100,
|
||||||
|
"dataVersion": 2,
|
||||||
|
"bounds": {"west": -180.0, "south": -85.0, "east": 180.0, "north": 85.0},
|
||||||
|
"grid": {"width": 16, "height": 16},
|
||||||
|
"vector": {"layout": "interleaved-uv", "encoding": "float32-le", "noData": -9999.0},
|
||||||
|
"payload": {"byteLength": 2048}
|
||||||
|
}
|
||||||
13
particle-production/field-02.json
Normal file
13
particle-production/field-02.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"schema": "maplibre-particle-field/v1",
|
||||||
|
"fieldId": "production-wind-t02",
|
||||||
|
"fieldType": "WIND",
|
||||||
|
"displayDomain": "GLOBAL",
|
||||||
|
"generation": 3,
|
||||||
|
"validTime": 202609040200,
|
||||||
|
"dataVersion": 3,
|
||||||
|
"bounds": {"west": -180.0, "south": -85.0, "east": 180.0, "north": 85.0},
|
||||||
|
"grid": {"width": 16, "height": 16},
|
||||||
|
"vector": {"layout": "interleaved-uv", "encoding": "float32-le", "noData": -9999.0},
|
||||||
|
"payload": {"byteLength": 2048}
|
||||||
|
}
|
||||||
13
particle-production/field-03.json
Normal file
13
particle-production/field-03.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"schema": "maplibre-particle-field/v1",
|
||||||
|
"fieldId": "production-wind-t03",
|
||||||
|
"fieldType": "WIND",
|
||||||
|
"displayDomain": "GLOBAL",
|
||||||
|
"generation": 4,
|
||||||
|
"validTime": 202609040300,
|
||||||
|
"dataVersion": 4,
|
||||||
|
"bounds": {"west": -180.0, "south": -85.0, "east": 180.0, "north": 85.0},
|
||||||
|
"grid": {"width": 16, "height": 16},
|
||||||
|
"vector": {"layout": "interleaved-uv", "encoding": "float32-le", "noData": -9999.0},
|
||||||
|
"payload": {"byteLength": 2048}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"schema": "maplibre-particle-field/v1", "fieldId": "production-wind-t00", "fieldType": "WIND", "displayDomain": "GLOBAL", "generation": 1, "validTime": 202609040000, "dataVersion": 1,
|
||||||
|
"bounds": {"west": -180.0, "south": -85.0, "east": 180.0, "north": 85.0}, "grid": {"width": 16, "height": 16}, "vector": {"layout": "interleaved-uv", "encoding": "float32-le", "noData": -9999.0}, "payload": {"byteLength": 2048}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"schema": "maplibre-particle-field/v1", "fieldId": "production-wind-t01", "fieldType": "WIND", "displayDomain": "GLOBAL", "generation": 2, "validTime": 202609040100, "dataVersion": 2,
|
||||||
|
"bounds": {"west": -180.0, "south": -85.0, "east": 180.0, "north": 85.0}, "grid": {"width": 16, "height": 16}, "vector": {"layout": "interleaved-uv", "encoding": "float32-le", "noData": -9999.0}, "payload": {"byteLength": 2048}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"schema": "maplibre-particle-field/v1", "fieldId": "production-wind-t02", "fieldType": "WIND", "displayDomain": "GLOBAL", "generation": 3, "validTime": 202609040200, "dataVersion": 3,
|
||||||
|
"bounds": {"west": -180.0, "south": -85.0, "east": 180.0, "north": 85.0}, "grid": {"width": 16, "height": 16}, "vector": {"layout": "interleaved-uv", "encoding": "float32-le", "noData": -9999.0}, "payload": {"byteLength": 2048}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"schema": "maplibre-particle-field/v1", "fieldId": "production-wind-t03", "fieldType": "WIND", "displayDomain": "GLOBAL", "generation": 4, "validTime": 202609040300, "dataVersion": 4,
|
||||||
|
"bounds": {"west": -180.0, "south": -85.0, "east": 180.0, "north": 85.0}, "grid": {"width": 16, "height": 16}, "vector": {"layout": "interleaved-uv", "encoding": "float32-le", "noData": -9999.0}, "payload": {"byteLength": 2048}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
|
|||||||
private var trailFade = DEFAULT_TRAIL_FADE
|
private var trailFade = DEFAULT_TRAIL_FADE
|
||||||
private var speedColorEnabled = true
|
private var speedColorEnabled = true
|
||||||
private var timeWindowMode = false
|
private var timeWindowMode = false
|
||||||
|
private var productionFeedMode = false
|
||||||
private var styleLoadEpoch = 0L
|
private var styleLoadEpoch = 0L
|
||||||
private var styleReady = false
|
private var styleReady = false
|
||||||
// Display mirror only; Core remains the playback state authority.
|
// Display mirror only; Core remains the playback state authority.
|
||||||
@@ -97,21 +98,25 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
findViewById<Button>(R.id.validationUniformEast).setOnClickListener {
|
findViewById<Button>(R.id.validationUniformEast).setOnClickListener {
|
||||||
timeWindowMode = false
|
timeWindowMode = false
|
||||||
|
productionFeedMode = false
|
||||||
fixtureMode = FIXTURE_UNIFORM_EAST
|
fixtureMode = FIXTURE_UNIFORM_EAST
|
||||||
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "UNIFORM_EAST")
|
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "UNIFORM_EAST")
|
||||||
}
|
}
|
||||||
findViewById<Button>(R.id.validationUniformNorth).setOnClickListener {
|
findViewById<Button>(R.id.validationUniformNorth).setOnClickListener {
|
||||||
timeWindowMode = false
|
timeWindowMode = false
|
||||||
|
productionFeedMode = false
|
||||||
fixtureMode = FIXTURE_UNIFORM_NORTH
|
fixtureMode = FIXTURE_UNIFORM_NORTH
|
||||||
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "UNIFORM_NORTH")
|
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "UNIFORM_NORTH")
|
||||||
}
|
}
|
||||||
findViewById<Button>(R.id.validationVortex).setOnClickListener {
|
findViewById<Button>(R.id.validationVortex).setOnClickListener {
|
||||||
timeWindowMode = false
|
timeWindowMode = false
|
||||||
|
productionFeedMode = false
|
||||||
fixtureMode = FIXTURE_VORTEX
|
fixtureMode = FIXTURE_VORTEX
|
||||||
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "VORTEX")
|
if (this::maplibreMap.isInitialized) loadValidationStyle(active, enabled, "VORTEX")
|
||||||
}
|
}
|
||||||
findViewById<Button>(R.id.validationTimeWindow).setOnClickListener {
|
findViewById<Button>(R.id.validationTimeWindow).setOnClickListener {
|
||||||
timeWindowMode = true
|
timeWindowMode = true
|
||||||
|
productionFeedMode = true
|
||||||
fixtureMode = FIXTURE_UNIFORM_EAST
|
fixtureMode = FIXTURE_UNIFORM_EAST
|
||||||
timeMix = 0.0f
|
timeMix = 0.0f
|
||||||
timelineState = "NOT READY"
|
timelineState = "NOT READY"
|
||||||
@@ -381,7 +386,7 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
|
|||||||
} else {
|
} else {
|
||||||
"EAST"
|
"EAST"
|
||||||
}
|
}
|
||||||
val playback = if (timeWindowMode) " · TL $timelineState · $timelineSpeed" else ""
|
val playback = if (timeWindowMode) " · ${if (productionFeedMode) "PRODUCTION" else "FIXTURE"} · TL $timelineState · $timelineSpeed" else ""
|
||||||
return "$mask · $runtime · $fields$playback · MIX ${(timeMix * 100).toInt()}% · $particleCount · W ${"%.1f".format(lineWidth)} L ${"%.3f".format(particleLength)} O ${(particleOpacity * 100).toInt()} T ${(trailOpacity * 100).toInt()} F ${(trailFade * 100).toInt()} C ${if (speedColorEnabled) "ON" else "OFF"}"
|
return "$mask · $runtime · $fields$playback · MIX ${(timeMix * 100).toInt()}% · $particleCount · W ${"%.1f".format(lineWidth)} L ${"%.3f".format(particleLength)} O ${(particleOpacity * 100).toInt()} T ${(trailOpacity * 100).toInt()} F ${(trailFade * 100).toInt()} C ${if (speedColorEnabled) "ON" else "OFF"}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,30 +407,33 @@ class ParticleCoreLayerValidationActivity : AppCompatActivity() {
|
|||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
return """
|
val source = if (productionFeedMode) {
|
||||||
{
|
"""{"type":"raster-array","production-fields":[
|
||||||
"version": 8,
|
{"metadata":"asset://integration/particle-production/field-00.json","field":"asset://integration/particle-fixtures/uniform-east/field.f32"},
|
||||||
"name": "Particle Core Layer Validation",
|
{"metadata":"asset://integration/particle-production/field-01.json","field":"asset://integration/particle-fixtures/uniform-north/field.f32"},
|
||||||
"sources": {
|
{"metadata":"asset://integration/particle-production/field-02.json","field":"asset://integration/particle-fixtures/vortex/field.f32"},
|
||||||
"particle-fixture": {
|
{"metadata":"asset://integration/particle-production/field-03.json","field":"asset://integration/particle-fixtures/uniform-east/field.f32"}
|
||||||
"type": "raster-array",
|
]}"""
|
||||||
"fixture": "$fixtureId",
|
} else {
|
||||||
|
"""{"type":"raster-array","fixture":"$fixtureId",
|
||||||
"metadata":"asset://integration/particle-fixtures/$fixtureId/metadata.json",
|
"metadata":"asset://integration/particle-fixtures/$fixtureId/metadata.json",
|
||||||
"field":"asset://integration/particle-fixtures/$fixtureId/field.f32",
|
"field":"asset://integration/particle-fixtures/$fixtureId/field.f32",
|
||||||
"next-fixture":"$nextFixtureId",
|
"next-fixture":"$nextFixtureId",
|
||||||
"next-metadata":"asset://integration/particle-fixtures/$nextFixtureId/metadata.json",
|
"next-metadata":"asset://integration/particle-fixtures/$nextFixtureId/metadata.json",
|
||||||
"next-field":"asset://integration/particle-fixtures/$nextFixtureId/field.f32",
|
"next-field":"asset://integration/particle-fixtures/$nextFixtureId/field.f32",
|
||||||
"runtime-fields":[
|
"runtime-fields":[
|
||||||
{"fixture":"vortex","metadata":"asset://integration/particle-fixtures/vortex/metadata.json",
|
{"fixture":"vortex","metadata":"asset://integration/particle-fixtures/vortex/metadata.json","field":"asset://integration/particle-fixtures/vortex/field.f32"},
|
||||||
"field":"asset://integration/particle-fixtures/vortex/field.f32"},
|
{"fixture":"nodata","metadata":"asset://integration/particle-fixtures/nodata/metadata.json","field":"asset://integration/particle-fixtures/nodata/field.f32"},
|
||||||
{"fixture":"nodata","metadata":"asset://integration/particle-fixtures/nodata/metadata.json",
|
{"fixture":"uniform-east","metadata":"asset://integration/particle-fixtures/uniform-east/metadata.json","field":"asset://integration/particle-fixtures/uniform-east/field.f32"},
|
||||||
"field":"asset://integration/particle-fixtures/nodata/field.f32"},
|
{"fixture":"uniform-north","metadata":"asset://integration/particle-fixtures/uniform-north/metadata.json","field":"asset://integration/particle-fixtures/uniform-north/field.f32"}
|
||||||
{"fixture":"uniform-east","metadata":"asset://integration/particle-fixtures/uniform-east/metadata.json",
|
]}"""
|
||||||
"field":"asset://integration/particle-fixtures/uniform-east/field.f32"},
|
}
|
||||||
{"fixture":"uniform-north","metadata":"asset://integration/particle-fixtures/uniform-north/metadata.json",
|
return """
|
||||||
"field":"asset://integration/particle-fixtures/uniform-north/field.f32"}
|
{
|
||||||
]
|
"version": 8,
|
||||||
},
|
"name": "Particle Core Layer Validation",
|
||||||
|
"sources": {
|
||||||
|
"particle-fixture": $source,
|
||||||
"validation-point": {
|
"validation-point": {
|
||||||
"type": "geojson",
|
"type": "geojson",
|
||||||
"data": {"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]}}
|
"data": {"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]}}
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ bool ParticleFieldSnapshot::isTimeInterpolationCompatibleWith(const ParticleFiel
|
|||||||
ParticleFieldAdapterResult ParticleFieldDataAdapter::decode(const ParticleFieldDescriptor& descriptor,
|
ParticleFieldAdapterResult ParticleFieldDataAdapter::decode(const ParticleFieldDescriptor& descriptor,
|
||||||
const std::vector<char>& bytes,
|
const std::vector<char>& bytes,
|
||||||
const uint64_t generation) {
|
const uint64_t generation) {
|
||||||
if (descriptor.fieldID.empty() || descriptor.fieldType.empty() || descriptor.displayDomain.empty() || generation == 0) {
|
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");
|
return reject(ParticleFieldAdapterError::InvalidTimestampOrIdentity, "field identity or generation is invalid");
|
||||||
}
|
}
|
||||||
if (descriptor.width == 0 || descriptor.height == 0 || !(descriptor.west < descriptor.east) ||
|
if (descriptor.width == 0 || descriptor.height == 0 || !(descriptor.west < descriptor.east) ||
|
||||||
@@ -73,7 +74,7 @@ ParticleFieldAdapterResult ParticleFieldDataAdapter::decode(const ParticleFieldD
|
|||||||
field.north = descriptor.north;
|
field.north = descriptor.north;
|
||||||
field.noData = descriptor.noData;
|
field.noData = descriptor.noData;
|
||||||
field.dataVersion = descriptor.dataVersion;
|
field.dataVersion = descriptor.dataVersion;
|
||||||
field.generation = generation;
|
field.generation = descriptor.generation;
|
||||||
field.fieldSha256 = descriptor.fieldSha256;
|
field.fieldSha256 = descriptor.fieldSha256;
|
||||||
field.velocity.resize(static_cast<size_t>(cells) * 2);
|
field.velocity.resize(static_cast<size_t>(cells) * 2);
|
||||||
std::memcpy(field.velocity.data(), bytes.data(), bytes.size());
|
std::memcpy(field.velocity.data(), bytes.data(), bytes.size());
|
||||||
|
|||||||
74
src/mbgl/particle/field/local_particle_field_feed.cpp
Normal file
74
src/mbgl/particle/field/local_particle_field_feed.cpp
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#include <mbgl/particle/field/local_particle_field_feed.hpp>
|
||||||
|
|
||||||
|
#include <rapidjson/document.h>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace mbgl::particle {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
ParticleFieldAdapterResult reject(const 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 hasNumber(const rapidjson::Value& object, const char* name) {
|
||||||
|
return object.HasMember(name) && object[name].IsNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mbgl::particle
|
||||||
@@ -157,6 +157,7 @@ ParticleFieldSnapshot OfflineFixtureParticleAdapter::load(const std::string& fix
|
|||||||
descriptor.fieldID = fixtureID;
|
descriptor.fieldID = fixtureID;
|
||||||
descriptor.fieldType = member(metadata, "fieldType").GetString();
|
descriptor.fieldType = member(metadata, "fieldType").GetString();
|
||||||
descriptor.displayDomain = member(metadata, "displayDomain").GetString();
|
descriptor.displayDomain = member(metadata, "displayDomain").GetString();
|
||||||
|
descriptor.generation = generation;
|
||||||
descriptor.width = grid["width"].GetUint();
|
descriptor.width = grid["width"].GetUint();
|
||||||
descriptor.height = grid["height"].GetUint();
|
descriptor.height = grid["height"].GetUint();
|
||||||
descriptor.west = bounds["west"].GetDouble();
|
descriptor.west = bounds["west"].GetDouble();
|
||||||
|
|||||||
@@ -207,6 +207,30 @@ std::optional<std::unique_ptr<Source>> convertImageSource(const std::string& id,
|
|||||||
std::optional<std::unique_ptr<Source>> convertRasterArraySource(const std::string& id,
|
std::optional<std::unique_ptr<Source>> convertRasterArraySource(const std::string& id,
|
||||||
const Convertible& value,
|
const Convertible& value,
|
||||||
Error& error) {
|
Error& error) {
|
||||||
|
if (const auto localFieldsValue = objectMember(value, "production-fields")) {
|
||||||
|
if (!isArray(*localFieldsValue) || arrayLength(*localFieldsValue) < 2) {
|
||||||
|
error.message = "raster-array production-fields must contain at least two entries";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
std::vector<RasterArrayLocalFieldDescriptor> localFields;
|
||||||
|
for (size_t index = 0; index < arrayLength(*localFieldsValue); ++index) {
|
||||||
|
const auto entry = arrayMember(*localFieldsValue, index);
|
||||||
|
const auto metadata = objectMember(entry, "metadata");
|
||||||
|
const auto field = objectMember(entry, "field");
|
||||||
|
if (!metadata || !field) {
|
||||||
|
error.message = "raster-array production field requires metadata and field";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const auto metadataURL = toString(*metadata);
|
||||||
|
const auto fieldURL = toString(*field);
|
||||||
|
if (!metadataURL || !fieldURL) {
|
||||||
|
error.message = "raster-array production field inputs must be strings";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
localFields.push_back({*metadataURL, *fieldURL});
|
||||||
|
}
|
||||||
|
return {std::make_unique<RasterArraySource>(id, std::move(localFields))};
|
||||||
|
}
|
||||||
const auto fixture = objectMember(value, "fixture");
|
const auto fixture = objectMember(value, "fixture");
|
||||||
const auto metadata = objectMember(value, "metadata");
|
const auto metadata = objectMember(value, "metadata");
|
||||||
const auto field = objectMember(value, "field");
|
const auto field = objectMember(value, "field");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include <mbgl/style/sources/raster_array_source.hpp>
|
#include <mbgl/style/sources/raster_array_source.hpp>
|
||||||
|
|
||||||
#include <mbgl/particle/field/offline_fixture_particle_adapter.hpp>
|
#include <mbgl/particle/field/offline_fixture_particle_adapter.hpp>
|
||||||
|
#include <mbgl/particle/field/local_particle_field_feed.hpp>
|
||||||
#include <mbgl/storage/file_source.hpp>
|
#include <mbgl/storage/file_source.hpp>
|
||||||
#include <mbgl/storage/resource.hpp>
|
#include <mbgl/storage/resource.hpp>
|
||||||
#include <mbgl/style/source_observer.hpp>
|
#include <mbgl/style/source_observer.hpp>
|
||||||
@@ -203,6 +204,9 @@ RasterArraySource::RasterArraySource(std::string id,
|
|||||||
std::move(nextFieldURL),
|
std::move(nextFieldURL),
|
||||||
std::move(runtimeFields))) {}
|
std::move(runtimeFields))) {}
|
||||||
|
|
||||||
|
RasterArraySource::RasterArraySource(std::string id, std::vector<RasterArrayLocalFieldDescriptor> localFields)
|
||||||
|
: Source(makeMutable<Impl>(std::move(id), std::move(localFields))) {}
|
||||||
|
|
||||||
RasterArraySource::~RasterArraySource() {
|
RasterArraySource::~RasterArraySource() {
|
||||||
weakFactory.invalidateWeakPtrs();
|
weakFactory.invalidateWeakPtrs();
|
||||||
}
|
}
|
||||||
@@ -213,6 +217,39 @@ const RasterArraySource::Impl& RasterArraySource::impl() const {
|
|||||||
|
|
||||||
void RasterArraySource::loadDescription(FileSource& fileSource) {
|
void RasterArraySource::loadDescription(FileSource& fileSource) {
|
||||||
if (metadataRequest || fieldRequest) return;
|
if (metadataRequest || fieldRequest) return;
|
||||||
|
if (!impl().localFields.empty()) {
|
||||||
|
localFields.reserve(impl().localFields.size());
|
||||||
|
for (const auto& descriptor : impl().localFields) {
|
||||||
|
LocalFieldCandidate candidate;
|
||||||
|
candidate.descriptor = descriptor;
|
||||||
|
localFields.push_back(std::move(candidate));
|
||||||
|
const auto index = localFields.size() - 1;
|
||||||
|
auto& local = localFields.back();
|
||||||
|
local.metadataRequest = fileSource.request(Resource::source(local.descriptor.metadataURL),
|
||||||
|
[this, self = makeWeakPtr(), index](const Response& response) {
|
||||||
|
if (auto guard = self.lock(); self) {
|
||||||
|
if (response.error || response.noContent || !response.data) {
|
||||||
|
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error("particle local metadata request failed")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localFields[index].metadata = *response.data;
|
||||||
|
tryLoadLocalField(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
local.fieldRequest = fileSource.request(Resource::source(local.descriptor.fieldURL),
|
||||||
|
[this, self = makeWeakPtr(), index](const Response& response) {
|
||||||
|
if (auto guard = self.lock(); self) {
|
||||||
|
if (response.error || response.noContent || !response.data) {
|
||||||
|
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error("particle local field request failed")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localFields[index].fieldBytes = std::vector<char>(response.data->begin(), response.data->end());
|
||||||
|
tryLoadLocalField(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
metadataRequest = fileSource.request(
|
metadataRequest = fileSource.request(
|
||||||
Resource::source(impl().metadataURL), [this, self = makeWeakPtr()](const Response& response) {
|
Resource::source(impl().metadataURL), [this, self = makeWeakPtr()](const Response& response) {
|
||||||
if (auto guard = self.lock(); self) {
|
if (auto guard = self.lock(); self) {
|
||||||
@@ -371,17 +408,68 @@ void RasterArraySource::tryLoadRuntimeField(const size_t index) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RasterArraySource::tryLoadLocalField(const size_t index) {
|
||||||
|
auto& candidate = localFields.at(index);
|
||||||
|
if (candidate.snapshot || !candidate.metadata || !candidate.fieldBytes) return;
|
||||||
|
const auto result = particle::LocalParticleFieldFeed::load(*candidate.metadata, *candidate.fieldBytes);
|
||||||
|
if (!result) {
|
||||||
|
const auto fieldID = candidate.metadata->find("fieldId") == std::string::npos ? "" : "metadata";
|
||||||
|
Log::Warning(Event::Render, "ParticleField: event=ADAPTER_REJECTED sourceId=" + getID() +
|
||||||
|
" errorCode=" + particle::particleFieldAdapterErrorName(result.error) +
|
||||||
|
" fieldId=" + fieldID + " payloadBytes=" + std::to_string(candidate.fieldBytes->size()) +
|
||||||
|
" error=" + result.message);
|
||||||
|
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error(result.message)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
candidate.snapshot = std::move(*result.snapshot);
|
||||||
|
Log::Info(Event::Render, "ParticleField: event=PRODUCTION_FEED_LOAD fieldId=" + candidate.snapshot->fieldID +
|
||||||
|
" generation=" + std::to_string(candidate.snapshot->generation) +
|
||||||
|
" validTime=" + std::to_string(candidate.snapshot->validTime) +
|
||||||
|
" payloadBytes=" + std::to_string(candidate.fieldBytes->size()));
|
||||||
|
tryInitializeTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
void RasterArraySource::tryInitializeTimeline() {
|
void RasterArraySource::tryInitializeTimeline() {
|
||||||
if (timeline || !loaded || !impl().field || !impl().nextField || runtimeFields.empty()) return;
|
if (timeline) return;
|
||||||
|
if (!impl().localFields.empty()) {
|
||||||
|
if (localFields.size() != impl().localFields.size() || localFields.size() < 2) return;
|
||||||
|
for (const auto& candidate : localFields) if (!candidate.snapshot) return;
|
||||||
|
std::vector<RasterArrayTimelineRuntime::Entry> entries;
|
||||||
|
entries.reserve(localFields.size());
|
||||||
|
for (const auto& candidate : localFields) {
|
||||||
|
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(*candidate.snapshot), candidate.snapshot->validTime});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (submitInitialRuntimeFieldWindow(*localFields[0].snapshot, *localFields[1].snapshot) !=
|
||||||
|
RasterArrayRuntimeFieldUpdateResult::Applied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timeline = std::make_shared<RasterArrayTimelineRuntime>(std::move(entries));
|
||||||
|
auto updated = makeMutable<Impl>(impl());
|
||||||
|
updated->timeline = timeline;
|
||||||
|
baseImpl = std::move(updated);
|
||||||
|
loaded = true;
|
||||||
|
Log::Info(Event::Render, "ParticleTimeline: event=READY snapshots=" + std::to_string(localFields.size()));
|
||||||
|
observer->onSourceLoaded(*this);
|
||||||
|
observer->onSourceChanged(*this);
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error(error.what())));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!loaded || !impl().field || !impl().nextField || runtimeFields.empty()) return;
|
||||||
std::vector<RasterArrayTimelineRuntime::Entry> entries;
|
std::vector<RasterArrayTimelineRuntime::Entry> entries;
|
||||||
entries.reserve(runtimeFields.size() + 2);
|
entries.reserve(runtimeFields.size() + 2);
|
||||||
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(*impl().field), 0});
|
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(*impl().field),
|
||||||
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(*impl().nextField), 40000});
|
impl().field->validTime ? impl().field->validTime : 0});
|
||||||
|
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(*impl().nextField),
|
||||||
|
impl().nextField->validTime ? impl().nextField->validTime : 40000});
|
||||||
for (size_t index = 0; index < runtimeFields.size(); ++index) {
|
for (size_t index = 0; index < runtimeFields.size(); ++index) {
|
||||||
if (!runtimeFields[index].snapshot) return;
|
if (!runtimeFields[index].snapshot) return;
|
||||||
auto field = *runtimeFields[index].snapshot;
|
auto field = *runtimeFields[index].snapshot;
|
||||||
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(std::move(field)),
|
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(std::move(field)),
|
||||||
80000 + static_cast<uint64_t>(index) * 40000});
|
runtimeFields[index].descriptor.timestamp ? runtimeFields[index].descriptor.timestamp
|
||||||
|
: 80000 + static_cast<uint64_t>(index) * 40000});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
timeline = std::make_shared<RasterArrayTimelineRuntime>(std::move(entries));
|
timeline = std::make_shared<RasterArrayTimelineRuntime>(std::move(entries));
|
||||||
@@ -403,6 +491,19 @@ void RasterArraySource::tryInitializeTimeline() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RasterArrayRuntimeFieldUpdateResult RasterArraySource::submitInitialRuntimeFieldWindow(
|
||||||
|
particle::ParticleFieldSnapshot t0, particle::ParticleFieldSnapshot t1) {
|
||||||
|
if (loaded || !t0.isValid() || !t1.isValid() || t0.generation == 0 || t1.generation == 0 ||
|
||||||
|
t0.validTime >= t1.validTime || !t0.isTimeInterpolationCompatibleWith(t1)) {
|
||||||
|
return rejectRuntimeFieldUpdate(RasterArrayRuntimeFieldUpdateResult::RejectedInvalidSnapshot,
|
||||||
|
"INVALID_INITIAL_WINDOW");
|
||||||
|
}
|
||||||
|
baseImpl = makeMutable<Impl>(impl(), std::move(t0), std::move(t1));
|
||||||
|
loaded = true;
|
||||||
|
Log::Info(Event::Render, "ParticleField: event=SNAPSHOT_SUBMIT kind=INITIAL_T0_T1");
|
||||||
|
return RasterArrayRuntimeFieldUpdateResult::Applied;
|
||||||
|
}
|
||||||
|
|
||||||
RasterArrayRuntimeFieldUpdateResult RasterArraySource::rejectRuntimeFieldUpdate(
|
RasterArrayRuntimeFieldUpdateResult RasterArraySource::rejectRuntimeFieldUpdate(
|
||||||
const RasterArrayRuntimeFieldUpdateResult result, const std::string& reason) const {
|
const RasterArrayRuntimeFieldUpdateResult result, const std::string& reason) const {
|
||||||
Log::Warning(Event::Render,
|
Log::Warning(Event::Render,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public:
|
|||||||
nextMetadataURL(std::move(nextMetadataURL_)),
|
nextMetadataURL(std::move(nextMetadataURL_)),
|
||||||
nextFieldURL(std::move(nextFieldURL_)),
|
nextFieldURL(std::move(nextFieldURL_)),
|
||||||
runtimeFields(std::move(runtimeFields_)) {}
|
runtimeFields(std::move(runtimeFields_)) {}
|
||||||
|
Impl(std::string id, std::vector<RasterArrayLocalFieldDescriptor> localFields_)
|
||||||
|
: Source::Impl(SourceType::RasterArray, std::move(id)), localFields(std::move(localFields_)) {}
|
||||||
Impl(const Impl&) = default;
|
Impl(const Impl&) = default;
|
||||||
Impl(const Impl& other, particle::ParticleFieldSnapshot field_)
|
Impl(const Impl& other, particle::ParticleFieldSnapshot field_)
|
||||||
: Source::Impl(other),
|
: Source::Impl(other),
|
||||||
@@ -34,6 +36,7 @@ public:
|
|||||||
nextMetadataURL(other.nextMetadataURL),
|
nextMetadataURL(other.nextMetadataURL),
|
||||||
nextFieldURL(other.nextFieldURL),
|
nextFieldURL(other.nextFieldURL),
|
||||||
runtimeFields(other.runtimeFields),
|
runtimeFields(other.runtimeFields),
|
||||||
|
localFields(other.localFields),
|
||||||
timeline(other.timeline),
|
timeline(other.timeline),
|
||||||
field(std::move(field_)) {}
|
field(std::move(field_)) {}
|
||||||
Impl(const Impl& other, particle::ParticleFieldSnapshot field_, particle::ParticleFieldSnapshot nextField_)
|
Impl(const Impl& other, particle::ParticleFieldSnapshot field_, particle::ParticleFieldSnapshot nextField_)
|
||||||
@@ -49,6 +52,7 @@ public:
|
|||||||
std::string nextMetadataURL;
|
std::string nextMetadataURL;
|
||||||
std::string nextFieldURL;
|
std::string nextFieldURL;
|
||||||
std::vector<RasterArrayRuntimeFieldDescriptor> runtimeFields;
|
std::vector<RasterArrayRuntimeFieldDescriptor> runtimeFields;
|
||||||
|
std::vector<RasterArrayLocalFieldDescriptor> localFields;
|
||||||
std::shared_ptr<RasterArrayTimelineRuntime> timeline;
|
std::shared_ptr<RasterArrayTimelineRuntime> timeline;
|
||||||
std::optional<particle::ParticleFieldSnapshot> field;
|
std::optional<particle::ParticleFieldSnapshot> field;
|
||||||
std::optional<particle::ParticleFieldSnapshot> nextField;
|
std::optional<particle::ParticleFieldSnapshot> nextField;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
#include <mbgl/style/sources/vector_source.hpp>
|
#include <mbgl/style/sources/vector_source.hpp>
|
||||||
#include <mbgl/style/style.hpp>
|
#include <mbgl/style/style.hpp>
|
||||||
#include <mbgl/particle/field/field_data_adapter.hpp>
|
#include <mbgl/particle/field/field_data_adapter.hpp>
|
||||||
|
#include <mbgl/particle/field/local_particle_field_feed.hpp>
|
||||||
|
|
||||||
#include <mbgl/renderer/sources/render_geojson_source.hpp>
|
#include <mbgl/renderer/sources/render_geojson_source.hpp>
|
||||||
#include <mbgl/renderer/sources/render_raster_array_source.hpp>
|
#include <mbgl/renderer/sources/render_raster_array_source.hpp>
|
||||||
@@ -62,6 +63,14 @@ std::string fixtureBytes(const std::string& fixtureID, const std::string& file)
|
|||||||
return util::read_file("particle-fixtures/" + fixtureID + "/" + file);
|
return util::read_file("particle-fixtures/" + fixtureID + "/" + file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string localMetadata(const int index) {
|
||||||
|
return "{\"schema\":\"maplibre-particle-field/v1\",\"fieldId\":\"production-t" +
|
||||||
|
std::to_string(index) + "\",\"fieldType\":\"WIND\",\"displayDomain\":\"GLOBAL\",\"generation\":" +
|
||||||
|
std::to_string(index + 1) + ",\"validTime\":" + std::to_string(202609040000ULL + index * 100) +
|
||||||
|
",\"dataVersion\":" + std::to_string(index + 1) +
|
||||||
|
",\"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}}";
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
using namespace mbgl;
|
using namespace mbgl;
|
||||||
@@ -238,6 +247,7 @@ TEST(Source, ParticleFieldDataAdapterAcceptsNeutralDescriptor) {
|
|||||||
descriptor.fieldID = "weather-service-wind-20260904T0000Z";
|
descriptor.fieldID = "weather-service-wind-20260904T0000Z";
|
||||||
descriptor.fieldType = "WIND";
|
descriptor.fieldType = "WIND";
|
||||||
descriptor.displayDomain = "GLOBAL";
|
descriptor.displayDomain = "GLOBAL";
|
||||||
|
descriptor.generation = 1;
|
||||||
descriptor.width = 1;
|
descriptor.width = 1;
|
||||||
descriptor.height = 1;
|
descriptor.height = 1;
|
||||||
descriptor.byteLength = 2 * sizeof(float);
|
descriptor.byteLength = 2 * sizeof(float);
|
||||||
@@ -257,6 +267,7 @@ TEST(Source, ParticleFieldDataAdapterReturnsStructuredValidationErrors) {
|
|||||||
descriptor.fieldID = "field";
|
descriptor.fieldID = "field";
|
||||||
descriptor.fieldType = "WIND";
|
descriptor.fieldType = "WIND";
|
||||||
descriptor.displayDomain = "GLOBAL";
|
descriptor.displayDomain = "GLOBAL";
|
||||||
|
descriptor.generation = 1;
|
||||||
descriptor.width = 2;
|
descriptor.width = 2;
|
||||||
descriptor.height = 1;
|
descriptor.height = 1;
|
||||||
descriptor.byteLength = 4 * sizeof(float);
|
descriptor.byteLength = 4 * sizeof(float);
|
||||||
@@ -273,6 +284,68 @@ TEST(Source, ParticleFieldDataAdapterReturnsStructuredValidationErrors) {
|
|||||||
EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::UnsupportedFormat);
|
EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::UnsupportedFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(Source, LocalProductionFeedSubmitsSnapshotsWithProducerTime) {
|
||||||
|
const auto field = fixtureBytes("uniform-east", "field.f32");
|
||||||
|
const std::vector<char> payload(field.begin(), field.end());
|
||||||
|
std::vector<particle::ParticleFieldSnapshot> snapshots;
|
||||||
|
for (int index = 0; index < 4; ++index) {
|
||||||
|
const auto result = particle::LocalParticleFieldFeed::load(localMetadata(index), payload);
|
||||||
|
ASSERT_TRUE(result) << result.message;
|
||||||
|
ASSERT_TRUE(result.snapshot);
|
||||||
|
EXPECT_EQ(result.snapshot->generation, static_cast<uint64_t>(index + 1));
|
||||||
|
EXPECT_EQ(result.snapshot->validTime, 202609040000ULL + static_cast<uint64_t>(index) * 100);
|
||||||
|
snapshots.push_back(std::move(*result.snapshot));
|
||||||
|
}
|
||||||
|
for (size_t index = 1; index < snapshots.size(); ++index) {
|
||||||
|
EXPECT_LT(snapshots[index - 1].validTime, snapshots[index].validTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
SourceTest test;
|
||||||
|
std::vector<std::string> metadata;
|
||||||
|
for (int index = 0; index < 4; ++index) metadata.push_back(localMetadata(index));
|
||||||
|
test.fileSource->sourceResponse = [&](const Resource& resource) -> Response {
|
||||||
|
Response response;
|
||||||
|
const auto prefix = std::string("local-metadata-");
|
||||||
|
if (resource.url.rfind(prefix, 0) == 0) {
|
||||||
|
const auto index = static_cast<size_t>(std::stoi(resource.url.substr(prefix.size())));
|
||||||
|
response.data = std::make_unique<std::string>(metadata.at(index));
|
||||||
|
} else if (resource.url.rfind("local-field-", 0) == 0) {
|
||||||
|
response.data = std::make_unique<std::string>(field);
|
||||||
|
} else {
|
||||||
|
ADD_FAILURE() << "Unexpected local production resource: " << resource.url;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
std::vector<style::RasterArrayLocalFieldDescriptor> resources;
|
||||||
|
for (int index = 0; index < 4; ++index) {
|
||||||
|
resources.push_back({"local-metadata-" + std::to_string(index), "local-field-" + std::to_string(index)});
|
||||||
|
}
|
||||||
|
style::RasterArraySource source{"local-production", std::move(resources)};
|
||||||
|
source.setObserver(&test.styleObserver);
|
||||||
|
test.styleObserver.sourceChanged = [&](Source&) {
|
||||||
|
if (source.impl().timeline) test.end();
|
||||||
|
};
|
||||||
|
source.loadDescription(*test.fileSource);
|
||||||
|
test.run();
|
||||||
|
ASSERT_TRUE(source.impl().timeline);
|
||||||
|
EXPECT_EQ(source.impl().timeline->frame().state, style::RasterArrayPlaybackState::Ready);
|
||||||
|
EXPECT_EQ(source.impl().timeline->frame().t0->validTime, snapshots[0].validTime);
|
||||||
|
EXPECT_EQ(source.impl().timeline->frame().t1->validTime, snapshots[1].validTime);
|
||||||
|
EXPECT_TRUE(source.controlRuntimeTimeline(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(Source, LocalProductionFeedRejectsMissingGeneration) {
|
||||||
|
const auto field = fixtureBytes("uniform-east", "field.f32");
|
||||||
|
auto metadata = localMetadata(0);
|
||||||
|
const auto position = metadata.find("\"generation\":1");
|
||||||
|
ASSERT_NE(position, std::string::npos);
|
||||||
|
metadata.replace(position, std::string("\"generation\":1").size(), "\"generation\":0");
|
||||||
|
const std::vector<char> payload(field.begin(), field.end());
|
||||||
|
const auto result = particle::LocalParticleFieldFeed::load(metadata, payload);
|
||||||
|
EXPECT_FALSE(result);
|
||||||
|
EXPECT_EQ(result.error, particle::ParticleFieldAdapterError::InvalidTimestampOrIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
TEST(Source, RasterArrayTimelineCandidatesReachReadyThroughFieldAdapter) {
|
TEST(Source, RasterArrayTimelineCandidatesReachReadyThroughFieldAdapter) {
|
||||||
SourceTest test;
|
SourceTest test;
|
||||||
const auto eastMetadata = fixtureBytes("uniform-east", "metadata.json");
|
const auto eastMetadata = fixtureBytes("uniform-east", "metadata.json");
|
||||||
|
|||||||
@@ -368,3 +368,10 @@
|
|||||||
- 正常连续播放保持 `simulationRebuild=false`、`trailCleared=false`;Window 内 MIX 不产生 Field upload、simulation reset 或 style reload。
|
- 正常连续播放保持 `simulationRebuild=false`、`trailCleared=false`;Window 内 MIX 不产生 Field upload、simulation reset 或 style reload。
|
||||||
- Validation UI MIX 百分比文本可能滞后,保留为 `NON-BLOCKING observability`,不影响 Core/Playback/Window/Renderer/Adapter 结论。
|
- Validation UI MIX 百分比文本可能滞后,保留为 `NON-BLOCKING observability`,不影响 Core/Playback/Window/Renderer/Adapter 结论。
|
||||||
- 本次为 docs-only 收尾,未执行 Android Gradle/APK/ADB/App/Jest,STEP-049 正式状态更新为 `CLOSED / PASS`。
|
- 本次为 docs-only 收尾,未执行 Android Gradle/APK/ADB/App/Jest,STEP-049 正式状态更新为 `CLOSED / PASS`。
|
||||||
|
## 2026-09-04 — STEP-051 Local Production-like Field Feed 与 Core Snapshot Submit Bridge
|
||||||
|
|
||||||
|
- Starting HEAD `885e7aebef7fb5d02acaed57f9dcf98b8d9e9ba3`;新增 provider-neutral `maplibre-particle-field/v1` local metadata feed、四个时间片资源与 `LocalParticleFieldFeed`,正式复用 ParticleFieldDataAdapter/Snapshot。
|
||||||
|
- `RasterArraySource::submitInitialRuntimeFieldWindow(T0,T1)` 建立薄 Core submit bridge;Timeline 使用 producer `validTime`,旧 fixture null timestamp 保留兼容 fallback,Window/Playback/Renderer ownership 未改变。
|
||||||
|
- 新增 local feed 成功、严格 identity/generation/validTime、Timeline READY/PLAY 与非法 generation 拒绝测试;Linux OpenGL 定向 14/14 PASS。Android/Jest 未执行。
|
||||||
|
- Android TIME 入口可加载 production-like resources;MIX 文本滞后仍为 NON-BLOCKING observability,Rotation/Viewport 技术债未扩大处理。
|
||||||
|
- 状态:`IMPLEMENTED / WAITING_ANDROID_VALIDATION`。
|
||||||
|
|||||||
Reference in New Issue
Block a user