diff --git a/CMakeLists.txt b/CMakeLists.txt index 78e690102..b45935104 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -214,6 +214,7 @@ list(APPEND SRC_FILES ${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_runtime.cpp + ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_binding.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_profile_validator.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/particle/ingress/qbin_v2_decoder.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/local_particle_field_feed.cpp @@ -225,6 +226,7 @@ list(APPEND INCLUDE_FILES ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_profile_contract.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_profile_validator.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_runtime.hpp + ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_binding.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/ingress/qbin_v2_decoder.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/local_particle_field_feed.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/actor/actor_ref.hpp diff --git a/docs/50_Step记录/STEP-075_QBIN2_V4_Binding与ProjectionIdentity_v1.md b/docs/50_Step记录/STEP-075_QBIN2_V4_Binding与ProjectionIdentity_v1.md new file mode 100644 index 000000000..1c3036a3a --- /dev/null +++ b/docs/50_Step记录/STEP-075_QBIN2_V4_Binding与ProjectionIdentity_v1.md @@ -0,0 +1,36 @@ +# STEP-075 — QBIN2 V4 Binding 与 Projection Content Identity v1 + +状态:完成 + +日期:2026-09-13 + +## 目标与基线 + +- 开始 HEAD:`31783f25a8153f48000f19ac7cc2f2113cf47818`。 +- 仅实现 provider-neutral Core Binding/Projection;没有接入 Renderer、GPU、JNI、Android、RN、NavSea 或网络。 + +## 必做项 + +- [x] 新增独立 `ColorBinding`、`ParticleBinding`、`ColorProjectionContentIdentity`、`ParticleProjectionContentIdentity` 与 `BindingProjectionResolver`。 +- [x] Color 可选择 Scalar/Velocity/Direction numeric plane;unit 只作 equality assertion。 +- [x] Particle 支持同 fieldKey/window 的 U/V vector 与 DirectionAngle 两种模式;没有定义速度大小或 Renderer 行为。 +- [x] 每次 resolve 以 binding 的 fieldKey 独立调用 `EncodedFieldStore::resolve`,保留 Core-owned requested time 与每 field window。 +- [x] SampleState 仅保存 requestedForecastTimeMs/mix;content identity 不纳入 mix、播放状态或视觉参数,且保留只读 frame/storage shared_ptr。 +- [x] 结构化 fail-closed 返回 InvalidBinding、FieldSeriesMissing、WindowUnavailable、PlaneMissing、Class/Unit/Component/Group mismatch 与 InterpolationIncompatible。 +- [x] 测试 wind `00/01/02` 与 wave `00/03/06` 在 `01:30` 使用各自窗口;MIX `0/.25/.5/.99` 保持 identity/storage;Scalar-only revision 仅改变 Color identity;覆盖 Direction 成功及 unit/selector failure。 + +## 验证 + +- Linux OpenGL `mbgl-test-runner` 真实重建:PASS(输出节流)。 +- 规定过滤组 XML:50 tests、0 failures、0 errors:Binding 4,Runtime/Profile 11,QBIN Decoder/Ingress 24,V3 Source 11。 +- `git diff --check`:PASS。 + +## 边界与未执行 + +- FieldSeries 在提交阶段已拒绝不可插值相邻 frame;Resolver 仍保留 `InterpolationIncompatible` 结构化保护,正常 Store 无法形成该窗口。 +- 未执行 Android Gradle/APK/ADB/Jest、iOS/Metal 或任何 Host/Renderer 验证;它们不属于本 Step,不能视为 PASS。 +- 任务开始前既有 STEP-062 dirty、历史 untracked、build logs 与 RN 输出均未清理、暂存或提交。 + +## 下一步 + +STEP-075 关闭;按任务要求停止,不进入 STEP-076。 diff --git a/docs/报告/STEP-075_QBIN2_V4_Binding与ProjectionIdentity_Codex完成报告_v1.md b/docs/报告/STEP-075_QBIN2_V4_Binding与ProjectionIdentity_Codex完成报告_v1.md new file mode 100644 index 000000000..7e44d0991 --- /dev/null +++ b/docs/报告/STEP-075_QBIN2_V4_Binding与ProjectionIdentity_Codex完成报告_v1.md @@ -0,0 +1,27 @@ +# STEP-075 QBIN2 V4 Binding 与 Projection Identity Codex 完成报告 v1 + +日期:2026-09-13 + +## 已完成 + +- 完成纯 Core 的 Color/Particle binding 与 projection resolver。Host 仅表达 binding intent;Core 仍拥有 requested forecast time 和 per-field window。 +- Identity 由 binding selector、实际 T0/T1 window frame coordinate、所选 plane 与 shared validity 的 content identity 组成;SampleState 单独承载 requested time/mix。U/V/validity 未变化时,Scalar-only revision 不改变 Particle identity。 +- U/V 强制 VELOCITY_COMPONENT、MPS equality、U_EAST/V_NORTH 与同组;Direction 强制 DIRECTION_ANGLE/NONE/unit equality。所有 lookup/assertion 都 fail-closed,绝不 fallback 到其他 fieldKey/plane。 +- 增加 4 个独立 Binding tests:不同 cadence、MIX identity/storage、Scalar-only revision isolation、错误矩阵和 DirectionAngle。 + +## 验证结果 + +- `cmake --build build-linux-opengl --target mbgl-test-runner -j3`:PASS(真实重建,输出节流)。 +- 规定 GTest filter:50 tests / 0 failures / 0 errors;Binding 4、Runtime/Profile 11、QBIN Decoder/Ingress 24、V3 Source 11。 +- `git diff --check`:PASS。 + +## 未完成或无法验证 + +- Android Gradle/APK/ADB/Jest、iOS/Metal、Renderer/GPU、Host/JNI/RN 均按任务禁止或范围限制未执行,不能标记 PASS。 +- 不可插值相邻帧由既有 FieldSeries submit 拒绝,因此合法 Store 不会产生该 resolve window;Resolver 仍保留 `InterpolationIncompatible` 防御返回。 + +## Git + +- 首个 scoped commit:待本报告随实现创建后回填。 +- push:待 scoped commit 后回填。 +- 最终 `origin/particle/main` HEAD:待 push 后回填。 diff --git a/include/mbgl/particle/field/field_binding.hpp b/include/mbgl/particle/field/field_binding.hpp new file mode 100644 index 000000000..b7abdb007 --- /dev/null +++ b/include/mbgl/particle/field/field_binding.hpp @@ -0,0 +1,121 @@ +#pragma once + +#include + +#include +#include +#include + +namespace mbgl::particle { + +// Host-owned binding intent. FieldPlaneKey identity is deliberately only its +// semanticID/groupID pair; component is validated from the selected plane. +struct ColorBinding { + std::string fieldKey; + FieldPlaneKey plane; + FieldValueClass expectedValueClass = FieldValueClass::Scalar; + uint16_t expectedUnitID = FieldUnitNone; + + bool operator==(const ColorBinding&) const; +}; + +enum class ParticleBindingMode : uint8_t { VectorComponents, DirectionAngle }; + +struct ParticleBinding { + ParticleBindingMode mode = ParticleBindingMode::VectorComponents; + std::string fieldKey; + FieldPlaneKey uPlane; + FieldPlaneKey vPlane; + FieldPlaneKey directionPlane; + uint16_t expectedUnitID = FieldUnitNone; + + bool operator==(const ParticleBinding&) const; +}; + +struct FieldSampleState { + uint64_t requestedForecastTimeMs = 0; + float mix = 0.0f; +}; + +struct ColorProjectionContentIdentity { + ColorBinding binding; + FrameContentIdentity t0Frame; + FrameContentIdentity t1Frame; + std::string t0Plane; + std::string t1Plane; + std::string t0Validity; + std::string t1Validity; + + bool operator==(const ColorProjectionContentIdentity&) const; +}; + +struct ParticleProjectionContentIdentity { + ParticleBinding binding; + FrameContentIdentity t0Frame; + FrameContentIdentity t1Frame; + std::string t0FirstPlane; + std::string t1FirstPlane; + std::string t0SecondPlane; + std::string t1SecondPlane; + std::string t0Validity; + std::string t1Validity; + + bool operator==(const ParticleProjectionContentIdentity&) const; +}; + +struct ColorProjection { + ColorProjectionContentIdentity contentIdentity; + FieldSampleState sampleState; + std::shared_ptr t0Frame; + std::shared_ptr t1Frame; + std::shared_ptr> t0PlaneStorage; + std::shared_ptr> t1PlaneStorage; + std::shared_ptr> t0ValidityStorage; + std::shared_ptr> t1ValidityStorage; +}; + +struct ParticleProjection { + ParticleProjectionContentIdentity contentIdentity; + FieldSampleState sampleState; + std::shared_ptr t0Frame; + std::shared_ptr t1Frame; + std::shared_ptr> t0FirstPlaneStorage; + std::shared_ptr> t1FirstPlaneStorage; + std::shared_ptr> t0SecondPlaneStorage; + std::shared_ptr> t1SecondPlaneStorage; + std::shared_ptr> t0ValidityStorage; + std::shared_ptr> t1ValidityStorage; +}; + +enum class BindingProjectionError : uint8_t { + Accepted, + InvalidBinding, + FieldSeriesMissing, + WindowUnavailable, + PlaneMissing, + PlaneClassMismatch, + PlaneUnitMismatch, + PlaneComponentMismatch, + PlaneGroupMismatch, + InterpolationIncompatible, +}; + +struct ColorProjectionResult { + BindingProjectionError error = BindingProjectionError::InvalidBinding; + std::optional projection; + bool accepted() const { return error == BindingProjectionError::Accepted; } +}; + +struct ParticleProjectionResult { + BindingProjectionError error = BindingProjectionError::InvalidBinding; + std::optional projection; + bool accepted() const { return error == BindingProjectionError::Accepted; } +}; + +class BindingProjectionResolver final { +public: + static ColorProjectionResult resolve(const EncodedFieldStore&, const ColorBinding&, uint64_t requestedForecastTimeMs); + static ParticleProjectionResult resolve(const EncodedFieldStore&, const ParticleBinding&, uint64_t requestedForecastTimeMs); +}; + +} // namespace mbgl::particle diff --git a/src/mbgl/particle/field/field_binding.cpp b/src/mbgl/particle/field/field_binding.cpp new file mode 100644 index 000000000..ef7764e88 --- /dev/null +++ b/src/mbgl/particle/field/field_binding.cpp @@ -0,0 +1,149 @@ +#include + +#include + +namespace mbgl::particle { +namespace { + +bool validValueClass(FieldValueClass value) { return value <= FieldValueClass::Validity; } +bool validComponent(FieldComponent value) { return value <= FieldComponent::WUp; } +bool validKey(const FieldPlaneKey& key) { return key.semanticID != 0 && validComponent(key.component); } +bool sameSelector(const FieldPlaneKey& a, const FieldPlaneKey& b) { + return a.semanticID == b.semanticID && a.groupID == b.groupID; +} +const DecodedFieldPlane* findPlane(const DecodedFieldFrame& frame, const FieldPlaneKey& selector) { + for (const auto& plane : frame.planes) if (sameSelector(plane.key, selector)) return &plane; + return nullptr; +} + +BindingProjectionError validateColorBinding(const ColorBinding& binding) { + if (binding.fieldKey.empty() || !validKey(binding.plane) || !validValueClass(binding.expectedValueClass) || + binding.expectedValueClass == FieldValueClass::Validity) + return BindingProjectionError::InvalidBinding; + return BindingProjectionError::Accepted; +} +BindingProjectionError validateParticleBinding(const ParticleBinding& binding) { + if (binding.fieldKey.empty()) return BindingProjectionError::InvalidBinding; + if (binding.mode == ParticleBindingMode::VectorComponents) { + if (!validKey(binding.uPlane) || !validKey(binding.vPlane) || sameSelector(binding.uPlane, binding.vPlane)) + return BindingProjectionError::InvalidBinding; + } else if (binding.mode == ParticleBindingMode::DirectionAngle) { + if (!validKey(binding.directionPlane)) return BindingProjectionError::InvalidBinding; + } else { + return BindingProjectionError::InvalidBinding; + } + return BindingProjectionError::Accepted; +} +BindingProjectionError validateWindow(const EncodedFieldStore& store, const std::string& fieldKey, + uint64_t requested, std::optional& window) { + if (!store.find(fieldKey)) return BindingProjectionError::FieldSeriesMissing; + window = store.resolve(fieldKey, requested); + return window ? BindingProjectionError::Accepted : BindingProjectionError::WindowUnavailable; +} +BindingProjectionError validateSelected(const DecodedFieldPlane* t0, + const DecodedFieldPlane* t1, + FieldValueClass valueClass, + uint16_t unit, + FieldComponent component) { + if (!t0 || !t1) return BindingProjectionError::PlaneMissing; + if (t0->valueClass != valueClass || t1->valueClass != valueClass) return BindingProjectionError::PlaneClassMismatch; + if (t0->unitID != unit || t1->unitID != unit) return BindingProjectionError::PlaneUnitMismatch; + if (t0->key.component != component || t1->key.component != component) + return BindingProjectionError::PlaneComponentMismatch; + if (!t0->isInterpolationCompatibleWith(*t1)) return BindingProjectionError::InterpolationIncompatible; + return BindingProjectionError::Accepted; +} + +} // namespace + +bool ColorBinding::operator==(const ColorBinding& other) const { + return fieldKey == other.fieldKey && sameSelector(plane, other.plane) && + expectedValueClass == other.expectedValueClass && expectedUnitID == other.expectedUnitID; +} +bool ParticleBinding::operator==(const ParticleBinding& other) const { + return mode == other.mode && fieldKey == other.fieldKey && sameSelector(uPlane, other.uPlane) && + sameSelector(vPlane, other.vPlane) && sameSelector(directionPlane, other.directionPlane) && + expectedUnitID == other.expectedUnitID; +} +bool ColorProjectionContentIdentity::operator==(const ColorProjectionContentIdentity& other) const { + return binding == other.binding && t0Frame == other.t0Frame && t1Frame == other.t1Frame && + t0Plane == other.t0Plane && t1Plane == other.t1Plane && t0Validity == other.t0Validity && + t1Validity == other.t1Validity; +} +bool ParticleProjectionContentIdentity::operator==(const ParticleProjectionContentIdentity& other) const { + // A FieldFrame revision can carry an unrelated plane update. Projection + // identity therefore uses the selected frame time/field as its window + // coordinate; selected plane and validity content identities carry the + // revision-sensitive content part. This preserves a Particle projection + // across a scalar-only revision in the same frame window. + const auto sameWindowFrame = [](const FrameContentIdentity& a, const FrameContentIdentity& b) { + return a.fieldID == b.fieldID && a.validTimeMs == b.validTimeMs; + }; + return binding == other.binding && sameWindowFrame(t0Frame, other.t0Frame) && sameWindowFrame(t1Frame, other.t1Frame) && + t0FirstPlane == other.t0FirstPlane && t1FirstPlane == other.t1FirstPlane && + t0SecondPlane == other.t0SecondPlane && t1SecondPlane == other.t1SecondPlane && + t0Validity == other.t0Validity && t1Validity == other.t1Validity; +} + +ColorProjectionResult BindingProjectionResolver::resolve(const EncodedFieldStore& store, + const ColorBinding& binding, + const uint64_t requested) { + if (const auto error = validateColorBinding(binding); error != BindingProjectionError::Accepted) return {error, {}}; + std::optional window; + if (const auto error = validateWindow(store, binding.fieldKey, requested, window); error != BindingProjectionError::Accepted) + return {error, {}}; + const auto* t0 = findPlane(*window->t0, binding.plane); + const auto* t1 = findPlane(*window->t1, binding.plane); + if (const auto error = validateSelected(t0, t1, binding.expectedValueClass, binding.expectedUnitID, + t0 ? t0->key.component : FieldComponent::None); + error != BindingProjectionError::Accepted) + return {error, {}}; + ColorProjection projection{{binding, window->t0->frameContentIdentity, window->t1->frameContentIdentity, + t0->contentIdentity, t1->contentIdentity, window->t0->validityContentIdentity, + window->t1->validityContentIdentity}, + {requested, window->mix}, window->t0, window->t1, t0->storage, t1->storage, + window->t0->validityBits, window->t1->validityBits}; + return {BindingProjectionError::Accepted, std::move(projection)}; +} + +ParticleProjectionResult BindingProjectionResolver::resolve(const EncodedFieldStore& store, + const ParticleBinding& binding, + const uint64_t requested) { + if (const auto error = validateParticleBinding(binding); error != BindingProjectionError::Accepted) return {error, {}}; + std::optional window; + if (const auto error = validateWindow(store, binding.fieldKey, requested, window); error != BindingProjectionError::Accepted) + return {error, {}}; + const auto firstSelector = binding.mode == ParticleBindingMode::VectorComponents ? binding.uPlane : binding.directionPlane; + const auto secondSelector = binding.mode == ParticleBindingMode::VectorComponents ? binding.vPlane : FieldPlaneKey{}; + const auto* t0First = findPlane(*window->t0, firstSelector); + const auto* t1First = findPlane(*window->t1, firstSelector); + const auto expectedClass = binding.mode == ParticleBindingMode::VectorComponents ? FieldValueClass::VelocityComponent + : FieldValueClass::DirectionAngle; + const auto expectedComponent = binding.mode == ParticleBindingMode::VectorComponents ? FieldComponent::UEast + : FieldComponent::None; + if (const auto error = validateSelected(t0First, t1First, expectedClass, binding.expectedUnitID, expectedComponent); + error != BindingProjectionError::Accepted) + return {error, {}}; + const DecodedFieldPlane* t0Second = nullptr; + const DecodedFieldPlane* t1Second = nullptr; + if (binding.mode == ParticleBindingMode::VectorComponents) { + t0Second = findPlane(*window->t0, secondSelector); t1Second = findPlane(*window->t1, secondSelector); + if (const auto error = validateSelected(t0Second, t1Second, FieldValueClass::VelocityComponent, + binding.expectedUnitID, FieldComponent::VNorth); + error != BindingProjectionError::Accepted) + return {error, {}}; + if (t0First->key.groupID != t0Second->key.groupID || t1First->key.groupID != t1Second->key.groupID) + return {BindingProjectionError::PlaneGroupMismatch, {}}; + } + ParticleProjection projection{{binding, window->t0->frameContentIdentity, window->t1->frameContentIdentity, + t0First->contentIdentity, t1First->contentIdentity, + t0Second ? t0Second->contentIdentity : std::string{}, + t1Second ? t1Second->contentIdentity : std::string{}, + window->t0->validityContentIdentity, window->t1->validityContentIdentity}, + {requested, window->mix}, window->t0, window->t1, t0First->storage, t1First->storage, + t0Second ? t0Second->storage : nullptr, t1Second ? t1Second->storage : nullptr, + window->t0->validityBits, window->t1->validityBits}; + return {BindingProjectionError::Accepted, std::move(projection)}; +} + +} // namespace mbgl::particle diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 3e4c36a50..226a3c9e2 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -19,6 +19,7 @@ add_library( ${PROJECT_SOURCE_DIR}/test/math/wrap.test.cpp ${PROJECT_SOURCE_DIR}/test/platform/settings.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/field_runtime.test.cpp + ${PROJECT_SOURCE_DIR}/test/particle/field_binding.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/qbin_v2_decoder.test.cpp ${PROJECT_SOURCE_DIR}/test/plugin/plugin.test.cpp ${PROJECT_SOURCE_DIR}/test/renderer/image_manager.test.cpp diff --git a/test/particle/field_binding.test.cpp b/test/particle/field_binding.test.cpp new file mode 100644 index 000000000..e45427170 --- /dev/null +++ b/test/particle/field_binding.test.cpp @@ -0,0 +1,170 @@ +#include + +#include + +#include +#include +#include + +using namespace mbgl; + +namespace { + +constexpr uint64_t Hour = 3'600'000; +constexpr uint64_t Base = 1'700'000'000'000ULL; + +particle::DecodedFieldPlane makePlane(uint16_t semantic, uint16_t group, particle::FieldValueClass valueClass, + particle::FieldComponent component, uint16_t unit, std::string identity, + std::shared_ptr> bytes) { + return {{semantic, group, component}, particle::FieldDType::UInt8, valueClass, unit, false, 4, 1.0f, 0.0f, + std::move(identity), std::move(bytes)}; +} + +std::shared_ptr makeFrame(std::string field, uint64_t time, uint64_t generation, + std::string suffix, bool vector = true, + bool scalar = true) { + const auto bytes = std::make_shared>(std::vector{1, 2, 3, 4}); + const auto bits = std::make_shared>(std::vector{15}); + particle::DecodedFieldFrame frame; + frame.frameContentIdentity = {std::move(field), generation, generation, time}; + frame.referenceTimeMs = time; + frame.grid = {.layout = 1, .projection = 1, .sampleOrder = 1, .gridRegistration = 1, .width = 2, .height = 2, + .west = 0, .south = 0, .east = 1, .north = 1}; + frame.validityContentIdentity = "valid-" + suffix; + frame.validityBits = bits; + frame.validityPlane = makePlane(900, 0, particle::FieldValueClass::Validity, particle::FieldComponent::None, + particle::FieldUnitNone, frame.validityContentIdentity, bits); + frame.validityPlane.dtype = particle::FieldDType::Bitset; + frame.validityPlane.requiredFlag = true; + if (vector) { + frame.planes.push_back(makePlane(1, 7, particle::FieldValueClass::VelocityComponent, particle::FieldComponent::UEast, + particle::FieldUnitMetersPerSecond, "u-" + suffix, bytes)); + frame.planes.push_back(makePlane(2, 7, particle::FieldValueClass::VelocityComponent, particle::FieldComponent::VNorth, + particle::FieldUnitMetersPerSecond, "v-" + suffix, bytes)); + } + if (scalar) frame.planes.push_back(makePlane(50, 0, particle::FieldValueClass::Scalar, particle::FieldComponent::None, + 42, "s-" + suffix, bytes)); + return std::make_shared(std::move(frame)); +} + +void submit(particle::EncodedFieldStore& store, const std::string& key, + std::shared_ptr frame) { + ASSERT_EQ(store.submit(key, std::move(frame)), particle::FieldSeriesSubmitResult::Inserted); +} + +particle::ColorBinding color(std::string key = "weather.wave") { + return {std::move(key), {50, 0, particle::FieldComponent::None}, particle::FieldValueClass::Scalar, 42}; +} +particle::ParticleBinding wind() { + particle::ParticleBinding binding; + binding.fieldKey = "weather.wind"; binding.uPlane = {1, 7, particle::FieldComponent::None}; + binding.vPlane = {2, 7, particle::FieldComponent::None}; binding.expectedUnitID = particle::FieldUnitMetersPerSecond; + return binding; +} + +} // namespace + +TEST(ParticleFieldBinding, ResolvesIndependentFieldKeyCadence) { + particle::EncodedFieldStore store; + for (uint64_t hour : {0ULL, 1ULL, 2ULL}) submit(store, "weather.wind", makeFrame("wind", Base + hour * Hour, 1, "w" + std::to_string(hour))); + for (uint64_t hour : {0ULL, 3ULL, 6ULL}) submit(store, "weather.wave", makeFrame("wave", Base + hour * Hour, 1, "a" + std::to_string(hour))); + const auto c = particle::BindingProjectionResolver::resolve(store, color(), Base + Hour + Hour / 2); + const auto p = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour + Hour / 2); + ASSERT_TRUE(c.accepted()); ASSERT_TRUE(p.accepted()); + EXPECT_FLOAT_EQ(c.projection->sampleState.mix, 0.5f); + EXPECT_FLOAT_EQ(p.projection->sampleState.mix, 0.5f); + EXPECT_EQ(c.projection->contentIdentity.t0Frame.validTimeMs, Base); + EXPECT_EQ(c.projection->contentIdentity.t1Frame.validTimeMs, Base + 3 * Hour); + EXPECT_EQ(p.projection->contentIdentity.t0Frame.validTimeMs, Base + Hour); + EXPECT_EQ(p.projection->contentIdentity.t1Frame.validTimeMs, Base + 2 * Hour); + EXPECT_NE(c.projection->t0Frame, p.projection->t0Frame); + EXPECT_NE(c.projection->t0ValidityStorage, p.projection->t0ValidityStorage); +} + +TEST(ParticleFieldBinding, MixChangesSampleStateButNotContentIdentityOrStorage) { + particle::EncodedFieldStore store; + submit(store, "weather.wave", makeFrame("wave", Base, 1, "0")); + submit(store, "weather.wave", makeFrame("wave", Base + 4 * Hour, 1, "4")); + const auto at0 = particle::BindingProjectionResolver::resolve(store, color(), Base); + const auto at1 = particle::BindingProjectionResolver::resolve(store, color(), Base + Hour); + const auto at2 = particle::BindingProjectionResolver::resolve(store, color(), Base + 2 * Hour); + const auto at3 = particle::BindingProjectionResolver::resolve(store, color(), Base + 14'256'000); + ASSERT_TRUE(at0.accepted()); ASSERT_TRUE(at1.accepted()); ASSERT_TRUE(at2.accepted()); ASSERT_TRUE(at3.accepted()); + EXPECT_FLOAT_EQ(at0.projection->sampleState.mix, 0.0f); EXPECT_FLOAT_EQ(at1.projection->sampleState.mix, 0.25f); + EXPECT_FLOAT_EQ(at2.projection->sampleState.mix, 0.5f); EXPECT_FLOAT_EQ(at3.projection->sampleState.mix, 0.99f); + EXPECT_EQ(at0.projection->contentIdentity, at1.projection->contentIdentity); + EXPECT_EQ(at0.projection->contentIdentity, at2.projection->contentIdentity); + EXPECT_EQ(at0.projection->contentIdentity, at3.projection->contentIdentity); + EXPECT_EQ(at0.projection->t0PlaneStorage, at3.projection->t0PlaneStorage); +} + +TEST(ParticleFieldBinding, ScalarRevisionChangesOnlyColorIdentity) { + particle::EncodedFieldStore store; + submit(store, "weather.wind", makeFrame("wind", Base, 1, "0")); + submit(store, "weather.wind", makeFrame("wind", Base + Hour, 1, "1")); + const auto beforeColor = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + Hour / 2); + const auto beforeParticle = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour / 2); + ASSERT_TRUE(beforeColor.accepted()); ASSERT_TRUE(beforeParticle.accepted()); + auto revision = *beforeParticle.projection->t0Frame; + revision.frameContentIdentity.generation = 2; + revision.frameContentIdentity.dataVersion = 2; + revision.planes.back().contentIdentity = "s-revised"; + ASSERT_EQ(store.submit("weather.wind", std::make_shared(std::move(revision))), + particle::FieldSeriesSubmitResult::ReplacedRevision); + const auto afterColor = particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + Hour / 2); + const auto afterParticle = particle::BindingProjectionResolver::resolve(store, wind(), Base + Hour / 2); + ASSERT_TRUE(afterColor.accepted()); ASSERT_TRUE(afterParticle.accepted()); + EXPECT_NE(beforeColor.projection->contentIdentity, afterColor.projection->contentIdentity); + EXPECT_EQ(beforeParticle.projection->contentIdentity, afterParticle.projection->contentIdentity); + EXPECT_EQ(beforeParticle.projection->t0FirstPlaneStorage, afterParticle.projection->t0FirstPlaneStorage); + EXPECT_EQ(beforeParticle.projection->t0ValidityStorage, afterParticle.projection->t0ValidityStorage); +} + +TEST(ParticleFieldBinding, FailsClosedAndSupportsDirectionAngle) { + particle::EncodedFieldStore store; + submit(store, "weather.wind", makeFrame("wind", Base, 1, "0")); + submit(store, "weather.wind", makeFrame("wind", Base + Hour, 1, "1")); + EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color(""), Base).error, particle::BindingProjectionError::InvalidBinding); + EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color("missing"), Base).error, particle::BindingProjectionError::FieldSeriesMissing); + EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, color("weather.wind"), Base + 2 * Hour).error, particle::BindingProjectionError::WindowUnavailable); + auto missing = color("weather.wind"); missing.plane.semanticID = 99; + EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, missing, Base).error, particle::BindingProjectionError::PlaneMissing); + auto classMismatch = color("weather.wind"); classMismatch.plane = {1, 7, particle::FieldComponent::None}; + EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, classMismatch, Base).error, particle::BindingProjectionError::PlaneClassMismatch); + auto unitMismatch = wind(); unitMismatch.expectedUnitID = 99; + EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, unitMismatch, Base).error, particle::BindingProjectionError::PlaneUnitMismatch); + auto componentMismatch = wind(); componentMismatch.uPlane.semanticID = 2; componentMismatch.vPlane.semanticID = 1; + EXPECT_EQ(particle::BindingProjectionResolver::resolve(store, componentMismatch, Base).error, particle::BindingProjectionError::PlaneComponentMismatch); + particle::EncodedFieldStore unequalGroups; + auto g0 = *makeFrame("wind", Base, 1, "g0"), g1 = *makeFrame("wind", Base + Hour, 1, "g1"); + g0.planes[1].key.groupID = 8; g1.planes[1].key.groupID = 8; + submit(unequalGroups, "weather.wind", std::make_shared(std::move(g0))); + submit(unequalGroups, "weather.wind", std::make_shared(std::move(g1))); + auto groupMismatch = wind(); groupMismatch.vPlane.groupID = 8; + EXPECT_EQ(particle::BindingProjectionResolver::resolve(unequalGroups, groupMismatch, Base).error, + particle::BindingProjectionError::PlaneGroupMismatch); + + particle::EncodedFieldStore directions; + auto d0 = *makeFrame("direction", Base, 1, "d0", false, false); + auto d1 = *makeFrame("direction", Base + Hour, 1, "d1", false, false); + const auto directionBytes = std::make_shared>(std::vector{1, 2, 3, 4}); + for (auto* frame : {&d0, &d1}) { + frame->planes.push_back(makePlane(10, 0, particle::FieldValueClass::DirectionAngle, particle::FieldComponent::None, + particle::FieldUnitDegrees, "direction", directionBytes)); + frame->planes.back().dtype = particle::FieldDType::UInt16; + frame->planes.back().scale = 360.0f / 65536.0f; + } + submit(directions, "weather.direction", std::make_shared(std::move(d0))); + submit(directions, "weather.direction", std::make_shared(std::move(d1))); + particle::ParticleBinding direction; + direction.mode = particle::ParticleBindingMode::DirectionAngle; direction.fieldKey = "weather.direction"; + direction.directionPlane = {10, 0, particle::FieldComponent::None}; direction.expectedUnitID = particle::FieldUnitDegrees; + EXPECT_TRUE(particle::BindingProjectionResolver::resolve(directions, direction, Base).accepted()); + direction.expectedUnitID = particle::FieldUnitMetersPerSecond; + EXPECT_EQ(particle::BindingProjectionResolver::resolve(directions, direction, Base).error, + particle::BindingProjectionError::PlaneUnitMismatch); + direction.expectedUnitID = particle::FieldUnitDegrees; + direction.directionPlane.semanticID = 99; + EXPECT_EQ(particle::BindingProjectionResolver::resolve(directions, direction, Base).error, + particle::BindingProjectionError::PlaneMissing); +} diff --git a/粒子开发记录.md b/粒子开发记录.md index e4361fc74..e44bdde3e 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -734,3 +734,9 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2` - `AcceptsRawLimitWithCanonicalPaddingExcluded` 改为 1101×1890(2,080,890 cells)的 17-plane canonical fixture:single shared validity BITSET 加 16 个 UINT8 numeric plane。rawSum=33,554,352 bytes,小于 32 MiB limit=33,554,432;payloadLength=33,554,448 bytes,大于 limit 与 rawSum;totalQbinBytes=33,555,104 bytes,小于 64 MiB。 - production `QbinV2Decoder::decode(...)` Accepted;未发现生产缺陷,未改 Decoder 或 Host/网络/JNI/RN/Binding/Projection/Renderer。真实 runner 重建 PASS,Decoder/Ingress 24/24、Runtime/Profile 11/11、Source 回归 11/11 PASS,`git diff --check` PASS;未执行 Android Gradle/APK/ADB/Jest,按范围停止。 + +### 2026-09-13 STEP-075 QBIN2 V4 Binding 与 Projection Identity + +- 新增 provider-neutral Color/Particle Binding 与 Resolver;每个 binding 按自己的 fieldKey 解析 FieldSeries Window,SampleState 单独保存 requested time/mix,Content Identity 不包含 mix 或视觉/播放状态。 +- Vector U/V 与 DirectionAngle 都严格按 selected plane assertion fail-closed;Scalar-only revision 保持 Particle U/V/validity storage 与 identity 不变。 +- Linux runner 真实重建、规定 Core filter 50/50 PASS、`git diff --check` PASS;未执行 Android Gradle/APK/ADB/Jest/iOS/Metal,未进入 Renderer/GPU/Host/JNI/RN,STEP-075 关闭并停止。