feat: add v2 multi-plane field ingress

This commit is contained in:
OpenAI Codex
2026-09-11 09:00:29 +08:00
parent 9e9ea07bc7
commit 148bf80fe4
17 changed files with 368 additions and 20 deletions

View File

@@ -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<char>& 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<Source::Impl> createMutable() const noexcept override;
private:
RasterArrayHostFieldSubmitResult submitDecodedHostField(particle::ParticleFieldSnapshot snapshot);
struct RuntimeFieldCandidate {
RasterArrayRuntimeFieldDescriptor descriptor;
std::unique_ptr<AsyncRequest> metadataRequest;

View File

@@ -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 接口。

View File

@@ -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 或凭证。

View File

@@ -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

View File

@@ -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",

View File

@@ -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<RuntimeSnapshot> => nativeBridge().attach(reactTag),
detach: (reactTag: number): Promise<void> => nativeBridge().detach(reactTag),
getState: (reactTag: number): Promise<RuntimeSnapshot> => nativeBridge().getState(reactTag),
clearFields: (reactTag: number): Promise<CommandResult> => nativeBridge().clearWindFields(reactTag),
submitField: (reactTag: number, metadataJson: string, payloadFileUri: string): Promise<FieldSubmitResult> => nativeBridge().submitWindField(reactTag, metadataJson, payloadFileUri),
clearFields: (reactTag: number): Promise<CommandResult> => nativeBridge().clearFields(reactTag),
submitField: (reactTag: number, metadataJson: string, payloadRefs: FieldPayloadRefs): Promise<FieldSubmitResult> =>
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<FieldSubmitResult> => nativeBridge().submitWindField(reactTag, metadataJson, payloadFileUri),
setFieldValueSource: (reactTag: number, source: FieldValueSource): Promise<CommandResult> => nativeBridge().setFieldValueSource(reactTag, source),
setParticleAdvectionScale: (reactTag: number, scale: number): Promise<CommandResult> => {
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<CommandResult> => nativeBridge().setWeatherEnabled(reactTag, enabled),
setFieldVisible: (reactTag: number, visible: boolean): Promise<CommandResult> => nativeBridge().setWeatherColorVisible(reactTag, visible),
setParticleVisible: (reactTag: number, visible: boolean): Promise<CommandResult> => nativeBridge().setParticleVisible(reactTag, visible),

View File

@@ -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<RuntimeSnapshot>;
detach(reactTag: number): Promise<void>;
getState(reactTag: number): Promise<RuntimeSnapshot>;
clearWindFields(reactTag: number): Promise<CommandResult>;
submitWindField(reactTag: number, metadataJson: string, payloadFileUri: string): Promise<FieldSubmitResult>;
clearFields(reactTag: number): Promise<CommandResult>;
// 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<FieldSubmitResult>;
setFieldValueSource(reactTag: number, source: "SCALAR" | "VECTOR_MAGNITUDE"): Promise<CommandResult>;
setParticleAdvectionScale(reactTag: number, scale: number): Promise<CommandResult>;
setWeatherEnabled(reactTag: number, enabled: boolean): Promise<CommandResult>;
setWeatherColorVisible(reactTag: number, visible: boolean): Promise<CommandResult>;
setParticleVisible(reactTag: number, visible: boolean): Promise<CommandResult>;

View File

@@ -3,7 +3,9 @@ export type {
ColorChannel,
CommandResult,
FieldColorStop,
FieldPayloadRefs,
FieldSubmitResult,
FieldValueSource,
PlaybackState,
RuntimeSnapshot,
RuntimeState,

View File

@@ -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 }>;

View File

@@ -1124,10 +1124,21 @@ jni::jboolean NativeMapView::setStyleLayerFloatProperty(JNIEnv& env,
auto* layer = map->getStyle().getLayer(jni::Make<std::string>(env, layerId));
if (!layer) return false;
const auto property = jni::Make<std::string>(env, propertyName);
if (property == "weather-color-field-value-source") {
auto* weatherColorLayer = dynamic_cast<mbgl::style::WeatherColorLayer*>(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<jni::jint>(result);
}
jni::jint NativeMapView::submitRasterArraySourceHostFieldV2(JNIEnv& env,
const jni::String& sourceId,
const jni::String& metadata,
const jni::Array<jbyte>& 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<std::string>(env, sourceId);
auto* source = dynamic_cast<mbgl::style::RasterArraySource*>(map->getStyle().getSource(id));
if (!source) return static_cast<jni::jint>(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<jni::jint>(mbgl::style::RasterArrayHostFieldSubmitResult::RejectedAdapter);
}
const uint64_t total = static_cast<uint64_t>(scalarLength) + static_cast<uint64_t>(vectorLength) +
static_cast<uint64_t>(validityLength);
if (total != packedPlanes.Length(env)) {
return static_cast<jni::jint>(mbgl::style::RasterArrayHostFieldSubmitResult::RejectedAdapter);
}
std::vector<char> packed(total);
if (!packed.empty()) jni::GetArrayRegion(env, *packedPlanes, 0, packed.size(), reinterpret_cast<jbyte*>(packed.data()));
size_t offset = 0;
const auto take = [&packed, &offset](const size_t length) {
std::vector<char> result(packed.begin() + static_cast<std::ptrdiff_t>(offset),
packed.begin() + static_cast<std::ptrdiff_t>(offset + length));
offset += length;
return result;
};
mbgl::particle::ParticleFieldPayloadBundle payloads{
.scalar = scalarPresent ? std::optional<std::vector<char>>(take(static_cast<size_t>(scalarLength))) : std::nullopt,
.vector = vectorPresent ? std::optional<std::vector<char>>(take(static_cast<size_t>(vectorLength))) : std::nullopt,
.validity = take(static_cast<size_t>(validityLength)),
};
const auto result = source->submitHostField(jni::Make<std::string>(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<jni::jint>(result);
}
jni::jboolean NativeMapView::clearRasterArraySourceHostFields(JNIEnv& env, const jni::String& sourceId) {
const auto id = jni::Make<std::string>(env, sourceId);
auto* source = dynamic_cast<mbgl::style::RasterArraySource*>(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"),

View File

@@ -298,6 +298,15 @@ public:
const jni::String&,
const jni::String&,
const jni::Array<jbyte>&);
jni::jint submitRasterArraySourceHostFieldV2(JNIEnv&,
const jni::String&,
const jni::String&,
const jni::Array<jbyte>&,
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);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<std::vector<char>>& 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<const particle::ParticleFieldSnapshot> previous;
if (timeline) {

View File

@@ -94,6 +94,23 @@ particle::ParticleFieldPayloadBundle v2Payload(std::optional<std::vector<char>>
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<char>{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<char> legacyPayload(legacyBytes.begin(), legacyBytes.end());
const std::vector<char> validity(16 * 16, 1);
const auto northBytes = fixtureBytes("uniform-north", "field.f32");
const std::vector<char> 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"};