diff --git a/include/mbgl/style/sources/raster_array_source.hpp b/include/mbgl/style/sources/raster_array_source.hpp index ba21c6d6e..9d20b5328 100644 --- a/include/mbgl/style/sources/raster_array_source.hpp +++ b/include/mbgl/style/sources/raster_array_source.hpp @@ -175,6 +175,9 @@ public: // update API. This must return Noop without changing the Source window. RasterArrayRuntimeFieldUpdateResult repeatRuntimeFieldWindow(); RasterArrayHostFieldSubmitResult submitHostField(const std::string& metadataText, const std::vector& payload); + // v2 ingress preserves explicit scalar/vector/validity plane presence. + RasterArrayHostFieldSubmitResult submitHostField(const std::string& metadataText, + const particle::ParticleFieldPayloadBundle& payloads); // Clears producer-owned Host ingress state while retaining the Source and // its Style layers for a subsequent T0/T1 submission. bool clearHostFields(); @@ -191,6 +194,7 @@ protected: Mutable createMutable() const noexcept override; private: + RasterArrayHostFieldSubmitResult submitDecodedHostField(particle::ParticleFieldSnapshot snapshot); struct RuntimeFieldCandidate { RasterArrayRuntimeFieldDescriptor descriptor; std::unique_ptr metadataRequest; diff --git a/packages/maplibre-particle-rn/CHANGELOG.md b/packages/maplibre-particle-rn/CHANGELOG.md index c0608c168..b75b6b6e4 100644 --- a/packages/maplibre-particle-rn/CHANGELOG.md +++ b/packages/maplibre-particle-rn/CHANGELOG.md @@ -1,5 +1,13 @@ # 变更记录 +## 13.4.1-particle.3 + +- `FieldRuntime.submitField` 升级为 `maplibre-particle-field/v2` 的明确 `FieldPayloadRefs`:shared validity 加可选 scalar/vector 本地文件,不再接受模糊单 payload。 +- 新增 `setFieldValueSource` 与 `setParticleAdvectionScale`,仅桥接既有 renderer 配置,不重传 field 或改变 Timeline。 +- 保留 `submitWindField` native/internal v1 ABI;公共兼容入口改名为 `submitLegacyVectorField`,避免与 v2 混淆。 +- Android bridge 在读取前校验全部 local file、非空 plane 与合计 64 MiB;Java/JNI 用有 presence flags 和长度的 v2 packed container 恢复 Core `ParticleFieldPayloadBundle`。 +- Android artifact 未构建、未发布;Android Gradle/AAR/APK/ADB 与 Jest 均未执行。 + ## 13.4.1-particle.2 - 公共 RN/TS API 重整为纯 `FieldRuntime` Field Renderer 接口。 diff --git a/packages/maplibre-particle-rn/README.md b/packages/maplibre-particle-rn/README.md index 591e8dcd0..19b2ed499 100644 --- a/packages/maplibre-particle-rn/README.md +++ b/packages/maplibre-particle-rn/README.md @@ -1,12 +1,12 @@ # @navsea/maplibre-particle-rn -`13.4.1-particle.2` 提供 React Native 的纯 Field Renderer 接口。它只接收宿主已经准备好的 canonical field 元数据与本地二进制文件,并控制既有 Native/Core 渲染器;不提供地图组件、底图、数据集、provider、HTTP、缓存或业务策略。 +`13.4.1-particle.3` 提供 React Native 的纯 Field Renderer 接口。它只接收宿主已经准备好的 canonical field 元数据与本地二进制文件,并控制既有 Native/Core 渲染器;不提供地图组件、底图、数据集、provider、HTTP、缓存或业务策略。 Map 与 Camera 必须由 Consumer 直接从 `@maplibre/maplibre-react-native@11.3.6` 导入。本包将该库声明为 peer dependency,不再转导出 MapLibre React Native。 ## 版本关系 -- RN/TS 包:`13.4.1-particle.2` +- RN/TS 包:`13.4.1-particle.3` - Android Native artifact:`cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.1` 本次仅整理 RN/TS 公共接口。Native artifact 未构建、未修改、未发布;Expo plugin 继续解析上述冻结 artifact。 @@ -33,7 +33,11 @@ const mapStyle = JSON.stringify({ ```ts await FieldRuntime.clearFields(reactTag); -await FieldRuntime.submitField(reactTag, metadataJson, localFileUri); +await FieldRuntime.submitField(reactTag, metadataJson, { + validityFileUri: "file:///absolute/path/validity.u8", + scalarFileUri: "file:///absolute/path/scalar.f32", // 可选 + vectorFileUri: "file:///absolute/path/vector-uv.f32", // 可选;至少 scalar/vector 之一 +}); await FieldRuntime.setFieldColorStops(reactTag, [ { value: 0, red: 0.08, green: 0.2, blue: 0.8, alpha: 0.72 }, { value: 30, red: 0.9, green: 0.08, blue: 0.08, alpha: 0.72 }, @@ -47,19 +51,24 @@ await FieldRuntime.setFieldColorStops(reactTag, [ 全部方法均以 `reactTag` 为首参并返回既有桥接命令结果: - 生命周期:`attach`、`detach`、`getState` -- 数据:`clearFields`、`submitField` +- 数据:`clearFields`、`submitField`(v2 `FieldPayloadRefs`) +- renderer 配置:`setFieldValueSource("SCALAR" | "VECTOR_MAGNITUDE")`、`setParticleAdvectionScale(scale)` - 显示与播放:`setEnabled`、`setFieldVisible`、`setParticleVisible`、`setTargetFps`、`setTargetTime`、`setPlaybackState`、`setPlaybackSpeed` - 色彩:`setFieldColorStops`、`setParticleColor`、`setTrailColor` - 粒子视觉参数:`setParticleCount`、`setParticleLineWidth`、`setParticleLength`、`setParticleOpacity`、`setTrailOpacity`、`setTrailFade`、`setFieldOpacity` `FieldColorStop` 使用无业务含义的 `value` 字段;色阶继续遵循既有 Native 契约,至少提供 2 个且 `value` 严格递增。包在非原生环境可安全导入;实际调用 `FieldRuntime` 而 Native bridge 不存在时会给出稳定、明确的错误。 +`submitField` 的 v2 `payloadRefs` 必须有 `validityFileUri`,并且必须有 `scalarFileUri` 或 `vectorFileUri`。每个引用只能是 `file://` URI 或绝对本地路径;桥接层会在读入前检查全部文件、非空性和合计 64 MiB 上限。它不接受 JS number array、Base64 或大 JSON payload。Core 继续按 metadata 精确校验各 plane 的长度、encoding、layout 和 validity。 + +`.2` 的 `submitField(reactTag, metadataJson, payloadFileUri)` 是单一 v1 vector payload。`.3` 不再以该模糊形状作为主入口;确有旧 native seam 的 Consumer 可显式调用 `submitLegacyVectorField`,它仅转发冻结的 v1 `submitWindField` ABI。新代码应使用 v2 `submitField`。 + ## 安装与发布 正式包仍以 HTTPS tarball 交付: ```text -https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.2.tgz +https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.3.tgz ``` Consumer 只依赖发布 tarball,不依赖本仓库源码路径。发布前通过 `npm pack` 检查 tarball 内容;本包不包含 AAR、APK、fixture 或凭证。 diff --git a/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt b/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt index b394e60cb..586b877b0 100644 --- a/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt +++ b/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt @@ -8,7 +8,6 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.turbomodule.core.interfaces.TurboModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableArray -import com.facebook.react.bridge.ReadableMap import com.facebook.react.uimanager.IllegalViewOperationException import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.common.UIManagerType @@ -64,6 +63,7 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : @ReactMethod override fun getState(reactTag: Double, promise: Promise) = withRuntime(reactTag, promise) { runtime -> promise.resolve(snapshot(runtime)) } @ReactMethod override fun clearWindFields(reactTag: Double, promise: Promise) = command(reactTag, promise) { it.clearWindFields() } + @ReactMethod override fun clearFields(reactTag: Double, promise: Promise) = command(reactTag, promise) { it.clearFields() } /** Binary ingress is a local file reference, never a JS number array or encoded string. */ @ReactMethod @@ -79,6 +79,30 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : catch (error: IllegalStateException) { reject(promise, "NATIVE_REJECTED", error.message ?: "WeatherRuntime rejected the field") } } } + + /** v2 reads each declared local plane only after every reference and aggregate size is checked. */ + @ReactMethod + override fun submitField( + reactTag: Double, + metadataJson: String, + validityFileUri: String, + scalarFileUri: String, + vectorFileUri: String, + promise: Promise, + ) { + if (metadataJson.isEmpty() || metadataJson.toByteArray(Charsets.UTF_8).size > MAX_METADATA_UTF8_BYTES) { + return reject(promise, "INVALID_ARGUMENT", "metadataJson UTF-8 payload must be non-empty and at most $MAX_METADATA_UTF8_BYTES bytes") + } + val refs = try { resolvePayloadRefs(validityFileUri, scalarFileUri, vectorFileUri) } + catch (error: IllegalArgumentException) { return reject(promise, "PAYLOAD_FILE_UNREADABLE", error.message ?: "payload references are invalid") } + val planes = try { readPayloadRefs(refs) } + catch (error: IOException) { return reject(promise, "PAYLOAD_FILE_UNREADABLE", "payload file could not be read: ${error.message ?: "I/O error"}") } + catch (error: RuntimeException) { return reject(promise, "PAYLOAD_FILE_UNREADABLE", "payload file could not be read") } + withRuntime(reactTag, promise) { runtime -> + try { promise.resolve(runtime.submitField(metadataJson, planes.scalar, planes.vector, planes.validity).name) } + catch (error: IllegalStateException) { reject(promise, "NATIVE_REJECTED", error.message ?: "WeatherRuntime rejected the field") } + } + } @ReactMethod override fun setWeatherEnabled(reactTag: Double, enabled: Boolean, promise: Promise) = command(reactTag, promise) { it.setWeatherEnabled(enabled) } @ReactMethod override fun setWeatherColorVisible(reactTag: Double, visible: Boolean, promise: Promise) = command(reactTag, promise) { it.setWeatherColorVisible(visible) } @ReactMethod override fun setParticleVisible(reactTag: Double, visible: Boolean, promise: Promise) = command(reactTag, promise) { it.setParticleVisible(visible) } @@ -103,6 +127,14 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : @ReactMethod override fun setParticleOpacity(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setParticleOpacity(valid) } @ReactMethod override fun setTrailOpacity(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setTrailOpacity(valid) } @ReactMethod override fun setTrailFade(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setTrailFade(valid) } + @ReactMethod + override fun setParticleAdvectionScale(reactTag: Double, scale: Double, promise: Promise) { + if (!scale.isFinite() || scale <= 0.0) return reject(promise, "INVALID_ARGUMENT", "advectionScale must be finite and > 0") + command(reactTag, promise) { it.setParticleAdvectionScale(scale.toFloat()) } + } + @ReactMethod + override fun setFieldValueSource(reactTag: Double, source: String, promise: Promise) = + enumCommand(reactTag, promise, source, WeatherRuntime.FieldValueSource.values()) { runtime, value -> runtime.setFieldValueSource(value) } @ReactMethod override fun setWeatherColorOpacity(reactTag: Double, value: Double, promise: Promise) = floatCommand(reactTag, value, promise) { runtime, valid -> runtime.setWeatherColorOpacity(valid) } @ReactMethod override fun setParticleColor(reactTag: Double, red: Double, green: Double, blue: Double, alpha: Double, promise: Promise) = colorCommand(reactTag, red, green, blue, alpha, promise) { runtime, r, g, b, a -> runtime.setParticleColor(r, g, b, a) } @ReactMethod override fun setTrailColor(reactTag: Double, red: Double, green: Double, blue: Double, alpha: Double, promise: Promise) = colorCommand(reactTag, red, green, blue, alpha, promise) { runtime, r, g, b, a -> runtime.setTrailColor(r, g, b, a) } @@ -145,6 +177,10 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : command(reactTag, promise) { fn(it, value) } } private fun readLocalPayload(reference: String): ByteArray { + val file = resolveLocalFile(reference) + return file.inputStream().use { readBounded(it, file.length()) } + } + private fun resolveLocalFile(reference: String): File { if (reference.isBlank()) throw IllegalArgumentException("payload file reference is empty") val uri = Uri.parse(reference) if (uri.scheme != null && uri.scheme != "file") throw IllegalArgumentException("payload file reference must use file:// or an absolute local path") @@ -153,17 +189,41 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : } if (!file.isFile || !file.canRead()) throw IllegalArgumentException("payload file is not a readable local file") if (file.length() <= 0L || file.length() > MAX_PAYLOAD_BYTES) throw IllegalArgumentException("payload file size must be in 1..$MAX_PAYLOAD_BYTES bytes") - return file.inputStream().use(::readBounded) + return file } - private fun readBounded(input: InputStream): ByteArray { + private data class PayloadRefs(val scalar: File?, val vector: File?, val validity: File) + private data class PayloadPlanes(val scalar: ByteArray?, val vector: ByteArray?, val validity: ByteArray) + private fun resolvePayloadRefs(validityFileUri: String, scalarFileUri: String, vectorFileUri: String): PayloadRefs { + val validity = resolveLocalFile(validityFileUri) + // Empty strings are the frozen TurboModule ABI markers for absent optional planes. + val scalar = scalarFileUri.takeIf { it.isNotEmpty() }?.let(::resolveLocalFile) + val vector = vectorFileUri.takeIf { it.isNotEmpty() }?.let(::resolveLocalFile) + if (scalar == null && vector == null) throw IllegalArgumentException("payloadRefs requires scalarFileUri or vectorFileUri") + var total = 0L + for (file in listOfNotNull(scalar, vector, validity)) { + if (file.length() > MAX_PAYLOAD_BYTES - total) throw IllegalArgumentException("aggregate payload size must not exceed $MAX_PAYLOAD_BYTES bytes") + total += file.length() + } + return PayloadRefs(scalar, vector, validity) + } + private fun readPayloadRefs(refs: PayloadRefs): PayloadPlanes { + val scalar = refs.scalar + val vector = refs.vector + return PayloadPlanes( + scalar?.inputStream()?.use { readBounded(it, scalar.length()) }, + vector?.inputStream()?.use { readBounded(it, vector.length()) }, + refs.validity.inputStream().use { readBounded(it, refs.validity.length()) }, + ) + } + private fun readBounded(input: InputStream, expectedLength: Long): ByteArray { val output = ByteArrayOutputStream(); val buffer = ByteArray(DEFAULT_READ_BUFFER_BYTES) while (true) { val count = input.read(buffer) if (count < 0) break - if (output.size().toLong() + count > MAX_PAYLOAD_BYTES) throw IllegalArgumentException("payload file exceeds $MAX_PAYLOAD_BYTES bytes") + if (output.size().toLong() + count > expectedLength || output.size().toLong() + count > MAX_PAYLOAD_BYTES) throw IllegalArgumentException("payload file changed while being read") output.write(buffer, 0, count) } - return output.toByteArray() + return output.toByteArray().also { if (it.size.toLong() != expectedLength) throw IllegalArgumentException("payload file changed while being read") } } private fun withRuntime(reactTag: Double, promise: Promise, fn: (WeatherRuntime) -> Unit) = withMap(reactTag, promise) { map -> val runtime = map.weatherRuntime diff --git a/packages/maplibre-particle-rn/package.json b/packages/maplibre-particle-rn/package.json index 8ca4f6ef6..f0e4ad9ae 100644 --- a/packages/maplibre-particle-rn/package.json +++ b/packages/maplibre-particle-rn/package.json @@ -1,6 +1,6 @@ { "name": "@navsea/maplibre-particle-rn", - "version": "13.4.1-particle.2", + "version": "13.4.1-particle.3", "description": "NavSea MapLibre Particle React Native pure Field Renderer API.", "license": "BSD-2-Clause", "main": "src/index.ts", diff --git a/packages/maplibre-particle-rn/src/FieldRuntime.ts b/packages/maplibre-particle-rn/src/FieldRuntime.ts index 485d483d1..9a7c46627 100644 --- a/packages/maplibre-particle-rn/src/FieldRuntime.ts +++ b/packages/maplibre-particle-rn/src/FieldRuntime.ts @@ -1,5 +1,5 @@ import { getNativeFieldRuntimeBridge } from "./NativeWeatherRuntimeBridge"; -import type { ColorChannel, CommandResult, FieldColorStop, FieldSubmitResult, PlaybackState, RuntimeSnapshot, TargetFps } from "./types"; +import type { ColorChannel, CommandResult, FieldColorStop, FieldPayloadRefs, FieldSubmitResult, FieldValueSource, PlaybackState, RuntimeSnapshot, TargetFps } from "./types"; function nativeBridge() { const bridge = getNativeFieldRuntimeBridge(); @@ -12,8 +12,22 @@ export const FieldRuntime = { attach: (reactTag: number): Promise => nativeBridge().attach(reactTag), detach: (reactTag: number): Promise => nativeBridge().detach(reactTag), getState: (reactTag: number): Promise => nativeBridge().getState(reactTag), - clearFields: (reactTag: number): Promise => nativeBridge().clearWindFields(reactTag), - submitField: (reactTag: number, metadataJson: string, payloadFileUri: string): Promise => nativeBridge().submitWindField(reactTag, metadataJson, payloadFileUri), + clearFields: (reactTag: number): Promise => nativeBridge().clearFields(reactTag), + submitField: (reactTag: number, metadataJson: string, payloadRefs: FieldPayloadRefs): Promise => + nativeBridge().submitField( + reactTag, + metadataJson, + payloadRefs.validityFileUri, + payloadRefs.scalarFileUri ?? "", + payloadRefs.vectorFileUri ?? "", + ), + /** Explicit v1 compatibility only; v2 submitField always receives FieldPayloadRefs. */ + submitLegacyVectorField: (reactTag: number, metadataJson: string, payloadFileUri: string): Promise => nativeBridge().submitWindField(reactTag, metadataJson, payloadFileUri), + setFieldValueSource: (reactTag: number, source: FieldValueSource): Promise => nativeBridge().setFieldValueSource(reactTag, source), + setParticleAdvectionScale: (reactTag: number, scale: number): Promise => { + if (!Number.isFinite(scale) || scale <= 0) return Promise.reject(new Error("advectionScale must be finite and > 0")); + return nativeBridge().setParticleAdvectionScale(reactTag, scale); + }, setEnabled: (reactTag: number, enabled: boolean): Promise => nativeBridge().setWeatherEnabled(reactTag, enabled), setFieldVisible: (reactTag: number, visible: boolean): Promise => nativeBridge().setWeatherColorVisible(reactTag, visible), setParticleVisible: (reactTag: number, visible: boolean): Promise => nativeBridge().setParticleVisible(reactTag, visible), diff --git a/packages/maplibre-particle-rn/src/NativeWeatherRuntimeBridge.ts b/packages/maplibre-particle-rn/src/NativeWeatherRuntimeBridge.ts index 4ad86516e..1387468fd 100644 --- a/packages/maplibre-particle-rn/src/NativeWeatherRuntimeBridge.ts +++ b/packages/maplibre-particle-rn/src/NativeWeatherRuntimeBridge.ts @@ -8,13 +8,18 @@ type CommandResult = "ACCEPTED" | "REJECTED"; type FieldSubmitResult = "REJECTED_SOURCE_MODE" | "REJECTED_ADAPTER" | "REJECTED_INCOMPATIBLE" | "REJECTED_NON_MONOTONIC" | "REJECTED_CAPACITY" | "ACCEPTED_PENDING_INITIAL_WINDOW" | "INITIAL_WINDOW_READY" | "ACCEPTED_NEXT_SNAPSHOT" | "DUPLICATE" | "UNKNOWN"; type ColorStop = { speedMps: number; red: number; green: number; blue: number; alpha: number }; type RuntimeSnapshot = { state: RuntimeState; playbackState: PlaybackState; targetFps: TargetFps; disposed: boolean }; - export interface Spec extends TurboModule { attach(reactTag: number): Promise; detach(reactTag: number): Promise; getState(reactTag: number): Promise; clearWindFields(reactTag: number): Promise; submitWindField(reactTag: number, metadataJson: string, payloadFileUri: string): Promise; + clearFields(reactTag: number): Promise; + // Keep the codegen ABI to primitive parameters. Empty scalar/vector strings are explicit + // optional-plane markers at this boundary only; the public API keeps FieldPayloadRefs. + submitField(reactTag: number, metadataJson: string, validityFileUri: string, scalarFileUri: string, vectorFileUri: string): Promise; + setFieldValueSource(reactTag: number, source: "SCALAR" | "VECTOR_MAGNITUDE"): Promise; + setParticleAdvectionScale(reactTag: number, scale: number): Promise; setWeatherEnabled(reactTag: number, enabled: boolean): Promise; setWeatherColorVisible(reactTag: number, visible: boolean): Promise; setParticleVisible(reactTag: number, visible: boolean): Promise; diff --git a/packages/maplibre-particle-rn/src/index.ts b/packages/maplibre-particle-rn/src/index.ts index 66225b018..a2dd4d4a0 100644 --- a/packages/maplibre-particle-rn/src/index.ts +++ b/packages/maplibre-particle-rn/src/index.ts @@ -3,7 +3,9 @@ export type { ColorChannel, CommandResult, FieldColorStop, + FieldPayloadRefs, FieldSubmitResult, + FieldValueSource, PlaybackState, RuntimeSnapshot, RuntimeState, diff --git a/packages/maplibre-particle-rn/src/types.ts b/packages/maplibre-particle-rn/src/types.ts index 6a9dd268d..38f21b6de 100644 --- a/packages/maplibre-particle-rn/src/types.ts +++ b/packages/maplibre-particle-rn/src/types.ts @@ -3,6 +3,13 @@ export type PlaybackState = "PLAY" | "PAUSE"; export type TargetFps = "AUTO" | "FPS_30" | "FPS_60"; export type CommandResult = "ACCEPTED" | "REJECTED"; export type FieldSubmitResult = "REJECTED_SOURCE_MODE" | "REJECTED_ADAPTER" | "REJECTED_INCOMPATIBLE" | "REJECTED_NON_MONOTONIC" | "REJECTED_CAPACITY" | "ACCEPTED_PENDING_INITIAL_WINDOW" | "INITIAL_WINDOW_READY" | "ACCEPTED_NEXT_SNAPSHOT" | "DUPLICATE" | "UNKNOWN"; +export type FieldPayloadRefs = Readonly< + { validityFileUri: string } & ( + | { scalarFileUri: string; vectorFileUri?: string } + | { scalarFileUri?: string; vectorFileUri: string } + ) +>; +export type FieldValueSource = "SCALAR" | "VECTOR_MAGNITUDE"; export type ColorChannel = number; export type FieldColorStop = Readonly<{ value: number; red: ColorChannel; green: ColorChannel; blue: ColorChannel; alpha: ColorChannel }>; export type RuntimeSnapshot = Readonly<{ state: RuntimeState; playbackState: PlaybackState; targetFps: TargetFps; disposed: boolean }>; diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp index d4eba269d..723edda81 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp @@ -1124,10 +1124,21 @@ jni::jboolean NativeMapView::setStyleLayerFloatProperty(JNIEnv& env, auto* layer = map->getStyle().getLayer(jni::Make(env, layerId)); if (!layer) return false; const auto property = jni::Make(env, propertyName); + if (property == "weather-color-field-value-source") { + auto* weatherColorLayer = dynamic_cast(layer); + if (!weatherColorLayer || (value != 0.0f && value != 1.0f)) { + mbgl::Log::Error(mbgl::Event::JNI, "ParticleField: event=FIELD_VALUE_SOURCE_SET result=REJECTED"); + return false; + } + weatherColorLayer->setFieldValueSource(value == 0.0f ? mbgl::style::FieldValueSource::Scalar + : mbgl::style::FieldValueSource::VectorMagnitude); + return true; + } if (property != "raster-particle-time-mix" && property != "raster-particle-particle-count" && property != "raster-particle-line-width" && property != "raster-particle-particle-length" && property != "raster-particle-particle-opacity" && property != "raster-particle-trail-opacity" && - property != "raster-particle-trail-fade" && property != "raster-particle-speed-color-enabled") { + property != "raster-particle-trail-fade" && property != "raster-particle-speed-color-enabled" && + property != "raster-particle-advection-scale") { mbgl::Log::Error(mbgl::Event::JNI, "ParticleGPU: event=STYLE_PROPERTY_SET result=UNSUPPORTED_PROPERTY"); return false; } @@ -1163,6 +1174,9 @@ jni::jboolean NativeMapView::setStyleLayerFloatProperty(JNIEnv& env, particleLayer->setTrailOpacity(value); } else if (property == "raster-particle-trail-fade") { particleLayer->setTrailFade(value); + } else if (property == "raster-particle-advection-scale") { + if (value <= 0.0f) return false; + particleLayer->setAdvectionScale(value); } else { if (value != 0.0f && value != 1.0f) { mbgl::Log::Error(mbgl::Event::JNI, "ParticleGPU: event=STYLE_PROPERTY_SET result=INVALID_COLOR_MODE"); @@ -1327,6 +1341,51 @@ jni::jint NativeMapView::submitRasterArraySourceHostField(JNIEnv& env, return static_cast(result); } +jni::jint NativeMapView::submitRasterArraySourceHostFieldV2(JNIEnv& env, + const jni::String& sourceId, + const jni::String& metadata, + const jni::Array& packedPlanes, + const jni::jboolean scalarPresent, + const jni::jint scalarLength, + const jni::jboolean vectorPresent, + const jni::jint vectorLength, + const jni::jint validityLength) { + const auto id = jni::Make(env, sourceId); + auto* source = dynamic_cast(map->getStyle().getSource(id)); + if (!source) return static_cast(mbgl::style::RasterArrayHostFieldSubmitResult::RejectedSourceMode); + NullCheck(env, &packedPlanes); + if (scalarLength < 0 || vectorLength < 0 || validityLength <= 0 || (!scalarPresent && scalarLength != 0) || + (!vectorPresent && vectorLength != 0) || (scalarPresent && scalarLength == 0) || + (vectorPresent && vectorLength == 0) || (!scalarPresent && !vectorPresent)) { + return static_cast(mbgl::style::RasterArrayHostFieldSubmitResult::RejectedAdapter); + } + const uint64_t total = static_cast(scalarLength) + static_cast(vectorLength) + + static_cast(validityLength); + if (total != packedPlanes.Length(env)) { + return static_cast(mbgl::style::RasterArrayHostFieldSubmitResult::RejectedAdapter); + } + std::vector packed(total); + if (!packed.empty()) jni::GetArrayRegion(env, *packedPlanes, 0, packed.size(), reinterpret_cast(packed.data())); + size_t offset = 0; + const auto take = [&packed, &offset](const size_t length) { + std::vector result(packed.begin() + static_cast(offset), + packed.begin() + static_cast(offset + length)); + offset += length; + return result; + }; + mbgl::particle::ParticleFieldPayloadBundle payloads{ + .scalar = scalarPresent ? std::optional>(take(static_cast(scalarLength))) : std::nullopt, + .vector = vectorPresent ? std::optional>(take(static_cast(vectorLength))) : std::nullopt, + .validity = take(static_cast(validityLength)), + }; + const auto result = source->submitHostField(jni::Make(env, metadata), payloads); + mbgl::Log::Info(mbgl::Event::JNI, + "ParticleField: event=HOST_FIELD_V2_SUBMIT_BRIDGE sourceId=" + id + " scalar=" + + (scalarPresent ? "present" : "absent") + " vector=" + (vectorPresent ? "present" : "absent") + + " result=" + mbgl::style::rasterArrayHostFieldSubmitResultName(result)); + return static_cast(result); +} + jni::jboolean NativeMapView::clearRasterArraySourceHostFields(JNIEnv& env, const jni::String& sourceId) { const auto id = jni::Make(env, sourceId); auto* source = dynamic_cast(map->getStyle().getSource(id)); @@ -1761,6 +1820,7 @@ void NativeMapView::registerNative(jni::JNIEnv& env) { METHOD(&NativeMapView::advanceRasterArraySourceFieldWindow, "nativeAdvanceRasterArraySourceFieldWindow"), METHOD(&NativeMapView::repeatRasterArraySourceFieldWindow, "nativeRepeatRasterArraySourceFieldWindow"), METHOD(&NativeMapView::submitRasterArraySourceHostField, "nativeSubmitRasterArraySourceHostField"), + METHOD(&NativeMapView::submitRasterArraySourceHostFieldV2, "nativeSubmitRasterArraySourceHostFieldV2"), METHOD(&NativeMapView::clearRasterArraySourceHostFields, "nativeClearRasterArraySourceHostFields"), METHOD(&NativeMapView::seekRasterArraySourceTimelineToTimestamp, "nativeSeekRasterArraySourceTimelineToTimestamp"), METHOD(&NativeMapView::controlRasterArraySourceTimeline, "nativeControlRasterArraySourceTimeline"), diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp index 6f4a3437d..c9002320c 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp @@ -298,6 +298,15 @@ public: const jni::String&, const jni::String&, const jni::Array&); + jni::jint submitRasterArraySourceHostFieldV2(JNIEnv&, + const jni::String&, + const jni::String&, + const jni::Array&, + jni::jboolean, + jni::jint, + jni::jboolean, + jni::jint, + jni::jint); jni::jboolean clearRasterArraySourceHostFields(JNIEnv&, const jni::String&); jni::jboolean seekRasterArraySourceTimelineToTimestamp(JNIEnv&, const jni::String&, jni::jlong); jni::jboolean controlRasterArraySourceTimeline(JNIEnv&, const jni::String&, jni::jint, jni::jfloat); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java index 72d2acb63..ba5c58a13 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java @@ -402,6 +402,14 @@ public final class MapLibreMap { return nativeMapView.submitRasterArraySourceHostField(sourceId, metadata, payload); } + /** Explicit v2 ingress: packed bytes are split by presence flags and lengths in JNI, never in JS. */ + public int submitRasterArraySourceHostFieldV2(String sourceId, String metadata, byte[] packedPlanes, + boolean scalarPresent, int scalarLength, + boolean vectorPresent, int vectorLength, int validityLength) { + return nativeMapView.submitRasterArraySourceHostFieldV2(sourceId, metadata, packedPlanes, scalarPresent, + scalarLength, vectorPresent, vectorLength, validityLength); + } + /** Clears Host-owned canonical fields while retaining the raster-array Source and Style layers. */ public boolean clearRasterArraySourceHostFields(String sourceId) { return nativeMapView.clearRasterArraySourceHostFields(sourceId); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java index d295df1c5..e3c868a9f 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java @@ -167,6 +167,10 @@ interface NativeMap { int submitRasterArraySourceHostField(String sourceId, String metadata, byte[] payload); + int submitRasterArraySourceHostFieldV2(String sourceId, String metadata, byte[] packedPlanes, + boolean scalarPresent, int scalarLength, + boolean vectorPresent, int vectorLength, int validityLength); + boolean clearRasterArraySourceHostFields(String sourceId); boolean seekRasterArraySourceTimelineToTimestamp(String sourceId, long timestamp); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java index 41edd33aa..4df639ed7 100755 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java @@ -1019,6 +1019,16 @@ final class NativeMapView implements NativeMap { : nativeSubmitRasterArraySourceHostField(sourceId, metadata, payload); } + @Override + public int submitRasterArraySourceHostFieldV2(String sourceId, String metadata, byte[] packedPlanes, + boolean scalarPresent, int scalarLength, + boolean vectorPresent, int vectorLength, int validityLength) { + return checkState("submitRasterArraySourceHostFieldV2") + ? MapLibreMap.HOST_FIELD_REJECTED_SOURCE_MODE + : nativeSubmitRasterArraySourceHostFieldV2(sourceId, metadata, packedPlanes, scalarPresent, scalarLength, + vectorPresent, vectorLength, validityLength); + } + @Override public boolean clearRasterArraySourceHostFields(String sourceId) { return !checkState("clearRasterArraySourceHostFields") @@ -1799,6 +1809,11 @@ final class NativeMapView implements NativeMap { @Keep private native int nativeSubmitRasterArraySourceHostField(String sourceId, String metadata, byte[] payload); + @Keep + private native int nativeSubmitRasterArraySourceHostFieldV2(String sourceId, String metadata, byte[] packedPlanes, + boolean scalarPresent, int scalarLength, + boolean vectorPresent, int vectorLength, int validityLength); + @Keep private native boolean nativeClearRasterArraySourceHostFields(String sourceId); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java index 893d951b0..6237c5fc5 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java @@ -1,6 +1,7 @@ package org.maplibre.android.maps; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import android.os.Handler; import android.os.Looper; @@ -12,6 +13,7 @@ import java.util.List; * It owns no provider, network, route, ETA, Snapshot, T0/T1, or GPU state. */ public final class WeatherRuntime { + private static final long MAX_FIELD_PAYLOAD_BYTES = 64L * 1024L * 1024L; public enum State { DISABLED, WAITING_FOR_FIELDS, READY, ERROR } public enum PlaybackState { PLAY, PAUSE } public enum PlaybackSpeed { HALF(0.5f), NORMAL(1.0f), DOUBLE(2.0f); final float value; PlaybackSpeed(float value) { this.value = value; } } @@ -76,7 +78,27 @@ public final class WeatherRuntime { if (state == State.READY && result.accepted()) scheduleCadenceWake(); return result; } - public CommandResult clearWindFields() { if (disposed || state == State.DISABLED) return CommandResult.REJECTED; boolean ok = map.clearRasterArraySourceHostFields(config.sourceId); state = stateAfterClear(state, ok); if (ok) cancelCadenceWake(); return result(ok); } + /** v2 provider-neutral ingress; missing scalar/vector is expressed only by its explicit presence flag. */ + public FieldSubmitResult submitField(@NonNull String metadataJson, @Nullable byte[] scalarBytes, @Nullable byte[] vectorBytes, @NonNull byte[] validityBytes) { + requireAttached(); requireEnabled(); + if (metadataJson.isEmpty() || validityBytes.length == 0 || (scalarBytes == null && vectorBytes == null)) return FieldSubmitResult.REJECTED_ADAPTER; + final int scalarLength = scalarBytes == null ? 0 : scalarBytes.length; + final int vectorLength = vectorBytes == null ? 0 : vectorBytes.length; + if ((scalarBytes != null && scalarLength == 0) || (vectorBytes != null && vectorLength == 0)) return FieldSubmitResult.REJECTED_ADAPTER; + final long total = (long) scalarLength + vectorLength + validityBytes.length; + // Do not rely on Kotlin's file-reference guard: Java callers can bypass it. + if (total > MAX_FIELD_PAYLOAD_BYTES || total > Integer.MAX_VALUE) return FieldSubmitResult.REJECTED_ADAPTER; + final byte[] packed = new byte[(int) total]; int offset = 0; + if (scalarBytes != null) { System.arraycopy(scalarBytes, 0, packed, offset, scalarLength); offset += scalarLength; } + if (vectorBytes != null) { System.arraycopy(vectorBytes, 0, packed, offset, vectorLength); offset += vectorLength; } + System.arraycopy(validityBytes, 0, packed, offset, validityBytes.length); + FieldSubmitResult result = FieldSubmitResult.fromCode(map.submitRasterArraySourceHostFieldV2(config.sourceId, metadataJson, + packed, scalarBytes != null, scalarLength, vectorBytes != null, vectorLength, validityBytes.length)); + state = stateAfterSubmit(state, result); if (state == State.READY && result.accepted()) scheduleCadenceWake(); return result; + } + public CommandResult clearFields() { if (disposed || state == State.DISABLED) return CommandResult.REJECTED; boolean ok = map.clearRasterArraySourceHostFields(config.sourceId); state = stateAfterClear(state, ok); if (ok) cancelCadenceWake(); return result(ok); } + /** @deprecated v1 compatibility alias. */ + public CommandResult clearWindFields() { return clearFields(); } public CommandResult setWeatherEnabled(boolean enabled) { if (disposed) return CommandResult.REJECTED; if (!enabled) { @@ -108,6 +130,11 @@ public final class WeatherRuntime { public CommandResult setParticleOpacity(float opacity) { return setParticleFloat("raster-particle-particle-opacity", opacity); } public CommandResult setTrailOpacity(float opacity) { return setParticleFloat("raster-particle-trail-opacity", opacity); } public CommandResult setTrailFade(float fade) { return setParticleFloat("raster-particle-trail-fade", fade); } + public CommandResult setParticleAdvectionScale(float scale) { return disposed || !Float.isFinite(scale) || scale <= 0f ? CommandResult.REJECTED : setParticleFloat("raster-particle-advection-scale", scale); } + public CommandResult setFieldValueSource(@NonNull FieldValueSource source) { + if (disposed) return CommandResult.REJECTED; + return result(map.setStyleLayerFloatProperty(config.weatherColorLayerId, "weather-color-field-value-source", source.code)); + } public CommandResult setParticleSpeedColorEnabled(boolean enabled) { return setParticleFloat("raster-particle-speed-color-enabled", enabled ? 1f : 0f); } /** Sets only the particle RGBA; trail color and WeatherColor ramp are unchanged. */ public CommandResult setParticleColor(float red, float green, float blue, float alpha) { @@ -143,6 +170,7 @@ public final class WeatherRuntime { return result(map.setWeatherColorStops(config.weatherColorLayerId, speeds, colors)); } private static CommandResult result(boolean accepted) { return accepted ? CommandResult.ACCEPTED : CommandResult.REJECTED; } + public enum FieldValueSource { SCALAR(0f), VECTOR_MAGNITUDE(1f); final float code; FieldValueSource(float code) { this.code = code; } } private boolean shouldWakeWeather() { return !disposed && targetFps != TargetFps.AUTO && state == State.READY && (particleVisible || (weatherColorVisible && playbackState == PlaybackState.PLAY)); } diff --git a/src/mbgl/style/sources/raster_array_source.cpp b/src/mbgl/style/sources/raster_array_source.cpp index 8b6812141..d77a669ab 100644 --- a/src/mbgl/style/sources/raster_array_source.cpp +++ b/src/mbgl/style/sources/raster_array_source.cpp @@ -739,13 +739,44 @@ RasterArrayHostFieldSubmitResult RasterArraySource::submitHostField(const std::s return RasterArrayHostFieldSubmitResult::RejectedAdapter; } - auto snapshot = std::move(*decoded.snapshot); + Log::Info(Event::Render, + "ParticleField: event=HOST_FIELD_LEGACY_VECTOR_ONLY sourceId=" + getID() + + " payloadBytes=" + std::to_string(payload.size())); + return submitDecodedHostField(std::move(*decoded.snapshot)); +} + +RasterArrayHostFieldSubmitResult RasterArraySource::submitHostField( + const std::string& metadataText, + const particle::ParticleFieldPayloadBundle& payloads) { + const auto bytes = [](const std::optional>& plane) { + return plane ? std::to_string(plane->size()) : std::string("absent"); + }; + Log::Info(Event::Render, + "ParticleField: event=HOST_FIELD_V2_SUBMIT_REQUEST sourceId=" + getID() + " metadataBytes=" + + std::to_string(metadataText.size()) + " scalarBytes=" + bytes(payloads.scalar) + + " vectorBytes=" + bytes(payloads.vector) + " validityBytes=" + bytes(payloads.validity)); + if (!impl().hostFieldSubmission) { + return RasterArrayHostFieldSubmitResult::RejectedSourceMode; + } + auto decoded = particle::LocalParticleFieldFeed::load(metadataText, payloads); + if (!decoded) { + Log::Warning(Event::Render, + "ParticleField: event=HOST_FIELD_V2_SUBMIT_REJECTED sourceId=" + getID() + + " result=REJECTED_ADAPTER errorCode=" + particle::particleFieldAdapterErrorName(decoded.error) + + " error=" + decoded.message); + return RasterArrayHostFieldSubmitResult::RejectedAdapter; + } + return submitDecodedHostField(std::move(*decoded.snapshot)); +} + +RasterArrayHostFieldSubmitResult RasterArraySource::submitDecodedHostField(particle::ParticleFieldSnapshot snapshot) { Log::Info(Event::Render, "ParticleField: event=HOST_FIELD_SNAPSHOT_READY sourceId=" + getID() + " fieldId=" + snapshot.fieldID + " generation=" + std::to_string(snapshot.generation) + " validTime=" + std::to_string(snapshot.validTime) + " width=" + std::to_string(snapshot.width) + - " height=" + std::to_string(snapshot.height) + - " layout=interleaved-uv encoding=float32-le payloadBytes=" + std::to_string(payload.size())); + " height=" + std::to_string(snapshot.height) + " scalar=" + + (snapshot.hasScalar() ? "present" : "absent") + " vector=" + + (snapshot.hasVector() ? "present" : "absent") + " validity=present"); std::shared_ptr previous; if (timeline) { diff --git a/test/style/source.test.cpp b/test/style/source.test.cpp index 214cffaa4..069aa2558 100644 --- a/test/style/source.test.cpp +++ b/test/style/source.test.cpp @@ -94,6 +94,23 @@ particle::ParticleFieldPayloadBundle v2Payload(std::optional> return {.scalar = std::move(scalar), .vector = std::move(vector), .validity = std::move(validity)}; } +std::string v2HostMetadata(const int index, const bool scalar, const bool vector, const char* semantics = "VELOCITY") { + std::string result = "{\"schema\":\"maplibre-particle-field/v2\",\"fieldId\":\"v2-host-" + + std::to_string(index) + "\",\"generation\":" + std::to_string(index + 1) + + ",\"validTime\":" + std::to_string(100 + index) + + ",\"grid\":{\"width\":2,\"height\":1},\"bounds\":{\"west\":0,\"south\":0,\"east\":1,\"north\":1},\"validity\":{\"encoding\":\"uint8\",\"byteLength\":2}"; + if (scalar) result += ",\"scalar\":{\"encoding\":\"float32-le\",\"byteLength\":8,\"noData\":-9999}"; + if (vector) result += std::string(",\"vector\":{\"layout\":\"interleaved-uv\",\"encoding\":\"float32-le\",\"byteLength\":16,\"noData\":-9999,\"semantics\":\"") + semantics + "\"}"; + return result + "}"; +} + +std::string v2LegacyCompatibleHostMetadata(const int index) { + return "{\"schema\":\"maplibre-particle-field/v2\",\"fieldId\":\"v2-compatible-t" + + std::to_string(index) + "\",\"generation\":" + std::to_string(index + 1) + + ",\"validTime\":" + std::to_string(1704067200000ULL + index * 3600000ULL) + + ",\"grid\":{\"width\":16,\"height\":16},\"bounds\":{\"west\":-180,\"south\":-85,\"east\":180,\"north\":85},\"validity\":{\"encoding\":\"uint8\",\"byteLength\":256},\"vector\":{\"layout\":\"interleaved-uv\",\"encoding\":\"float32-le\",\"byteLength\":2048,\"noData\":-9999,\"semantics\":\"VELOCITY\"}}"; +} + } // namespace using namespace mbgl; @@ -545,6 +562,73 @@ TEST(Source, HostFieldSubmissionBuildsOneCoreOwnedTimeline) { EXPECT_EQ(source.impl().timeline->frame().t0Index, 2u); } +TEST(Source, HostFieldV2BundleUsesTheSameTimelineIngress) { + SourceTest test; + style::RasterArraySource source{"host-submission"}; + source.setObserver(&test.styleObserver); + source.loadDescription(*test.fileSource); + const auto valid = std::vector{1, 1}; + const auto scalarT0 = floatBytes({2.0f, 3.0f}); + const auto scalarT1 = floatBytes({2.5f, 3.5f}); + const auto vectorT0 = floatBytes({1.0f, 0.0f, 0.0f, 1.0f}); + const auto vectorT1 = floatBytes({0.5f, 0.0f, 0.0f, 1.0f}); + + EXPECT_EQ(source.submitHostField(v2HostMetadata(0, true, false), v2Payload(scalarT0, std::nullopt, valid)), + style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow); + EXPECT_EQ(source.submitHostField(v2HostMetadata(1, true, false), v2Payload(scalarT1, std::nullopt, valid)), + style::RasterArrayHostFieldSubmitResult::InitialWindowReady); + EXPECT_TRUE(source.impl().field->hasScalar()); + EXPECT_FALSE(source.impl().field->hasVector()); + + EXPECT_TRUE(source.clearHostFields()); + EXPECT_EQ(source.submitHostField(v2HostMetadata(2, false, true), v2Payload(std::nullopt, vectorT0, valid)), + style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow); + EXPECT_EQ(source.submitHostField(v2HostMetadata(3, false, true), v2Payload(std::nullopt, vectorT1, valid)), + style::RasterArrayHostFieldSubmitResult::InitialWindowReady); + EXPECT_TRUE(source.impl().field->hasVector()); + EXPECT_EQ(source.impl().field->vectorSemantics, particle::VectorSemantics::Velocity); + + EXPECT_TRUE(source.clearHostFields()); + EXPECT_EQ(source.submitHostField(v2HostMetadata(4, true, true, "DIRECTION"), v2Payload(scalarT0, vectorT0, valid)), + style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow); + EXPECT_EQ(source.submitHostField(v2HostMetadata(5, true, true, "DIRECTION"), v2Payload(scalarT1, vectorT1, valid)), + style::RasterArrayHostFieldSubmitResult::InitialWindowReady); + EXPECT_EQ(source.impl().field->vectorSemantics, particle::VectorSemantics::Direction); + EXPECT_EQ(source.submitHostField(v2HostMetadata(6, true, true), v2Payload(scalarT1, vectorT1, valid)), + style::RasterArrayHostFieldSubmitResult::RejectedIncompatibleSnapshot); + + EXPECT_TRUE(source.clearHostFields()); + EXPECT_EQ(source.submitHostField(v2HostMetadata(7, false, true), + particle::ParticleFieldPayloadBundle{.scalar = std::nullopt, + .vector = vectorT0, + .validity = std::nullopt}), + style::RasterArrayHostFieldSubmitResult::RejectedAdapter); + EXPECT_EQ(source.submitHostField(v2HostMetadata(8, false, false), v2Payload(std::nullopt, std::nullopt, valid)), + style::RasterArrayHostFieldSubmitResult::RejectedAdapter); +} + +TEST(Source, HostFieldLegacyAndCompatibleV2VectorOnlyShareOneTimeline) { + SourceTest test; + style::RasterArraySource source{"host-submission"}; + source.setObserver(&test.styleObserver); + source.loadDescription(*test.fileSource); + const auto legacyBytes = fixtureBytes("uniform-east", "field.f32"); + const std::vector legacyPayload(legacyBytes.begin(), legacyBytes.end()); + const std::vector validity(16 * 16, 1); + const auto northBytes = fixtureBytes("uniform-north", "field.f32"); + const std::vector nextPayload(northBytes.begin(), northBytes.end()); + + EXPECT_EQ(source.submitHostField(localMetadata(0), legacyPayload), + style::RasterArrayHostFieldSubmitResult::AcceptedPendingInitialWindow); + EXPECT_EQ(source.submitHostField(v2LegacyCompatibleHostMetadata(1), + v2Payload(std::nullopt, nextPayload, validity)), + style::RasterArrayHostFieldSubmitResult::InitialWindowReady); + ASSERT_TRUE(source.impl().timeline); + EXPECT_EQ(source.impl().timeline->size(), 2u); + EXPECT_EQ(source.impl().field->vectorSemantics, particle::VectorSemantics::Velocity); + EXPECT_EQ(source.impl().nextField->vectorSemantics, particle::VectorSemantics::Velocity); +} + TEST(Source, HostFieldClearAndTimestampSeekAreFailClosed) { SourceTest test; style::RasterArraySource source{"host-submission"};