Compare commits

...

6 Commits

Author SHA1 Message Date
tei
f47469d2ab test: integrate particle.10 online QBIN host
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-21 20:21:16 +09:00
tei
822663cd5d release: finalize unified particle.10
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-21 19:11:02 +09:00
tei
d5ce2006be fix: validate iOS release slice without pipefail false negative 2026-09-21 18:52:20 +09:00
tei
73bebcf827 release: prepare unified particle.10 2026-09-21 15:52:51 +09:00
tei
3f7ec99f85 fix: preserve canonical color semantics after QBIN decode 2026-09-21 15:52:22 +09:00
tei
a4cb727b16 docs: close STEP-087 particle.9 release
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
2026-09-21 10:02:33 +09:00
30 changed files with 1087 additions and 331 deletions

View File

@@ -2,7 +2,7 @@
## 状态
IMPLEMENTED / MAC VALIDATION IN PROGRESS
CLOSED / PASS
## 已完成
@@ -20,12 +20,15 @@ IMPLEMENTED / MAC VALIDATION IN PROGRESS
真实 8 帧 benchmark:16×16 数值 payload 为 2,470,419 bytes;32×32 为 2,514,288 bytes,32×32 增加约 1.7758%。32×32 的 decode 总时间仅减少约 2.67 ms / 8 帧,因此最终冻结 16×16。详见 `docs/30_设计/MapLibreParticle_QBIN_V3_Decoder与BlockGeometry实施决策_v1.md`。
## 当前验证
## 最终验证与发布
- `verify-qbin-v3-golden-fixtures.py`:PASS(8 files,block=16×16)。
- QBIN V3 C++ 定向 tests:PASS 8 / FAIL 0。
- iOS Metal `MapLibre.dynamic`:当前正在 Mac 专用 worktree 中做正式 Bazel 构建验证;此前仅因 worktree vendor submodule 未展开而在 analysis 阶段失败,已修复 submodule checkout。
## 下一步
完成 iOS Metal 正式构建结果确认后,执行 `git diff --check`、生成完成报告、提交并 push 到 `origin/particle/main`。Android Gradle / APK / ADB 不在本任务自动执行范围内。
- Android Release 四 ABI:armeabi-v7a、arm64-v8a、x86、x86_64 全部构建通过。
- iOS Metal `MapLibre.dynamic`:PASS;device-arm64 XCFramework 已生成并发布。
- RN tgz:已生成并发布。
- Android Maven / RN tgz / iOS XCFramework 公网 readback:PASS。
- SwiftPM `6.28.0-particle.9`:已发布。
- Release tag:`maplibre-particle/rn/13.4.1-particle.9`。
- Release Final HEAD:`63047540cba703c8efeae0df71e759e165315f85`。
- Android APK / ADB 仍按项目规则由用户手工执行,不属于本次自动发布门槛。

View File

@@ -29,9 +29,10 @@ C++ benchmark 使用四类真实 Golden 数据共 8 帧:
- QBIN V3 Golden Fixtures 校验:PASS,8 files,block=16×16。
- C++ 定向测试:PASS 8 / FAIL 0。
- Current / Rain / Wave / Wind V3 解码结果与对应 V2 canonical raw 做逐 plane / validity byte-for-byte 对比:PASS 8 / FAIL 0。
- `git diff --check`:将在提交前执行。
- iOS Metal `MapLibre.dynamic` 正式 Bazel 构建:__IOS_BUILD_RESULT__。
- Android Gradle / APK / ADB:按项目规则未执行,由用户手工完成。
- `git diff --check`:PASS。
- iOS Metal `MapLibre.dynamic` 正式 Bazel 构建:PASS;生成 device-arm64 `MapLibre.dynamic.xcframework.zip`。
- Android 统一 Release 构建:PASS,包含 armeabi-v7a、arm64-v8a、x86、x86_64 四 ABI。
- Android APK / ADB:按项目规则未执行,由用户手工完成。
## 5. 文档与数据
@@ -42,8 +43,15 @@ C++ benchmark 使用四类真实 Golden 数据共 8 帧:
## 6. Git 收尾
- 实现提交:__IMPLEMENTATION_COMMIT__
- 提交信息:__IMPLEMENTATION_MESSAGE__
- Push:__PUSH_RESULT__
- 实现提交:`0e8c87fe49fb45c23a88d8f6cbf79dbc70df393c`
- 实现提交信息:`feat: add QBIN V3 decoder for particle.9`
- 发布 finalize 提交:`63047540cba703c8efeae0df71e759e165315f85`
- 发布 finalize 提交信息:`release: finalize unified particle.9`
- Push:PASS
- 远端分支:`origin/particle/main`
- Final HEAD:__FINAL_HEAD__
- Release Final HEAD / Tag HEAD:`63047540cba703c8efeae0df71e759e165315f85`
- Release Tag:`maplibre-particle/rn/13.4.1-particle.9`
- Android AAR:`6114067c45685e2d20b3dd5115c480a39341177ac8608f86ec431562f649e395`
- RN tgz:`98dc94e88dc69c06c9d99cc8ece3547f0489ba650c4a3d71814eab45e8720dcb`
- iOS XCFramework zip:`fbda91e09d53e3cd1362590bfc0410b6fb5f9fef4cf99796d4ab7b4b84c93562`
- SwiftPM:`6.28.0-particle.9`,public readback PASS。

View File

@@ -5,16 +5,23 @@
#include <optional>
#include <memory>
#include <variant>
namespace mbgl::particle {
// Renderer-neutral canonical snapshots. QBIN stays confined to ingress and
// field Core; renderers receive only the existing canonical field shape.
enum class MaterializedColorValueSource : uint8_t {
Scalar,
VectorMagnitude,
};
struct MaterializedColorProjection {
std::shared_ptr<const ParticleFieldSnapshot> t0;
std::shared_ptr<const ParticleFieldSnapshot> t1;
ColorProjectionContentIdentity contentIdentity;
FieldSampleState sampleState;
MaterializedColorValueSource valueSource = MaterializedColorValueSource::Scalar;
};
struct MaterializedParticleProjection {

View File

@@ -1,2 +1,4 @@
# Expo/RN prebuild output is generated locally and must not enter this source host.
/ios/
/.expo/

View File

@@ -1,192 +1,453 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, findNodeHandle } from "react-native";
import {
Button,
SafeAreaView,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
findNodeHandle,
} from "react-native";
import { Camera, Map } from "@maplibre/maplibre-react-native";
import { FieldRuntime, type FieldColorStop, type RuntimeSnapshot, type TargetFps } from "@navsea/maplibre-particle-rn";
import { fieldDatasets, materializeV3Frame, type V3Dataset } from "./src/fieldDatasets";
import { materializeV4Frame, qbinGoldenDatasets, type V4Dataset } from "./src/qbinGoldenFixtures";
import { FieldRuntime, type RuntimeSnapshot } from "@navsea/maplibre-particle-rn";
import {
materializeV4Frame as materializeV2Frame,
qbinGoldenDatasets,
} from "./src/qbinGoldenFixtures";
import {
materializeV4Frame as materializeV3Frame,
qbinV3GoldenDatasets,
type V4Dataset,
} from "./src/qbinV3GoldenFixtures";
import {
colorStopsFor,
legendFor,
type WeatherDatasetId,
} from "./src/weatherPalettes";
import {
downloadOnlineQbin,
type QbinFormat,
} from "./src/onlineQbin";
import { weatherTestStyle } from "./src/weatherStyle";
const initialSnapshot: RuntimeSnapshot = {
state: "WAITING_FOR_FIELDS",
playbackState: "PAUSE",
targetFps: "AUTO",
disposed: false,
};
const initialSnapshot: RuntimeSnapshot = { state: "WAITING_FOR_FIELDS", playbackState: "PAUSE", targetFps: "AUTO", disposed: false };
const initialVisual = { count: "4096", width: "1.5", length: "0.012", opacity: "0.9", trailOpacity: "0.8", trailFade: "0.94", weatherOpacity: "0.62" };
const frozenStops: FieldColorStop[] = [
{ value: 0, red: 0.08, green: 0.2, blue: 0.8, alpha: 0.72 }, { value: 8, red: 0, green: 0.85, blue: 0.9, alpha: 0.72 },
{ value: 16, red: 1, green: 0.85, blue: 0.05, alpha: 0.72 }, { value: 30, red: 0.9, green: 0.08, blue: 0.08, alpha: 0.72 },
];
const initialVisual = {
count: "4096",
width: "1.8",
length: "0.028",
opacity: "0.85",
trailOpacity: "0.72",
trailFade: "0.975",
weatherOpacity: "1.0",
};
function datasetFor(format: QbinFormat, id: WeatherDatasetId): V4Dataset {
const datasets = format === "V3" ? qbinV3GoldenDatasets : qbinGoldenDatasets;
const found = datasets.find((item) => item.id === id);
if (!found) throw new Error("Dataset missing: " + format + " / " + id);
return found as V4Dataset;
}
function valueSourceFor(id: WeatherDatasetId): "SCALAR" | "VECTOR_MAGNITUDE" {
return id === "wind" || id === "current" ? "VECTOR_MAGNITUDE" : "SCALAR";
}
export default function App() {
const [reactTag, setReactTag] = useState<number | null>(null);
const [snapshot, setSnapshot] = useState<RuntimeSnapshot>(initialSnapshot);
const [result, setResult] = useState("Style is loading");
const [basemap, setBasemap] = useState("LOADING");
const [frames, setFrames] = useState<Record<string, string>>({ T0: "PENDING", T1: "PENDING" });
const [dataset, setDataset] = useState<V3Dataset>(fieldDatasets[0]);
const [transport, setTransport] = useState("PENDING: V2 FILE_REF / FLOAT32_LE");
const [v4Dataset, setV4Dataset] = useState<V4Dataset>(qbinGoldenDatasets[0]);
const [v4Frames, setV4Frames] = useState<Record<"T0" | "T1", string>>({ T0: "PENDING", T1: "PENDING" });
const [v4Bindings, setV4Bindings] = useState("PENDING");
const [v4Transport, setV4Transport] = useState("PENDING: raw QBIN bundled asset → local file:// URI");
const [finalControlRunning, setFinalControlRunning] = useState(false);
const [finalControlSummary, setFinalControlSummary] = useState("FINAL CONTROL REGRESSION: not run");
const finalControlRunRef = useRef(false);
const [mapStatus, setMapStatus] = useState("LOADING");
const [weatherStatus, setWeatherStatus] = useState("WAITING");
const [result, setResult] = useState("Waiting for map/style");
const [activeSource, setActiveSource] = useState("none");
const [format, setFormat] = useState<QbinFormat>("V3");
const [datasetId, setDatasetId] = useState<WeatherDatasetId>("wind");
const [weatherVisible, setWeatherVisible] = useState(true);
const [particleVisible, setParticleVisible] = useState(true);
const [controlsCollapsed, setControlsCollapsed] = useState(false);
const [loading, setLoading] = useState(false);
const [visual, setVisual] = useState(initialVisual);
const autoLoadTagRef = useRef<number | null>(null);
const selectedDataset = useMemo(
() => datasetFor(format, datasetId),
[format, datasetId],
);
const refresh = useCallback(async (tag: number) => {
setSnapshot(await FieldRuntime.getState(tag));
}, []);
const applyVisualToTag = useCallback(async (tag: number) => {
const results = await Promise.all([
FieldRuntime.setParticleCount(tag, Number(visual.count)),
FieldRuntime.setParticleLineWidth(tag, Number(visual.width)),
FieldRuntime.setParticleLength(tag, Number(visual.length)),
FieldRuntime.setParticleOpacity(tag, Number(visual.opacity)),
FieldRuntime.setTrailOpacity(tag, Number(visual.trailOpacity)),
FieldRuntime.setTrailFade(tag, Number(visual.trailFade)),
FieldRuntime.setFieldOpacity(tag, Number(visual.weatherOpacity)),
]);
if (results.some((value) => value !== "ACCEPTED")) {
throw new Error(results.join(", "));
}
}, [visual]);
const prepareDataset = useCallback(async (
tag: number,
dataset: V4Dataset,
) => {
const clearEncoded = await FieldRuntime.clearAllEncodedFields(tag);
if (clearEncoded !== "ACCEPTED") throw new Error("clearAllEncodedFields rejected");
const clearCanonical = await FieldRuntime.clearFields(tag);
if (clearCanonical !== "ACCEPTED") throw new Error("clearFields rejected");
const valueSource = await FieldRuntime.setFieldValueSource(
tag,
valueSourceFor(dataset.id),
);
if (valueSource !== "ACCEPTED") throw new Error("setFieldValueSource rejected");
const stops = await FieldRuntime.setFieldColorStops(
tag,
colorStopsFor(dataset.id),
);
if (stops !== "ACCEPTED") throw new Error("setFieldColorStops rejected");
const color = await FieldRuntime.setEncodedColorBinding(tag, dataset.colorBinding);
if (color !== "ACCEPTED") throw new Error("setEncodedColorBinding rejected");
if (dataset.particleBinding != null) {
const particle = await FieldRuntime.setEncodedParticleBinding(
tag,
dataset.particleBinding,
);
if (particle !== "ACCEPTED") {
throw new Error("setEncodedParticleBinding rejected");
}
}
const hasParticle = dataset.particleBinding != null;
await FieldRuntime.setFieldVisible(tag, true);
await FieldRuntime.setParticleVisible(tag, hasParticle);
await applyVisualToTag(tag);
setWeatherVisible(true);
setParticleVisible(hasParticle);
}, [applyVisualToTag]);
const finishLoad = useCallback(async (
tag: number,
dataset: V4Dataset,
midpointMs: number,
sourceLabel: string,
) => {
const midpoint = await FieldRuntime.setEncodedForecastTime(tag, midpointMs);
if (midpoint !== "ACCEPTED") throw new Error("setEncodedForecastTime rejected");
await FieldRuntime.setFieldVisible(tag, true);
await FieldRuntime.setParticleVisible(tag, dataset.particleBinding != null);
await refresh(tag);
setWeatherStatus("READY");
setActiveSource(sourceLabel);
setResult(
dataset.id + " READY / " + valueSourceFor(dataset.id) +
" / " + legendFor(dataset.id),
);
}, [refresh]);
const loadBundled = useCallback(async (
tag: number,
loadFormat: QbinFormat,
id: WeatherDatasetId,
) => {
const dataset = datasetFor(loadFormat, id);
try {
setLoading(true);
setWeatherStatus("LOADING");
setResult("Loading bundled " + loadFormat + " " + id);
await prepareDataset(tag, dataset);
const materialize = loadFormat === "V3" ? materializeV3Frame : materializeV2Frame;
const frames = await Promise.all(
dataset.frames.map((frame) => materialize(frame as never)),
);
for (const frame of frames) {
const submit = await FieldRuntime.submitEncodedField(
tag,
dataset.fieldKey,
frame.metadata,
dataset.profileContract,
frame.qbinFileUri,
);
if (submit !== "ACCEPTED_INSERTED") {
throw new Error(frame.label + " submit=" + submit);
}
}
await finishLoad(
tag,
dataset,
dataset.midpointMs,
"BUNDLED " + loadFormat + " / " + id,
);
} catch (error) {
setWeatherStatus("ERROR");
setResult("Bundled load error: " + String(error));
} finally {
setLoading(false);
}
}, [finishLoad, prepareDataset]);
const loadOnline = useCallback(async (
tag: number,
loadFormat: QbinFormat,
id: WeatherDatasetId,
) => {
const dataset = datasetFor(loadFormat, id);
try {
setLoading(true);
setWeatherStatus("DOWNLOADING");
setResult("Discovering online " + loadFormat + " " + id);
await prepareDataset(tag, dataset);
const online = await downloadOnlineQbin(loadFormat, id);
setResult(
"Downloading " + online.datasetId + "/" + online.serverLayer +
" ref=" + online.refTime,
);
for (const frame of online.frames) {
const submit = await FieldRuntime.submitEncodedField(
tag,
dataset.fieldKey,
frame.metadata,
dataset.profileContract,
frame.qbinFileUri,
);
if (submit !== "ACCEPTED_INSERTED") {
throw new Error(frame.label + " submit=" + submit);
}
}
const times = online.frames
.map((frame) => frame.targetTime)
.join(" → ");
await finishLoad(
tag,
dataset,
online.midpointMs,
"ONLINE " + loadFormat + " / " + online.datasetId +
" / " + online.serverLayer + " / ref=" + online.refTime +
" / " + times,
);
} catch (error) {
setWeatherStatus("ERROR");
setResult("Online load error: " + String(error));
} finally {
setLoading(false);
}
}, [finishLoad, prepareDataset]);
const refresh = useCallback(async (tag: number) => setSnapshot(await FieldRuntime.getState(tag)), []);
const attach = useCallback(async (tag: number) => {
try { await FieldRuntime.attach(tag); await refresh(tag); setReactTag(tag); setResult("FieldRuntime attached; style ready"); }
catch (error) { setResult(`style/attach error: ${String(error)}`); }
try {
await FieldRuntime.attach(tag);
setReactTag(tag);
await refresh(tag);
setResult("FieldRuntime attached / particle.10");
} catch (error) {
setResult("attach error: " + String(error));
}
}, [refresh]);
const onStyleReady = useCallback((event: unknown) => {
const tag = findNodeHandle((event as { target?: unknown }).target as never);
if (tag != null) void attach(tag);
}, [attach]);
useEffect(() => () => { if (reactTag != null) void FieldRuntime.detach(reactTag).catch(() => undefined); }, [reactTag]);
const command = useCallback(async (name: string, operation: (tag: number) => Promise<unknown>) => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
try { const value = await operation(reactTag); await refresh(reactTag); setResult(`${name}: ${String(value)}`); }
catch (error) { setResult(`${name}: ${String(error)}`); }
}, [reactTag, refresh]);
useEffect(() => {
if (reactTag == null || autoLoadTagRef.current === reactTag) return;
autoLoadTagRef.current = reactTag;
void loadBundled(reactTag, "V3", "wind");
}, [loadBundled, reactTag]);
const loadDataset = useCallback(async () => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
const expected = ["ACCEPTED_PENDING_INITIAL_WINDOW", "INITIAL_WINDOW_READY"];
useEffect(() => () => {
if (reactTag != null) void FieldRuntime.detach(reactTag).catch(() => undefined);
}, [reactTag]);
const applyVisual = useCallback(async () => {
if (reactTag == null) return;
try {
setTransport("LOADING: V2 metadata + validity/scalar/vector local file:// URIs"); setFrames({ T0: "PENDING", T1: "PENDING" });
if (await FieldRuntime.clearFields(reactTag) !== "ACCEPTED") throw new Error("clearFields was rejected");
await FieldRuntime.setFieldValueSource(reactTag, dataset.fieldValueSource); await FieldRuntime.setParticleVisible(reactTag, dataset.particle); await FieldRuntime.setParticleAdvectionScale(reactTag, dataset.advectionScale);
const localFrames = await Promise.all(dataset.frames.map(materializeV3Frame));
for (let index = 0; index < localFrames.length; index += 1) {
const frame = localFrames[index]; const submit = await FieldRuntime.submitField(reactTag, frame.metadataJson, frame.payloadRefs);
setFrames((current) => ({ ...current, [frame.label]: submit }));
if (submit !== expected[index]) throw new Error(`${frame.label} expected ${expected[index]}, got ${submit}`);
}
const native = await FieldRuntime.getState(reactTag); setSnapshot(native);
if (native.state !== "READY") throw new Error(`Native WeatherRuntime expected READY, got ${native.state}`);
setTransport("READY: V2 FILE_REF / FLOAT32_LE / shared validity"); setResult(`${dataset.title}: T0/T1 accepted; FieldRuntime READY`);
} catch (error) { setTransport(`ERROR: ${String(error)}`); setResult(`LOAD ${dataset.id}: ${String(error)}`); }
}, [reactTag, dataset]);
const prepareV4 = useCallback(async (tag: number) => {
setV4Transport("LOADING: clear → binding → T0 → T1 → midpoint");
setV4Frames({ T0: "PENDING", T1: "PENDING" });
setV4Bindings("PENDING");
const clear = await FieldRuntime.clearAllEncodedFields(tag);
if (clear !== "ACCEPTED") throw new Error(`clearAllEncodedFields expected ACCEPTED, got ${clear}`);
const color = await FieldRuntime.setEncodedColorBinding(tag, v4Dataset.colorBinding);
if (color !== "ACCEPTED") throw new Error(`ColorBinding expected ACCEPTED, got ${color}`);
const particle = v4Dataset.particleBinding == null ? "N/A" : await FieldRuntime.setEncodedParticleBinding(tag, v4Dataset.particleBinding);
if (particle !== "N/A" && particle !== "ACCEPTED") throw new Error(`ParticleBinding expected ACCEPTED, got ${particle}`);
setV4Bindings(`clearAll=${clear}; color=${color}; particle=${particle}`);
const localFrames = await Promise.all(v4Dataset.frames.map(materializeV4Frame));
const submitted: string[] = [];
for (const frame of localFrames) {
const submit = await FieldRuntime.submitEncodedField(tag, v4Dataset.fieldKey, frame.metadata, v4Dataset.profileContract, frame.qbinFileUri);
submitted.push(`${frame.label}=${submit}`);
setV4Frames((current) => ({ ...current, [frame.label]: submit }));
if (submit !== "ACCEPTED_INSERTED") throw new Error(`${frame.label} expected ACCEPTED_INSERTED, got ${submit}`);
}
const midpoint = await FieldRuntime.setEncodedForecastTime(tag, v4Dataset.midpointMs);
if (midpoint !== "ACCEPTED") throw new Error(`midpoint seek expected ACCEPTED, got ${midpoint}`);
await refresh(tag);
setV4Transport("READY: T0/T1 ACCEPTED_INSERTED; midpoint command ACCEPTED (mix=0.5 is not exposed)");
return `clearAll=${clear}; color=${color}; particle=${particle}; ${submitted.join(", ")}; midpoint=${midpoint}`;
}, [refresh, v4Dataset]);
const loadV4Dataset = useCallback(async () => {
if (reactTag == null) return setResult("MAP_NOT_READY: wait for GSI/style ready");
try {
await prepareV4(reactTag);
setResult(`${v4Dataset.title}: V4 raw QBIN load accepted`);
await applyVisualToTag(reactTag);
await refresh(reactTag);
setResult("Visual parameters applied");
} catch (error) {
setV4Transport(`ERROR: ${String(error)}`);
setResult(`LOAD V4 ${v4Dataset.id}: ${String(error)}`);
setResult("Visual error: " + String(error));
}
}, [prepareV4, reactTag, v4Dataset]);
}, [applyVisualToTag, reactTag, refresh]);
const runV4FinalControlRegression = useCallback(async () => {
if (reactTag == null || !Number.isInteger(reactTag) || reactTag <= 0) return setResult("MAP_NOT_READY: wait for a valid reactTag/style ready");
if (finalControlRunRef.current) return;
const steps: string[] = [];
const accepted = (step: string, value: string) => {
if (value !== "ACCEPTED") throw new Error(`${step} expected ACCEPTED, got ${value}`);
steps.push(`${step}=${value}`);
};
const prepare = async (attempt: number) => {
try { steps.push(`prepare#${attempt} PASS (${await prepareV4(reactTag)})`); }
catch (error) { throw new Error(`prepare#${attempt}: ${String(error)}`); }
};
finalControlRunRef.current = true;
setFinalControlRunning(true);
setFinalControlSummary("FINAL CONTROL REGRESSION: RUNNING");
try {
await prepare(1);
accepted("PLAY", await FieldRuntime.setEncodedForecastPlaybackState(reactTag, "PLAY"));
await new Promise<void>((resolve) => setTimeout(resolve, 500));
steps.push("PLAY wait≈500ms=PASS");
accepted("PAUSE", await FieldRuntime.setEncodedForecastPlaybackState(reactTag, "PAUSE"));
accepted("clear selected", await FieldRuntime.clearEncodedField(reactTag, v4Dataset.fieldKey));
await prepare(2);
accepted("clear all", await FieldRuntime.clearAllEncodedFields(reactTag));
try { await FieldRuntime.detach(reactTag); }
catch (error) { throw new Error(`detach: ${String(error)}`); }
steps.push("detach=PASS");
let attached: RuntimeSnapshot;
try { attached = await FieldRuntime.attach(reactTag); }
catch (error) { throw new Error(`attach: ${String(error)}`); }
setSnapshot(attached);
steps.push("attach=PASS");
await prepare(3);
try { await refresh(reactTag); }
catch (error) { throw new Error(`refresh: ${String(error)}`); }
steps.push("refresh=PASS");
const summary = steps.join(" | ");
setFinalControlSummary(`FINAL CONTROL REGRESSION PASS: ${summary}`);
setResult("FINAL CONTROL REGRESSION PASS");
} catch (error) {
const failure = `FAIL: ${String(error)}`;
steps.push(failure);
setFinalControlSummary(`FINAL CONTROL REGRESSION ${steps.join(" | ")}`);
setResult(`FINAL CONTROL REGRESSION ${failure}`);
} finally {
finalControlRunRef.current = false;
setFinalControlRunning(false);
const toggleWeather = useCallback(async () => {
if (reactTag == null) return;
const next = !weatherVisible;
if (await FieldRuntime.setFieldVisible(reactTag, next) === "ACCEPTED") {
setWeatherVisible(next);
}
}, [prepareV4, reactTag, refresh, v4Dataset.fieldKey]);
}, [reactTag, weatherVisible]);
const toggleParticle = useCallback(async () => {
if (reactTag == null) return;
const next = !particleVisible;
if (await FieldRuntime.setParticleVisible(reactTag, next) === "ACCEPTED") {
setParticleVisible(next);
}
}, [particleVisible, reactTag]);
const updateVisual = useCallback((key: keyof typeof initialVisual, value: string) => setVisual((current) => ({ ...current, [key]: value })), []);
const applyVisual = useCallback(() => void command("particle visual", async (tag) => {
const results = await Promise.all([
FieldRuntime.setParticleCount(tag, Number(visual.count)), FieldRuntime.setParticleLineWidth(tag, Number(visual.width)), FieldRuntime.setParticleLength(tag, Number(visual.length)), FieldRuntime.setParticleOpacity(tag, Number(visual.opacity)), FieldRuntime.setTrailOpacity(tag, Number(visual.trailOpacity)), FieldRuntime.setTrailFade(tag, Number(visual.trailFade)), FieldRuntime.setFieldOpacity(tag, Number(visual.weatherOpacity)),
]);
if (results.some((value) => value !== "ACCEPTED")) throw new Error(results.join(", ")); return "ACCEPTED";
}), [command, visual]);
const updateVisual = useCallback(
(key: keyof typeof initialVisual, value: string) => {
setVisual((current) => ({ ...current, [key]: value }));
},
[],
);
const controls = useMemo(() => [
["PLAY", (tag: number) => FieldRuntime.setPlaybackState(tag, "PLAY")], ["PAUSE", (tag: number) => FieldRuntime.setPlaybackState(tag, "PAUSE")], ["HALF", (tag: number) => FieldRuntime.setPlaybackSpeed(tag, "HALF")], ["NORMAL", (tag: number) => FieldRuntime.setPlaybackSpeed(tag, "NORMAL")], ["DOUBLE", (tag: number) => FieldRuntime.setPlaybackSpeed(tag, "DOUBLE")],
["FPS AUTO", (tag: number) => FieldRuntime.setTargetFps(tag, "AUTO" as TargetFps)], ["FPS 30", (tag: number) => FieldRuntime.setTargetFps(tag, "FPS_30" as TargetFps)], ["FPS 60", (tag: number) => FieldRuntime.setTargetFps(tag, "FPS_60" as TargetFps)], ["Field stops", (tag: number) => FieldRuntime.setFieldColorStops(tag, frozenStops)], ["Particle color", (tag: number) => FieldRuntime.setParticleColor(tag, 1, 1, 1, 0.95)], ["Trail color", (tag: number) => FieldRuntime.setTrailColor(tag, 0.35, 0.85, 1, 0.8)],
] as const, []);
return (
<SafeAreaView style={styles.root}>
<View style={styles.map}>
<Map
mapStyle={weatherTestStyle}
onDidFinishLoadingStyle={onStyleReady}
onDidFinishLoadingMap={() => setMapStatus("READY")}
onDidFailLoadingMap={() => {
setMapStatus("ERROR");
setResult("map loading error");
}}
>
<Camera initialViewState={{ center: [137.5, 36.2], zoom: 4.8 }} />
</Map>
</View>
<View style={styles.controlPanel}>
<View style={styles.controlHeader}>
<Text style={styles.controlHeaderTitle}>
Particle V10 Test Controls
</Text>
<Button
title={controlsCollapsed ? "展开 ▼" : "收起 ▲"}
onPress={() => setControlsCollapsed((current) => !current)}
/>
</View>
return <SafeAreaView style={styles.root}>
<View style={styles.map}><Map mapStyle={weatherTestStyle} onDidFinishLoadingStyle={onStyleReady} onDidFinishLoadingMap={() => setBasemap("READY")} onDidFailLoadingMap={() => { setBasemap("ERROR"); setResult("map loading error"); }}><Camera initialViewState={{ center: [135, 32], zoom: 3.9 }} /></Map></View>
<ScrollView style={styles.controls} contentContainerStyle={styles.content}>
<Text>Basemap: GSI {basemap}</Text><Text>Dataset: {dataset.title}</Text><Text>Runtime: {snapshot.state} / {snapshot.playbackState} / {snapshot.targetFps}</Text><Text selectable>V2 FILE TRANSPORT = {transport}</Text>
{dataset.frames.map((frame) => <Text selectable key={frame.label}>{frame.label}: {frames[frame.label]}</Text>)}
<Text selectable>{result}</Text><View style={styles.row}>{fieldDatasets.map((item) => <Button key={item.id} title={item.id} onPress={() => setDataset(item)} />)}</View><Button title="LOAD SELECTED V3 DATASET" onPress={() => void loadDataset()} />
<View style={styles.row}>{controls.map(([label, operation]) => <Button key={label} title={label} onPress={() => void command(label, operation)} />)}</View>
<View style={styles.row}><Button title={`Weather ${weatherVisible ? "visible" : "hidden"}`} onPress={() => { const next = !weatherVisible; setWeatherVisible(next); void command("weather visible", (tag) => FieldRuntime.setFieldVisible(tag, next)); }} /><Button title={`Particle ${particleVisible ? "visible" : "hidden"}`} onPress={() => { const next = !particleVisible; setParticleVisible(next); void command("particle visible", (tag) => FieldRuntime.setParticleVisible(tag, next)); }} /></View>
<View style={styles.row}>{dataset.frames.map((frame) => <Button key={`seek-${frame.label}`} title={`seek ${frame.label}`} onPress={() => void command(`seek ${frame.label}`, (tag) => FieldRuntime.setTargetTime(tag, frame.validTime))} />)}</View>
<Text>Visual: count / width / length / particle opacity / trail opacity / trail fade / WeatherColor opacity</Text><View style={styles.row}>{(Object.keys(visual) as Array<keyof typeof initialVisual>).map((key) => <TextInput key={key} value={visual[key]} onChangeText={(value) => updateVisual(key, value)} keyboardType="decimal-pad" style={styles.input} />)}<Button title="Apply visual" onPress={applyVisual} /></View>
<Text>V4 QBIN Golden: {v4Dataset.title}</Text><Text selectable>V4 raw QBIN transport = {v4Transport}</Text><Text selectable>V4 bindings: {v4Bindings}</Text><Text selectable>{v4Dataset.particleNote}</Text>
<Text selectable>V4 T0: {v4Frames.T0}</Text><Text selectable>V4 T1: {v4Frames.T1}</Text>
<View style={styles.row}>{qbinGoldenDatasets.map((item) => <Button key={item.id} title={`V4 ${item.id}`} onPress={() => setV4Dataset(item)} />)}</View><Button title="LOAD SELECTED V4 DATASET" onPress={() => void loadV4Dataset()} />
<Button title={finalControlRunning ? "RUN V4 FINAL CONTROL REGRESSION (RUNNING)" : "RUN V4 FINAL CONTROL REGRESSION"} disabled={finalControlRunning} onPress={() => void runV4FinalControlRegression()} />
<Text selectable>{finalControlSummary}</Text>
<View style={styles.row}><Button title="V4 seek T0" onPress={() => void command("V4 seek T0", (tag) => FieldRuntime.setEncodedForecastTime(tag, v4Dataset.frames[0].validTimeMs))} /><Button title="V4 seek midpoint" onPress={() => void command("V4 seek midpoint (mix not exposed)", (tag) => FieldRuntime.setEncodedForecastTime(tag, v4Dataset.midpointMs))} /><Button title="V4 seek T1" onPress={() => void command("V4 seek T1", (tag) => FieldRuntime.setEncodedForecastTime(tag, v4Dataset.frames[1].validTimeMs))} /></View>
<View style={styles.row}><Button title="V4 PLAY" onPress={() => void command("V4 PLAY", (tag) => FieldRuntime.setEncodedForecastPlaybackState(tag, "PLAY"))} /><Button title="V4 PAUSE" onPress={() => void command("V4 PAUSE", (tag) => FieldRuntime.setEncodedForecastPlaybackState(tag, "PAUSE"))} /><Button title="V4 speed 1.0" onPress={() => void command("V4 speed 1.0", (tag) => FieldRuntime.setEncodedForecastPlaybackSpeed(tag, 1.0))} /><Button title="V4 clear selected" onPress={() => void command("V4 clear selected", (tag) => FieldRuntime.clearEncodedField(tag, v4Dataset.fieldKey))} /><Button title="V4 clear all" onPress={() => void command("V4 clear all", (tag) => FieldRuntime.clearAllEncodedFields(tag))} /></View>
</ScrollView>
</SafeAreaView>;
{!controlsCollapsed && (
<ScrollView style={styles.controls} contentContainerStyle={styles.content}>
<Text>
Map: {mapStatus} / Weather: {weatherStatus} / Runtime: {snapshot.state}
</Text>
<Text>
Native: RN 13.4.1-particle.10 / iOS 6.28.0-particle.10
</Text>
<Text selectable>Source: {activeSource}</Text>
<Text selectable>{result}</Text>
<Text>QBIN Format</Text>
<View style={styles.row}>
{(["V2", "V3"] as const).map((item) => (
<Button
key={item}
title={(format === item ? "✓ " : "") + item}
onPress={() => setFormat(item)}
disabled={loading}
/>
))}
</View>
<Text>Dataset</Text>
<View style={styles.row}>
{(["wind", "wave", "current", "rain"] as const).map((item) => (
<Button
key={item}
title={(datasetId === item ? "✓ " : "") + item}
onPress={() => setDatasetId(item)}
disabled={loading}
/>
))}
</View>
<Text>
{valueSourceFor(datasetId)} / {legendFor(datasetId)}
</Text>
<View style={styles.row}>
<Button
title={"LOAD BUNDLED " + format}
disabled={reactTag == null || loading}
onPress={() => reactTag != null &&
void loadBundled(reactTag, format, datasetId)}
/>
<Button
title={"ONLINE GET " + format}
disabled={reactTag == null || loading}
onPress={() => reactTag != null &&
void loadOnline(reactTag, format, datasetId)}
/>
<Button
title={weatherVisible ? "Weather ON" : "Weather OFF"}
disabled={reactTag == null || loading}
onPress={() => void toggleWeather()}
/>
<Button
title={particleVisible ? "Particle ON" : "Particle OFF"}
disabled={
reactTag == null ||
loading ||
selectedDataset.particleBinding == null
}
onPress={() => void toggleParticle()}
/>
</View>
<Text>
Visual: count / width / length / particle opacity / trail opacity /
trail fade / WeatherColor opacity
</Text>
<View style={styles.row}>
{(Object.keys(visual) as Array<keyof typeof initialVisual>).map((key) => (
<TextInput
key={key}
value={visual[key]}
onChangeText={(value) => updateVisual(key, value)}
keyboardType="decimal-pad"
style={styles.input}
/>
))}
<Button
title="Apply visual"
disabled={reactTag == null || loading}
onPress={() => void applyVisual()}
/>
</View>
</ScrollView>
)}
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({ root: { flex: 1 }, map: { flex: 1 }, controls: { maxHeight: 310, backgroundColor: "#fff" }, content: { gap: 7, padding: 10 }, row: { flexDirection: "row", flexWrap: "wrap", gap: 6, alignItems: "center" }, input: { borderWidth: 1, minWidth: 48, padding: 5 } });
const styles = StyleSheet.create({
root: { flex: 1 },
map: { flex: 1 },
controlPanel: { backgroundColor: "#fff" },
controlHeader: {
minHeight: 44,
paddingHorizontal: 10,
paddingVertical: 4,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#c7c7c7",
},
controlHeaderTitle: { fontSize: 15, fontWeight: "600" },
controls: { maxHeight: 360, backgroundColor: "#fff" },
content: { gap: 7, padding: 10, paddingTop: 4 },
row: {
flexDirection: "row",
flexWrap: "wrap",
gap: 6,
alignItems: "center",
},
input: { borderWidth: 1, minWidth: 48, padding: 5 },
});

View File

@@ -5,7 +5,18 @@
"version": "0.1.0",
"orientation": "landscape",
"newArchEnabled": true,
"plugins": ["./plugins/withParticleNativeAndroid"],
"plugins": [
[
"@maplibre/maplibre-react-native",
{
"ios": {
"spmSpec": "{ url: \"https://pbf.jpcn.ccwu.cc/swift/maplibre-particle-spm.git\", requirement: { kind: \"exactVersion\", version: \"6.28.0-particle.10\" }, product_name: \"MapLibre\" }"
}
}
],
"./plugins/withParticleNativeIos",
"./plugins/withParticleNativeAndroid"
],
"android": { "package": "org.maplibre.particle.rntesthost", "minSdkVersion": 24 },
"ios": { "bundleIdentifier": "org.maplibre.particle.rntesthost", "supportsTablet": true }
}

View File

@@ -9,7 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@maplibre/maplibre-react-native": "11.3.6",
"@navsea/maplibre-particle-rn": "file:../../../packages/maplibre-particle-rn",
"@navsea/maplibre-particle-rn": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz",
"expo": "56.0.4",
"expo-asset": "~56.0.14",
"expo-file-system": "56.0.11",
@@ -21,24 +21,6 @@
"typescript": "5.9.3"
}
},
"../../../packages/maplibre-particle-rn": {
"name": "@navsea/maplibre-particle-rn",
"version": "13.4.1-particle.3",
"license": "BSD-2-Clause",
"devDependencies": {
"@maplibre/maplibre-react-native": "11.3.6"
},
"peerDependencies": {
"@maplibre/maplibre-react-native": "11.3.6",
"react": ">=19.1.0",
"react-native": ">=0.80.0"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1780,8 +1762,21 @@
}
},
"node_modules/@navsea/maplibre-particle-rn": {
"resolved": "../../../packages/maplibre-particle-rn",
"link": true
"version": "13.4.1-particle.10",
"resolved": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz",
"integrity": "sha512-SNXvZ71A+vhrWzTxYCLGLGNkg6evtl8hydgdMUzB2Ft78sf5o+SCLQFtJ7ieLJl9r4kVEYdmprG8uwRdJDlhYg==",
"license": "BSD-2-Clause",
"peerDependencies": {
"@maplibre/maplibre-react-native": "11.3.6",
"expo": ">=54.0.0",
"react": ">=19.1.0",
"react-native": ">=0.80.0"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
}
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.85.2",

View File

@@ -9,7 +9,7 @@
"android": "expo run:android"
},
"dependencies": {
"@navsea/maplibre-particle-rn": "file:../../../packages/maplibre-particle-rn",
"@navsea/maplibre-particle-rn": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz",
"@maplibre/maplibre-react-native": "11.3.6",
"expo": "56.0.4",
"expo-asset": "~56.0.14",

View File

@@ -6,7 +6,7 @@ const marker = "// MapLibreParticleRNTestHost NavSea Particle release SDK";
module.exports = function withParticleNativeAndroid(config) {
return withProjectBuildGradle(config, (project) => {
if (project.modResults.contents.includes(marker)) return project;
project.modResults.contents += `\n${marker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.maven { repository -> repository.url = p.uri("https://pbf.jpcn.ccwu.cc/maven") }\n p.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.9")\n }\n }\n }\n}\nrootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")\nrootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")\n`;
project.modResults.contents += `\n${marker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.maven { repository -> repository.url = p.uri("https://pbf.jpcn.ccwu.cc/maven") }\n p.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.10")\n }\n }\n }\n}\nrootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")\nrootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")\n`;
return project;
});
};

View File

@@ -0,0 +1,21 @@
const { withPodfile } = require("@expo/config-plugins");
const marker = "# MapLibreParticleRNTestHost Particle iOS SPM hook";
module.exports = function withParticleNativeIos(config) {
return withPodfile(config, (project) => {
const contents = project.modResults.contents;
if (contents.includes(marker)) return project;
const needle = " post_install do |installer|";
if (!contents.includes(needle)) {
throw new Error("Expo post_install block was not generated.");
}
project.modResults.contents = contents.replace(
needle,
needle + "\n " + marker + "\n $MLRN_PARTICLE.post_install(installer)",
);
return project;
});
};

View File

@@ -0,0 +1,136 @@
import { File, Paths } from "expo-file-system";
import type { WeatherDatasetId } from "./weatherPalettes";
export type QbinFormat = "V2" | "V3";
type WeatherManifest = Readonly<{
l: Record<string, Readonly<{
d: string;
ds: readonly Readonly<{ id: string; r: string; iu: string }>[];
}>>;
}>;
type WeatherTimeIndex = Readonly<{
d: string;
l: string;
r: string;
t: readonly string[];
x: Readonly<{
q2f?: string;
q2v?: number;
q3f?: string;
q3v?: number;
q3bw?: number;
q3bh?: number;
}>;
}>;
export type OnlineQbinFrame = Readonly<{
label: "T0" | "T1";
targetTime: string;
validTimeMs: number;
metadata: Readonly<{ fieldID: string; generation: number; dataVersion: number }>;
qbinFileUri: string;
sourceUrl: string;
}>;
export type OnlineQbinSelection = Readonly<{
datasetId: string;
serverLayer: string;
refTime: string;
frames: readonly OnlineQbinFrame[];
midpointMs: number;
}>;
const BASE = "https://weather.jpcn.ccwu.cc/";
const HEADERS = { "User-Agent": "Mozilla/5.0" };
function layerFor(id: WeatherDatasetId): string {
if (id === "wave") return "waves";
if (id === "current") return "tide";
return id;
}
function timeMs(value: string): number {
if (!/^\d{10}$/.test(value)) throw new Error("Invalid forecast time: " + value);
return Date.UTC(
Number(value.slice(0, 4)),
Number(value.slice(4, 6)) - 1,
Number(value.slice(6, 8)),
Number(value.slice(8, 10)),
);
}
function chooseTimes(times: readonly string[]): readonly [string, string] {
if (times.length < 2) throw new Error("time_index has fewer than two target times");
const now = Date.now();
let index = times.findIndex((value) => timeMs(value) >= now);
if (index < 0) index = times.length - 1;
if (index === 0) return [times[0], times[1]];
return [times[index - 1], times[index]];
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, { headers: HEADERS });
if (!response.ok) throw new Error("HTTP " + response.status + ": " + url);
return await response.json() as T;
}
export async function downloadOnlineQbin(
format: QbinFormat,
id: WeatherDatasetId,
): Promise<OnlineQbinSelection> {
const manifest = await fetchJson<WeatherManifest>(BASE + "weather_manifest.json");
const serverLayer = layerFor(id);
const layer = manifest.l[serverLayer];
if (!layer) throw new Error("manifest layer missing: " + serverLayer);
const dataset = layer.ds.find((item) => item.id === layer.d) ?? layer.ds[0];
if (!dataset) throw new Error("manifest dataset missing: " + serverLayer);
const indexUrl = BASE + dataset.iu.replace(/^\/+/, "");
const timeIndex = await fetchJson<WeatherTimeIndex>(indexUrl);
const template = format === "V3" ? timeIndex.x.q3f : timeIndex.x.q2f;
const version = format === "V3" ? timeIndex.x.q3v : timeIndex.x.q2v;
if (!template) throw new Error(format + " frame template missing");
if (version !== (format === "V3" ? 3 : 2)) {
throw new Error(format + " version mismatch: " + String(version));
}
const [t0, t1] = chooseTimes(timeIndex.t);
const baseUrl = indexUrl.slice(0, indexUrl.lastIndexOf("/") + 1);
const targets = [t0, t1] as const;
const frames: OnlineQbinFrame[] = [];
for (let index = 0; index < targets.length; index += 1) {
const targetTime = targets[index];
const relative = template.replace("{target_time}", targetTime);
const sourceUrl = baseUrl + relative;
const destination = new File(
Paths.cache,
"online-" + serverLayer + "-" + format.toLowerCase() + "-" + targetTime + ".qbin",
);
const downloaded = await File.downloadFileAsync(
sourceUrl,
destination,
{ headers: HEADERS, idempotent: true },
);
frames.push({
label: index === 0 ? "T0" : "T1",
targetTime,
validTimeMs: timeMs(targetTime),
metadata: {
fieldID: "online-" + timeIndex.d + "-" + serverLayer,
generation: index + 1,
dataVersion: index + 1,
},
qbinFileUri: downloaded.uri,
sourceUrl,
});
}
return {
datasetId: timeIndex.d,
serverLayer,
refTime: timeIndex.r,
frames,
midpointMs: Math.round((frames[0].validTimeMs + frames[1].validTimeMs) / 2),
};
}

View File

@@ -0,0 +1,126 @@
import { Asset } from "expo-asset";
export type V4CommandResult = "ACCEPTED" | "REJECTED";
type RequiredPlane = Readonly<{
semanticID: number;
valueClass: "SCALAR" | "VELOCITY_COMPONENT" | "DIRECTION_ANGLE" | "VALIDITY";
unitID: number;
groupID: number;
component: "NONE" | "U_EAST" | "V_NORTH" | "W_UP";
requiredFlag: boolean;
}>;
export type V4Dataset = Readonly<{
id: "wind" | "wave" | "current" | "rain";
title: string;
fieldKey: string;
profileContract: Readonly<{
contractSchemaVersion: 1;
profileID: string;
profileVersion: 1;
allowAdditionalPlanes: boolean;
sharedValidityRequired: boolean;
timeRelation: "VALID_GTE_REFERENCE";
requiredPlanes: readonly RequiredPlane[];
}>;
frames: readonly V4Frame[];
midpointMs: number;
colorBinding: Readonly<{
fieldKey: string;
semanticID: number;
groupID: number;
expectedValueClass: "SCALAR" | "VELOCITY_COMPONENT" | "DIRECTION_ANGLE";
expectedUnitID: number;
}>;
particleBinding?: Readonly<{
fieldKey: string;
mode: "VECTOR_COMPONENTS" | "DIRECTION_ANGLE";
uPlane?: Readonly<{ semanticID: number; groupID: number }>;
vPlane?: Readonly<{ semanticID: number; groupID: number }>;
directionPlane?: Readonly<{ semanticID: number; groupID: number }>;
expectedUnitID: number;
}>;
particleNote: string;
}>;
export type V4Frame = Readonly<{
label: "T0" | "T1";
validTimeMs: number;
metadata: Readonly<{ fieldID: string; generation: number; dataVersion: number }>;
assetModule: number;
sourceRelativePath: string;
bytes: number;
sha256: string;
}>;
const sharedValidity: RequiredPlane = {
semanticID: 3, valueClass: "VALIDITY", unitID: 0, groupID: 0, component: "NONE", requiredFlag: true,
};
const vectorPlanes: readonly RequiredPlane[] = [
{ semanticID: 1, valueClass: "VELOCITY_COMPONENT", unitID: 1, groupID: 1, component: "U_EAST", requiredFlag: true },
{ semanticID: 2, valueClass: "VELOCITY_COMPONENT", unitID: 1, groupID: 1, component: "V_NORTH", requiredFlag: true },
sharedValidity,
];
const wavePlanes: readonly RequiredPlane[] = [
{ semanticID: 10, valueClass: "DIRECTION_ANGLE", unitID: 4, groupID: 0, component: "NONE", requiredFlag: true },
{ semanticID: 20, valueClass: "SCALAR", unitID: 2, groupID: 0, component: "NONE", requiredFlag: true },
{ semanticID: 21, valueClass: "SCALAR", unitID: 3, groupID: 0, component: "NONE", requiredFlag: true },
sharedValidity,
];
const rainPlanes: readonly RequiredPlane[] = [
{ semanticID: 30, valueClass: "SCALAR", unitID: 5, groupID: 0, component: "NONE", requiredFlag: true },
sharedValidity,
];
function contract(profileID: string, requiredPlanes: readonly RequiredPlane[]) {
return {
contractSchemaVersion: 1 as const,
profileID,
profileVersion: 1 as const,
allowAdditionalPlanes: true,
sharedValidityRequired: true,
timeRelation: "VALID_GTE_REFERENCE" as const,
requiredPlanes,
};
}
function frame(label: "T0" | "T1", fieldID: string, validTimeMs: number, assetModule: number, sourceRelativePath: string, bytes: number, sha256: string): V4Frame {
const revision = label === "T0" ? 1 : 2;
return { label, validTimeMs, metadata: { fieldID, generation: revision, dataVersion: revision }, assetModule, sourceRelativePath, bytes, sha256 };
}
const windFrames = [
frame("T0", "fixture-wind", 1789239600000, require("../assets/qbin-v3-golden/wind/T0.qbin"), "wind/frames/2026091219.qbin", 621120, "e815d94c18711c70e27c570cfd89acda3a27d64646d04e0b29846404ca325979"),
frame("T1", "fixture-wind", 1789243200000, require("../assets/qbin-v3-golden/wind/T1.qbin"), "wind/frames/2026091220.qbin", 622832, "bb2dbde0a8984d2fda4140cb6393744ede14fc9327663091e7afd4d2e37606da"),
] as const;
const waveFrames = [
frame("T0", "fixture-wave", 1789239600000, require("../assets/qbin-v3-golden/wave/T0.qbin"), "wave/frames/2026091219.qbin", 520688, "e4409ba0b09003e663dacdce8fc2ef35a1b929b5ccba8dc82e1ad0fc8c2f1202"),
frame("T1", "fixture-wave", 1789243200000, require("../assets/qbin-v3-golden/wave/T1.qbin"), "wave/frames/2026091220.qbin", 520016, "4827e4c9a7917da944cdf65212cc6371b0b7276fb9ae3487625f1c539b27f178"),
] as const;
const currentFrames = [
frame("T0", "fixture-current", 1789174800000, require("../assets/qbin-v3-golden/current/T0.qbin"), "tide/frames/2026091201.qbin", 104592, "b1b6d4a1dbb98971c3daca6b781587464e3528dd929da25196409b4b3b2bc052"),
frame("T1", "fixture-current", 1789178400000, require("../assets/qbin-v3-golden/current/T1.qbin"), "tide/frames/2026091202.qbin", 104112, "0588b3b1ed9e2d491adefa308f7d0b0707a38d773cc8d0802cded77a24c2aa24"),
] as const;
const rainFrames = [
frame("T0", "fixture-rain", 1789239600000, require("../assets/qbin-v3-golden/rain/T0.qbin"), "rain/frames/2026091219.qbin", 153904, "567d99c6309d185184193f203f6eb7b2891572730ec9299371e6a0db0a067d20"),
frame("T1", "fixture-rain", 1789243200000, require("../assets/qbin-v3-golden/rain/T1.qbin"), "rain/frames/2026091220.qbin", 153904, "8688d0373534d1c67ab4eed836e1314f14eba639594666683e6c22ff091e14fc"),
] as const;
const vectorColor = (fieldKey: string) => ({ fieldKey, semanticID: 1, groupID: 1, expectedValueClass: "VELOCITY_COMPONENT" as const, expectedUnitID: 1 });
const vectorParticle = (fieldKey: string) => ({ fieldKey, mode: "VECTOR_COMPONENTS" as const, uPlane: { semanticID: 1, groupID: 1 }, vPlane: { semanticID: 2, groupID: 1 }, expectedUnitID: 1 });
/** QBIN V3 Golden fixtures for the encoded-field public contract. Production block geometry is fixed at 16x16. */
export const qbinV3GoldenDatasets: readonly V4Dataset[] = [
{ id: "wind", title: "Wind QBIN V3", fieldKey: "fixture.wind", profileContract: contract("wind", vectorPlanes), frames: windFrames, midpointMs: 1789241400000, colorBinding: vectorColor("fixture.wind"), particleBinding: vectorParticle("fixture.wind"), particleNote: "U ColorBinding (VELOCITY_COMPONENT); U/V particle vectors." },
{ id: "wave", title: "Wave QBIN V3", fieldKey: "fixture.wave", profileContract: contract("wave", wavePlanes), frames: waveFrames, midpointMs: 1789241400000, colorBinding: { fieldKey: "fixture.wave", semanticID: 20, groupID: 0, expectedValueClass: "SCALAR", expectedUnitID: 2 }, particleBinding: { fieldKey: "fixture.wave", mode: "DIRECTION_ANGLE", directionPlane: { semanticID: 10, groupID: 0 }, expectedUnitID: 4 }, particleNote: "WAVE_HEIGHT color; DIRECTION angle particle (no direction conversion)." },
{ id: "current", title: "Current QBIN V3", fieldKey: "fixture.current", profileContract: contract("current", vectorPlanes), frames: currentFrames, midpointMs: 1789176600000, colorBinding: vectorColor("fixture.current"), particleBinding: vectorParticle("fixture.current"), particleNote: "U ColorBinding (VELOCITY_COMPONENT); U/V particle vectors." },
{ id: "rain", title: "Rain QBIN V3", fieldKey: "fixture.rain", profileContract: contract("rain", rainPlanes), frames: rainFrames, midpointMs: 1789241400000, colorBinding: { fieldKey: "fixture.rain", semanticID: 30, groupID: 0, expectedValueClass: "SCALAR", expectedUnitID: 5 }, particleNote: "Particle N/A: fixture has no particle-compatible plane." },
] as const;
export async function materializeV4Frame(input: V4Frame): Promise<V4Frame & { qbinFileUri: string }> {
const asset = Asset.fromModule(input.assetModule);
await asset.downloadAsync();
if (!asset.localUri?.startsWith("file://")) throw new Error(`${input.label} QBIN asset did not materialize a local file:// URI`);
return { ...input, qbinFileUri: asset.localUri };
}

View File

@@ -0,0 +1,54 @@
import type { FieldColorStop } from "@navsea/maplibre-particle-rn";
export type WeatherDatasetId = "wind" | "wave" | "current" | "rain";
const wind: readonly FieldColorStop[] = [
{ value: 0.00000, red: 0.16, green: 0.35, blue: 0.78, alpha: 1.00 },
{ value: 2.57222, red: 0.10, green: 0.58, blue: 0.78, alpha: 1.00 },
{ value: 5.14444, red: 0.10, green: 0.68, blue: 0.34, alpha: 1.00 },
{ value: 10.28888, red: 0.70, green: 0.68, blue: 0.08, alpha: 1.00 },
{ value: 15.43332, red: 0.88, green: 0.38, blue: 0.12, alpha: 1.00 },
{ value: 20.57776, red: 0.78, green: 0.15, blue: 0.38, alpha: 1.00 },
{ value: 30.86664, red: 0.48, green: 0.18, blue: 0.70, alpha: 1.00 },
];
const wave: readonly FieldColorStop[] = [
{ value: 0.5, red: 0.18, green: 0.58, blue: 0.68, alpha: 1.00 },
{ value: 1.0, red: 0.18, green: 0.38, blue: 0.55, alpha: 1.00 },
{ value: 1.5, red: 0.21, green: 0.38, blue: 0.71, alpha: 1.00 },
{ value: 2.0, red: 0.22, green: 0.23, blue: 0.53, alpha: 1.00 },
{ value: 6.0, red: 0.74, green: 0.29, blue: 0.35, alpha: 1.00 },
{ value: 9.0, red: 0.73, green: 0.62, blue: 0.60, alpha: 1.00 },
];
const current: readonly FieldColorStop[] = [
{ value: 0.000000, red: 0.20, green: 0.25, blue: 0.46, alpha: 1.00 },
{ value: 0.205778, red: 0.19, green: 0.50, blue: 0.18, alpha: 1.00 },
{ value: 0.411555, red: 0.50, green: 0.40, blue: 0.18, alpha: 1.00 },
{ value: 0.823110, red: 0.37, green: 0.24, blue: 0.47, alpha: 1.00 },
{ value: 1.028889, red: 0.24, green: 0.39, blue: 0.42, alpha: 1.00 },
{ value: 1.646221, red: 0.53, green: 0.53, blue: 0.53, alpha: 1.00 },
];
const rain: readonly FieldColorStop[] = [
{ value: 1.5, red: 0.21, green: 0.44, blue: 0.57, alpha: 1.00 },
{ value: 2.0, red: 0.19, green: 0.42, blue: 0.53, alpha: 1.00 },
{ value: 3.0, red: 0.20, green: 0.47, blue: 0.56, alpha: 1.00 },
{ value: 7.0, red: 0.18, green: 0.58, blue: 0.38, alpha: 1.00 },
{ value: 10.0, red: 0.47, green: 0.59, blue: 0.22, alpha: 1.00 },
{ value: 20.0, red: 0.58, green: 0.22, blue: 0.22, alpha: 1.00 },
{ value: 30.0, red: 0.58, green: 0.21, blue: 0.55, alpha: 1.00 },
];
export function colorStopsFor(id: WeatherDatasetId): readonly FieldColorStop[] {
if (id === "wind") return wind;
if (id === "wave") return wave;
if (id === "current") return current;
return rain;
}
export function legendFor(id: WeatherDatasetId): string {
if (id === "wind") return "0 / 5 / 10 / 20 / 30 / 40 / 60 kt";
if (id === "wave") return "0.5 / 1 / 1.5 / 2 / 6 / 9 m";
if (id === "current") return "0 / 0.4 / 0.8 / 1.6 / 2 / 3.2 kt";
return "1.5 / 2 / 3 / 7 / 10 / 20 / 30 mm";
}

View File

@@ -1,31 +1,56 @@
/** No field is embedded here: RasterArraySource is reserved for STEP-061 file-reference submission. */
/** Fixed visual baseline: WeatherColor -> Particle -> local darkmap PNG. */
export const DARKMAP_SOURCE_ID = "navsea_darkmap_png";
export const DARKMAP_TILE_URL =
"http://192.168.200.133:8000/windy_tiles_japan/{z}/{x}/{y}.png";
export const WEATHER_SOURCE_ID = "rn-weather-fields";
export const WEATHER_COLOR_LAYER_ID = "rn-weather-color";
export const PARTICLE_LAYER_ID = "rn-weather-particles";
export const GSI_SOURCE_ID = "gsi-standard";
export const GSI_LAYER_ID = "gsi-standard";
export const weatherTestStyle = JSON.stringify({
version: 8,
sources: {
[GSI_SOURCE_ID]: {
[DARKMAP_SOURCE_ID]: {
type: "raster",
tiles: ["https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png"],
tileSize: 256,
maxzoom: 18,
attribution: "\u56fd\u571f\u5730\u7406\u9662 / GSI"
tiles: [DARKMAP_TILE_URL],
minzoom: 4,
maxzoom: 9,
tileSize: 256
},
[WEATHER_SOURCE_ID]: { type: "raster-array", "host-field-submission": true }
},
layers: [
{ id: "rn-basemap", type: "background", paint: { "background-color": "#10243a" } },
{ id: GSI_LAYER_ID, type: "raster", source: GSI_SOURCE_ID, paint: { "raster-opacity": 1 } },
{ id: WEATHER_COLOR_LAYER_ID, type: "weather-color", source: WEATHER_SOURCE_ID,
paint: { "weather-color-opacity": 0.62 } },
{ id: PARTICLE_LAYER_ID, type: "raster-particle", source: WEATHER_SOURCE_ID,
paint: { "raster-particle-active": true, "raster-particle-particle-count": 4096,
"raster-particle-line-width": 1.5, "raster-particle-particle-length": 0.012,
"raster-particle-particle-opacity": 0.9, "raster-particle-trail-opacity": 0.8,
"raster-particle-trail-fade": 0.94 } }
[WEATHER_SOURCE_ID]: {
type: "raster-array",
"host-field-submission": true
}
}, layers: [
{
id: WEATHER_COLOR_LAYER_ID,
type: "weather-color",
source: WEATHER_SOURCE_ID,
paint: {
"weather-color-opacity": 1.0
}
},
{
id: PARTICLE_LAYER_ID,
type: "raster-particle",
source: WEATHER_SOURCE_ID,
paint: {
"raster-particle-active": true,
"raster-particle-particle-count": 4096,
"raster-particle-line-width": 1.8,
"raster-particle-particle-length": 0.028,
"raster-particle-particle-opacity": 0.85,
"raster-particle-trail-opacity": 0.72,
"raster-particle-trail-fade": 0.975
}
}, {
id: "darkmap-basemap",
type: "raster",
source: DARKMAP_SOURCE_ID,
paint: {
"raster-opacity": 1,
"raster-fade-duration": 0
}
}
]
});

View File

@@ -2,6 +2,9 @@
## Unreleased
## 13.4.1-particle.10(Android / iOS 统一发行)
- Mac unified release pipeline 统一生成 Android Native、RN package 与 iOS SwiftPM/XCFramework;共享 Particle revision 10。
## 13.4.1-particle.9(QBIN V3 Decoder)
- 新增 QBV3/v3 Native Decoder,固定 16×16 Block Geometry,支持 Predictor + ZigZag + Rice 解码、Direction 环形残差、CRC32、canonical packing、validity 与资源上限校验。

View File

@@ -1,6 +1,6 @@
# @navsea/maplibre-particle-rn
当前版本:`13.4.1-particle.9`。Android Native 必须配套 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.9`。本包是 React Native 的薄控制面,不负责 Map 组件、provider、HTTP、manifest、下载、缓存或 NavSea 业务策略。
当前版本:`13.4.1-particle.10`。Android Native 必须配套 `cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.10`。本包是 React Native 的薄控制面,不负责 Map 组件、provider、HTTP、manifest、下载、缓存或 NavSea 业务策略。
## 当前推荐主链:QBIN V4
@@ -37,7 +37,7 @@ Podfile 的 `post_install do |installer|` 必须同时调用 `$MLRN.post_install
RN tgz:
```text
https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.9.tgz
https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz
```
Native Maven repository:
@@ -49,7 +49,7 @@ https://pbf.jpcn.ccwu.cc/maven
Native coordinate:
```text
cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.9
cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.10
```
Expo plugin 与 `android/build.gradle` 必须指向同一 `.8`。正常 Consumer 不复制 AAR、不依赖 MapLibreParticleLab 源码路径,也不以 Maven Local 作为公网验证主路径。完整 Linux 发布与 Win11 验证流程见 `docs/50_Step记录/STEP-080_Android_AAR与RNTestHost标准构建流水线_v2.md`。
Expo plugin 与 `android/build.gradle` 必须指向同一 `.10`。正常 Consumer 不复制 AAR、不依赖 MapLibreParticleLab 源码路径,也不以 Maven Local 作为公网验证主路径。完整 Linux 发布与 Win11 验证流程见 `docs/50_Step记录/STEP-080_Android_AAR与RNTestHost标准构建流水线_v2.md`。

View File

@@ -14,7 +14,7 @@ android {
dependencies {
implementation "com.facebook.react:react-android"
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.20"
implementation "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.9"
implementation "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.10"
compileOnly project(":maplibre_maplibre-react-native")
}

View File

@@ -20,7 +20,7 @@ const iosTag = "navsea-maplibre-particle-rn-maplibre-spm";
module.exports = function withNavSeaMapLibreParticle(config) {
config = withProjectBuildGradle(config, (project) => {
if (project.modResults.contents.includes(androidMarker)) return project;
project.modResults.contents += `\n${androidMarker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.maven { repository -> repository.url = p.uri("https://pbf.jpcn.ccwu.cc/maven") }\n p.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.9")\n }\n }\n }\n}\nrootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")\nrootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")\n`;
project.modResults.contents += `\n${androidMarker}\ngradle.projectsEvaluated {\n allprojects { p ->\n p.repositories.maven { repository -> repository.url = p.uri("https://pbf.jpcn.ccwu.cc/maven") }\n p.configurations.configureEach { configuration ->\n configuration.resolutionStrategy.dependencySubstitution {\n substitute module("org.maplibre.gl:android-sdk-opengl") using module("cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.10")\n }\n }\n }\n}\nrootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")\nrootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")\n`;
return project;
});

View File

@@ -1,6 +1,6 @@
{
"name": "@navsea/maplibre-particle-rn",
"version": "13.4.1-particle.9",
"version": "13.4.1-particle.10",
"description": "NavSea MapLibre Particle React Native pure Field Renderer API.",
"license": "BSD-2-Clause",
"main": "src/index.ts",

View File

@@ -1 +1 @@
13.4.1-particle.9
13.4.1-particle.10

View File

@@ -9,8 +9,8 @@ let package = Package(
targets: [
.binaryTarget(
name: "MapLibre",
url: "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.9/MapLibre.dynamic.xcframework.zip",
checksum: "fbda91e09d53e3cd1362590bfc0410b6fb5f9fef4cf99796d4ab7b4b84c93562"
url: "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.10/MapLibre.dynamic.xcframework.zip",
checksum: "1050573be4d2e29852a6ddce27f828e869eb51626ca91aa0fb4154656a57cd90"
),
]
)

View File

@@ -1,14 +1,14 @@
# MapLibre Particle iOS 发行
当前正式发行:6.28.0-particle.9
当前正式发行:6.28.0-particle.10
- Renderer:Metal
- Product / Module:MapLibre
- Binary:https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.9/MapLibre.dynamic.xcframework.zip
- SHA-256:fbda91e09d53e3cd1362590bfc0410b6fb5f9fef4cf99796d4ab7b4b84c93562
- Binary:https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.10/MapLibre.dynamic.xcframework.zip
- SHA-256:1050573be4d2e29852a6ddce27f828e869eb51626ca91aa0fb4154656a57cd90
- SwiftPM:https://pbf.jpcn.ccwu.cc/swift/maplibre-particle-spm.git
- Exact Version:6.28.0-particle.9
- Source Commit:0e8c87fe49fb45c23a88d8f6cbf79dbc70df393c
- Exact Version:6.28.0-particle.10
- Source Commit:d5ce2006be99e44699bb8ea55380bc372da6c23d
构建命令固定为:

View File

@@ -1,19 +1,19 @@
{
"schemaVersion": 2,
"status": "PUBLISHED_PUBLIC_READBACK_PASS",
"distributionVersion": "6.28.0-particle.9",
"distributionVersion": "6.28.0-particle.10",
"moduleName": "MapLibre",
"artifact": {
"fileName": "MapLibre.dynamic.xcframework.zip",
"sizeBytes": 8321079,
"sha256": "fbda91e09d53e3cd1362590bfc0410b6fb5f9fef4cf99796d4ab7b4b84c93562",
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.9/MapLibre.dynamic.xcframework.zip"
"sizeBytes": 8322801,
"sha256": "1050573be4d2e29852a6ddce27f828e869eb51626ca91aa0fb4154656a57cd90",
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.10/MapLibre.dynamic.xcframework.zip"
},
"sourceCommit": "0e8c87fe49fb45c23a88d8f6cbf79dbc70df393c",
"sourceCommit": "d5ce2006be99e44699bb8ea55380bc372da6c23d",
"swiftPackage": {
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle-spm.git",
"requirement": "exactVersion",
"version": "6.28.0-particle.9",
"version": "6.28.0-particle.10",
"productName": "MapLibre"
},
"notes": "Metal renderer release built with bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic"

View File

@@ -1,48 +1,48 @@
{
"schemaVersion": 2,
"releaseState": "PUBLISHED_PUBLIC_READBACK_PASS",
"particleRevision": 9,
"sourceCommit": "0e8c87fe49fb45c23a88d8f6cbf79dbc70df393c",
"generatedAtUtc": "2026-09-21T01:01:04.705768+00:00",
"particleRevision": 10,
"sourceCommit": "d5ce2006be99e44699bb8ea55380bc372da6c23d",
"generatedAtUtc": "2026-09-21T10:10:08.877852+00:00",
"reactNative": {
"name": "@navsea/maplibre-particle-rn",
"version": "13.4.1-particle.9",
"version": "13.4.1-particle.10",
"artifact": {
"fileName": "navsea-maplibre-particle-rn-13.4.1-particle.9.tgz",
"sizeBytes": 23411,
"sha256": "98dc94e88dc69c06c9d99cc8ece3547f0489ba650c4a3d71814eab45e8720dcb",
"url": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.9.tgz"
"fileName": "navsea-maplibre-particle-rn-13.4.1-particle.10.tgz",
"sizeBytes": 23430,
"sha256": "a8839c4603b9dec97bf92ff1116042eea98c4078fcd05f15befcd26e20c705b9",
"url": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz"
},
"mapLibreReactNative": "11.3.6"
},
"android": {
"version": "13.4.1-particle.9",
"coordinate": "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.9",
"version": "13.4.1-particle.10",
"coordinate": "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.10",
"renderer": "opengl",
"artifact": {
"fileName": "android-sdk-particle-opengl-13.4.1-particle.9.aar",
"sizeBytes": 168214914,
"sha256": "6114067c45685e2d20b3dd5115c480a39341177ac8608f86ec431562f649e395",
"url": "https://pbf.jpcn.ccwu.cc/maven/cc/navsea/maplibre/android-sdk-particle-opengl/13.4.1-particle.9/android-sdk-particle-opengl-13.4.1-particle.9.aar"
"fileName": "android-sdk-particle-opengl-13.4.1-particle.10.aar",
"sizeBytes": 168205277,
"sha256": "b126184186a5a96561e9a354a5959bc021ca0aedd72978f2fd856613b9df0f68",
"url": "https://pbf.jpcn.ccwu.cc/maven/cc/navsea/maplibre/android-sdk-particle-opengl/13.4.1-particle.10/android-sdk-particle-opengl-13.4.1-particle.10.aar"
},
"ndk": "28.2.13676358",
"gradle": "9.5.1",
"jdk": "openjdk version \"17.0.20.1\" 2026-08-18"
},
"ios": {
"distributionVersion": "6.28.0-particle.9",
"distributionVersion": "6.28.0-particle.10",
"renderer": "metal",
"moduleName": "MapLibre",
"artifact": {
"fileName": "MapLibre.dynamic.xcframework.zip",
"sizeBytes": 8321079,
"sha256": "fbda91e09d53e3cd1362590bfc0410b6fb5f9fef4cf99796d4ab7b4b84c93562",
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.9/MapLibre.dynamic.xcframework.zip"
"sizeBytes": 8322801,
"sha256": "1050573be4d2e29852a6ddce27f828e869eb51626ca91aa0fb4154656a57cd90",
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle/6.28.0-particle.10/MapLibre.dynamic.xcframework.zip"
},
"swiftPackage": {
"url": "https://pbf.jpcn.ccwu.cc/swift/maplibre-particle-spm.git",
"requirement": "exactVersion",
"version": "6.28.0-particle.9",
"version": "6.28.0-particle.10",
"productName": "MapLibre"
},
"xcode": "Xcode 26.3 Build version 17C529 ",

View File

@@ -138,23 +138,76 @@ bool vectorSnapshot(const DecodedFieldFrame& frame, const ParticleBinding& bindi
std::optional<MaterializedColorProjection> materializeColorProjection(const ColorProjection& projection) {
if (!projection.t0Frame || !projection.t1Frame || !compatible(*projection.t0Frame, *projection.t1Frame)) return {};
const auto* p0 = findPlane(*projection.t0Frame, projection.contentIdentity.binding.plane);
const auto* p1 = findPlane(*projection.t1Frame, projection.contentIdentity.binding.plane);
const auto& binding = projection.contentIdentity.binding;
if (binding.expectedValueClass == FieldValueClass::VelocityComponent) {
const auto findComponent = [&](const DecodedFieldFrame& frame, const FieldComponent component) {
const DecodedFieldPlane* result = nullptr;
for (const auto& plane : frame.planes) {
if (plane.valueClass != FieldValueClass::VelocityComponent || plane.unitID != binding.expectedUnitID ||
plane.key.groupID != binding.plane.groupID || plane.key.component != component)
continue;
if (result) return static_cast<const DecodedFieldPlane*>(nullptr);
result = &plane;
}
return result;
};
const auto* u0 = findComponent(*projection.t0Frame, FieldComponent::UEast);
const auto* v0 = findComponent(*projection.t0Frame, FieldComponent::VNorth);
const auto* u1 = findComponent(*projection.t1Frame, FieldComponent::UEast);
const auto* v1 = findComponent(*projection.t1Frame, FieldComponent::VNorth);
if (!u0 || !v0 || !u1 || !v1 || !u0->isInterpolationCompatibleWith(*u1) ||
!v0->isInterpolationCompatibleWith(*v1))
return {};
ParticleBinding vectorBinding{
ParticleBindingMode::VectorComponents,
binding.fieldKey,
u0->key,
v0->key,
{},
binding.expectedUnitID,
};
auto t0 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t0Frame, u0->contentIdentity + "|" + v0->contentIdentity));
auto t1 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t1Frame, u1->contentIdentity + "|" + v1->contentIdentity));
if (!vectorSnapshot(*projection.t0Frame, vectorBinding, *t0) ||
!vectorSnapshot(*projection.t1Frame, vectorBinding, *t1))
return {};
return MaterializedColorProjection{std::move(t0),
std::move(t1),
projection.contentIdentity,
projection.sampleState,
MaterializedColorValueSource::VectorMagnitude};
}
const auto* p0 = findPlane(*projection.t0Frame, binding.plane);
const auto* p1 = findPlane(*projection.t1Frame, binding.plane);
if (!p0 || !p1) return {};
auto t0 = std::make_shared<ParticleFieldSnapshot>(baseSnapshot(*projection.t0Frame, projection.contentIdentity.t0Plane));
auto t1 = std::make_shared<ParticleFieldSnapshot>(baseSnapshot(*projection.t1Frame, projection.contentIdentity.t1Plane));
if (!scalarSnapshot(*projection.t0Frame, *p0, *t0) || !scalarSnapshot(*projection.t1Frame, *p1, *t1))
return {};
return MaterializedColorProjection{std::move(t0), std::move(t1), projection.contentIdentity, projection.sampleState};
auto t0 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t0Frame, projection.contentIdentity.t0Plane));
auto t1 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t1Frame, projection.contentIdentity.t1Plane));
if (!scalarSnapshot(*projection.t0Frame, *p0, *t0) || !scalarSnapshot(*projection.t1Frame, *p1, *t1)) return {};
return MaterializedColorProjection{std::move(t0),
std::move(t1),
projection.contentIdentity,
projection.sampleState,
MaterializedColorValueSource::Scalar};
}
std::optional<MaterializedParticleProjection> materializeParticleProjection(const ParticleProjection& projection) {
if (!projection.t0Frame || !projection.t1Frame || !compatible(*projection.t0Frame, *projection.t1Frame)) return {};
auto t0 = std::make_shared<ParticleFieldSnapshot>(baseSnapshot(*projection.t0Frame, projection.contentIdentity.t0FirstPlane));
auto t1 = std::make_shared<ParticleFieldSnapshot>(baseSnapshot(*projection.t1Frame, projection.contentIdentity.t1FirstPlane));
auto t0 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t0Frame, projection.contentIdentity.t0FirstPlane));
auto t1 = std::make_shared<ParticleFieldSnapshot>(
baseSnapshot(*projection.t1Frame, projection.contentIdentity.t1FirstPlane));
if (!vectorSnapshot(*projection.t0Frame, projection.contentIdentity.binding, *t0) ||
!vectorSnapshot(*projection.t1Frame, projection.contentIdentity.binding, *t1))
return {};
return MaterializedParticleProjection{std::move(t0), std::move(t1), projection.contentIdentity, projection.sampleState};
return MaterializedParticleProjection{
std::move(t0), std::move(t1), projection.contentIdentity, projection.sampleState};
}
} // namespace mbgl::particle

View File

@@ -46,13 +46,13 @@ float scale(const particle::ParticleFieldSnapshot& field, style::FieldValueSourc
float result = 1.0f;
for (size_t index = 0; index < field.valid.size(); ++index)
if (field.valid[index]) {
if (valueSource == style::FieldValueSource::Scalar) {
result = std::max(result, std::abs(field.scalar[index]));
} else {
result = std::max(result, std::abs(field.vector[index * 2]));
result = std::max(result, std::abs(field.vector[index * 2 + 1]));
if (valueSource == style::FieldValueSource::Scalar) {
result = std::max(result, std::abs(field.scalar[index]));
} else {
result = std::max(result, std::abs(field.vector[index * 2]));
result = std::max(result, std::abs(field.vector[index * 2 + 1]));
}
}
}
return result;
}
@@ -62,17 +62,17 @@ std::shared_ptr<PremultipliedImage> image(const particle::ParticleFieldSnapshot&
auto result = std::make_shared<PremultipliedImage>(Size{field.width, field.height});
for (uint32_t y = 0; y < field.height; ++y)
for (uint32_t x = 0; x < field.width; ++x) {
const auto cell = static_cast<size_t>(y) * field.width + x;
const auto pixel = (static_cast<size_t>(y) * field.width + x) * PremultipliedImage::channels;
const auto cell = static_cast<size_t>(y) * field.width + x;
const auto pixel = (static_cast<size_t>(y) * field.width + x) * PremultipliedImage::channels;
const float first = valueSource == style::FieldValueSource::Scalar ? field.scalar[cell]
: field.vector[cell * 2];
const float second = valueSource == style::FieldValueSource::Scalar ? 0.0f : field.vector[cell * 2 + 1];
result->data[pixel] = static_cast<uint8_t>(std::clamp(first / fieldScale * .5f + .5f, 0.0f, 1.0f) * 255.0f);
const float second = valueSource == style::FieldValueSource::Scalar ? 0.0f : field.vector[cell * 2 + 1];
result->data[pixel] = static_cast<uint8_t>(std::clamp(first / fieldScale * .5f + .5f, 0.0f, 1.0f) * 255.0f);
result->data[pixel + 1] = static_cast<uint8_t>(std::clamp(second / fieldScale * .5f + .5f, 0.0f, 1.0f) *
255.0f);
result->data[pixel + 2] = 0;
result->data[pixel + 3] = field.valid[y * field.width + x] ? 255 : 0;
}
result->data[pixel + 2] = 0;
result->data[pixel + 3] = field.valid[y * field.width + x] ? 255 : 0;
}
return result;
}
@@ -100,7 +100,8 @@ struct Resources {
class Tweaker final : public gfx::DrawableTweaker {
public:
Tweaker(std::shared_ptr<Resources> resources_, int16_t wrap_)
: resources(std::move(resources_)), wrap(wrap_) {}
: resources(std::move(resources_)),
wrap(wrap_) {}
void init(gfx::Drawable&) override {}
void execute(gfx::Drawable& drawable, PaintParameters& parameters) override {
drawable.setTexture(resources->texture0, shaders::idParticleGpuValidationPreviousTexture);
@@ -154,8 +155,8 @@ bool uploadField(gfx::Context& context,
if (!outputTexture) return false;
outputTexture->setImage(outputImage);
outputTexture->setSamplerConfiguration({.filter = gfx::TextureFilterType::Linear,
.wrapU = gfx::TextureWrapType::Clamp,
.wrapV = gfx::TextureWrapType::Clamp});
.wrapU = gfx::TextureWrapType::Clamp,
.wrapV = gfx::TextureWrapType::Clamp});
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::FieldTextureUpload);
return true;
}
@@ -199,6 +200,7 @@ void RenderWeatherColorLayer::prepare(const LayerPrepareParameters& parameters)
if (!parameters.source || parameters.source->baseImpl->type != style::SourceType::RasterArray) return;
const auto& source = static_cast<const RenderRasterArraySource&>(*parameters.source);
sourceUsesV4Projection = false;
sourceProjectionUsesVectorMagnitude = false;
sourceProjectionMix = 0.0f;
if (source.impl().v4RendererState.color) {
const auto& projection = *source.impl().v4RendererState.color;
@@ -207,6 +209,8 @@ void RenderWeatherColorLayer::prepare(const LayerPrepareParameters& parameters)
sourceProjectionMix = projection.sampleState.mix;
sourceTimeline.reset();
sourceUsesV4Projection = true;
sourceProjectionUsesVectorMagnitude = projection.valueSource ==
particle::MaterializedColorValueSource::VectorMagnitude;
return;
}
sourceTimeline = source.impl().timeline;
@@ -242,10 +246,11 @@ void RenderWeatherColorLayer::update(gfx::ShaderRegistry& shaders,
resources.reset();
return;
}
// V4 ColorBinding has already selected and materialized its scalar plane;
// it must not be reinterpreted through the legacy style value source.
// Encoded ingress is decoder-only. After materialization, WeatherColor
// consumes the same canonical scalar/vector semantics as the legacy path.
const auto valueSource = sourceUsesV4Projection
? style::FieldValueSource::Scalar
? (sourceProjectionUsesVectorMagnitude ? style::FieldValueSource::VectorMagnitude
: style::FieldValueSource::Scalar)
: static_cast<const style::WeatherColorLayer::Impl&>(*baseImpl).valueSource;
if (!supportsValueSource(*sourceField, valueSource) || !supportsValueSource(*sourceNextField, valueSource)) {
if (layerGroup) static_cast<LayerGroup&>(*layerGroup).clearDrawables();

View File

@@ -34,6 +34,7 @@ private:
std::shared_ptr<style::RasterArrayTimelineRuntime> sourceTimeline;
float sourceProjectionMix = 0.0f;
bool sourceUsesV4Projection = false;
bool sourceProjectionUsesVectorMagnitude = false;
};
} // namespace mbgl

View File

@@ -39,21 +39,21 @@ RasterArrayEncodedFieldSubmitResult mapEncodedFieldStoreResult(const particle::F
switch (result) {
case particle::FieldSeriesSubmitResult::Inserted:
return RasterArrayEncodedFieldSubmitResult::AcceptedInserted;
case particle::FieldSeriesSubmitResult::ReplacedRevision:
return RasterArrayEncodedFieldSubmitResult::AcceptedReplacedRevision;
case particle::FieldSeriesSubmitResult::ReplacedRevision:
return RasterArrayEncodedFieldSubmitResult::AcceptedReplacedRevision;
case particle::FieldSeriesSubmitResult::Duplicate:
return RasterArrayEncodedFieldSubmitResult::Duplicate;
case particle::FieldSeriesSubmitResult::RejectedInvalidFieldKey:
return RasterArrayEncodedFieldSubmitResult::RejectedInvalidFieldKey;
case particle::FieldSeriesSubmitResult::RejectedIdentityConflict:
return RasterArrayEncodedFieldSubmitResult::RejectedIdentityConflict;
case particle::FieldSeriesSubmitResult::RejectedStaleRevision:
return RasterArrayEncodedFieldSubmitResult::RejectedStaleRevision;
case particle::FieldSeriesSubmitResult::RejectedIncompatibleFrame:
case particle::FieldSeriesSubmitResult::RejectedInvalidFrame:
return RasterArrayEncodedFieldSubmitResult::RejectedIncompatible;
case particle::FieldSeriesSubmitResult::RejectedCapacity:
return RasterArrayEncodedFieldSubmitResult::RejectedCapacity;
case particle::FieldSeriesSubmitResult::RejectedInvalidFieldKey:
return RasterArrayEncodedFieldSubmitResult::RejectedInvalidFieldKey;
case particle::FieldSeriesSubmitResult::RejectedIdentityConflict:
return RasterArrayEncodedFieldSubmitResult::RejectedIdentityConflict;
case particle::FieldSeriesSubmitResult::RejectedStaleRevision:
return RasterArrayEncodedFieldSubmitResult::RejectedStaleRevision;
case particle::FieldSeriesSubmitResult::RejectedIncompatibleFrame:
case particle::FieldSeriesSubmitResult::RejectedInvalidFrame:
return RasterArrayEncodedFieldSubmitResult::RejectedIncompatible;
case particle::FieldSeriesSubmitResult::RejectedCapacity:
return RasterArrayEncodedFieldSubmitResult::RejectedCapacity;
}
return RasterArrayEncodedFieldSubmitResult::RejectedIncompatible;
}
@@ -66,8 +66,8 @@ template <class Projection>
bool sameProjection(const std::shared_ptr<const Projection>& a, const std::shared_ptr<const Projection>& b) {
if (static_cast<bool>(a) != static_cast<bool>(b)) return false;
return !a || (a->contentIdentity == b->contentIdentity && a->sampleState.mix == b->sampleState.mix &&
a->sampleState.requestedForecastTimeMs == b->sampleState.requestedForecastTimeMs &&
a->t0 && b->t0 && a->t1 && b->t1 && sameSnapshot(*a->t0, *b->t0) && sameSnapshot(*a->t1, *b->t1));
a->sampleState.requestedForecastTimeMs == b->sampleState.requestedForecastTimeMs && a->t0 && b->t0 &&
a->t1 && b->t1 && sameSnapshot(*a->t0, *b->t0) && sameSnapshot(*a->t1, *b->t1));
}
} // namespace
@@ -207,7 +207,7 @@ RasterArrayCadenceAdvance RasterArrayTimelineRuntime::advanceIfDue(const std::ch
lastCadenceLog = now;
Log::Info(Event::Render,
"WeatherCadence: targetFps=" + std::to_string(static_cast<uint8_t>(targetFps_)) +
" weatherTicks=" + std::to_string(cadenceTickCount));
" weatherTicks=" + std::to_string(cadenceTickCount));
}
if (lastTick == std::chrono::steady_clock::time_point{}) {
lastTick = now;
@@ -341,8 +341,8 @@ bool RasterArrayTimelineRuntime::seekTimestamp(const uint64_t timestamp) {
const auto previousState = state;
const auto upper = std::upper_bound(
entries.begin(), entries.end(), timestamp, [](uint64_t value, const Entry& entry) {
return value < entry.timestamp;
});
return value < entry.timestamp;
});
if (upper == entries.begin()) return false;
if (upper == entries.end()) {
t0 = entries.size() - 2;
@@ -353,7 +353,7 @@ bool RasterArrayTimelineRuntime::seekTimestamp(const uint64_t timestamp) {
const auto duration = static_cast<float>(entries[t0 + 1].timestamp - entries[t0].timestamp);
mix = duration > 0.0f ? static_cast<float>(timestamp - entries[t0].timestamp) / duration : 0.0f;
state = previousState == RasterArrayPlaybackState::Playing ? RasterArrayPlaybackState::Playing
: RasterArrayPlaybackState::Paused;
: RasterArrayPlaybackState::Paused;
}
if (t0 != previousT0) ++windowRevision;
lastTick = std::chrono::steady_clock::now();
@@ -428,8 +428,7 @@ RasterArrayEncodedFieldSubmitResult RasterArraySource::submitEncodedField(
// checks metadata, but validate it here to make the Host ordering explicit.
if (!validEncodedFieldKey(fieldKey)) return RasterArrayEncodedFieldSubmitResult::RejectedInvalidFieldKey;
if (!validRuntimeMetadata(runtimeMetadata)) return RasterArrayEncodedFieldSubmitResult::RejectedDecode;
auto ingress = particle::ingress::EncodedFieldIngress::decodeAndValidate(
runtimeMetadata, &contract, rawQbinBytes);
auto ingress = particle::ingress::EncodedFieldIngress::decodeAndValidate(runtimeMetadata, &contract, rawQbinBytes);
if (ingress.code == particle::ingress::EncodedFieldIngressCode::DecodeRejected)
return RasterArrayEncodedFieldSubmitResult::RejectedDecode;
if (ingress.code != particle::ingress::EncodedFieldIngressCode::Accepted)
@@ -499,16 +498,19 @@ void RasterArraySource::refreshV4RendererState() {
if (colorBinding) {
const auto resolved = resolveColorProjection();
if (const auto materialized = resolved.accepted() ? particle::materializeColorProjection(*resolved.projection)
: std::nullopt)
: std::nullopt)
next.color = std::make_shared<const particle::MaterializedColorProjection>(std::move(*materialized));
if (next.color) particle::incrementRendererDebugCounter(particle::RendererDebugCounter::V4ProjectionMaterialize);
if (next.color)
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::V4ProjectionMaterialize);
}
if (particleBinding) {
const auto resolved = resolveParticleProjection();
if (const auto materialized = resolved.accepted() ? particle::materializeParticleProjection(*resolved.projection)
: std::nullopt)
if (const auto materialized = resolved.accepted()
? particle::materializeParticleProjection(*resolved.projection)
: std::nullopt)
next.particle = std::make_shared<const particle::MaterializedParticleProjection>(std::move(*materialized));
if (next.particle) particle::incrementRendererDebugCounter(particle::RendererDebugCounter::V4ProjectionMaterialize);
if (next.particle)
particle::incrementRendererDebugCounter(particle::RendererDebugCounter::V4ProjectionMaterialize);
}
const auto& current = impl().v4RendererState;
if (sameProjection(current.color, next.color) && sameProjection(current.particle, next.particle)) return;
@@ -691,7 +693,7 @@ void RasterArraySource::tryLoad() {
" dataVersion=" + std::to_string(loadedField.dataVersion));
if (impl().nextField) {
Log::Info(Event::Render,
"ParticleField: source loaded next fieldId=" + impl().nextField->fieldID +
"ParticleField: source loaded next fieldId=" + impl().nextField->fieldID +
" generation=" + std::to_string(impl().nextField->generation) +
" dataVersion=" + std::to_string(impl().nextField->dataVersion));
}
@@ -715,7 +717,7 @@ void RasterArraySource::tryLoadRuntimeField(const size_t index) {
candidate.snapshot = particle::OfflineFixtureParticleAdapter::load(candidate.descriptor.fixtureID,
*candidate.metadata,
*candidate.fieldBytes,
++nextRasterArrayFieldGeneration);
++nextRasterArrayFieldGeneration);
Log::Info(Event::Render,
"ParticleField: runtime candidate ready fieldId=" + candidate.snapshot->fieldID +
" dataVersion=" + std::to_string(candidate.snapshot->dataVersion));
@@ -725,8 +727,8 @@ void RasterArraySource::tryLoadRuntimeField(const size_t index) {
"ParticleField: event=ADAPTER_REJECTED sourceId=" + getID() +
" candidateIndex=" + std::to_string(index) + " fieldId=" + candidate.descriptor.fixtureID +
" generation=" + std::to_string(candidate.snapshot ? candidate.snapshot->generation : 0) +
" validTime=" + std::to_string(candidate.snapshot ? candidate.snapshot->validTime : 0) +
" error=" + error.what());
" validTime=" + std::to_string(candidate.snapshot ? candidate.snapshot->validTime : 0) +
" error=" + error.what());
observer->onSourceError(*this, std::make_exception_ptr(std::runtime_error(error.what())));
}
}
@@ -747,9 +749,9 @@ void RasterArraySource::tryLoadLocalField(const size_t index) {
candidate.snapshot = std::move(*result.snapshot);
Log::Info(Event::Render,
"ParticleField: event=PRODUCTION_FEED_LOAD fieldId=" + candidate.snapshot->fieldID +
" generation=" + std::to_string(candidate.snapshot->generation) +
" validTime=" + std::to_string(candidate.snapshot->validTime) +
" payloadBytes=" + std::to_string(candidate.fieldBytes->size()));
" generation=" + std::to_string(candidate.snapshot->generation) +
" validTime=" + std::to_string(candidate.snapshot->validTime) +
" payloadBytes=" + std::to_string(candidate.fieldBytes->size()));
tryInitializeTimeline();
}
@@ -796,7 +798,7 @@ void RasterArraySource::tryInitializeTimeline() {
auto field = *runtimeFields[index].snapshot;
entries.push_back({std::make_shared<particle::ParticleFieldSnapshot>(std::move(field)),
runtimeFields[index].descriptor.timestamp ? runtimeFields[index].descriptor.timestamp
: 80000 + static_cast<uint64_t>(index) * 40000});
: 80000 + static_cast<uint64_t>(index) * 40000});
}
try {
timeline = std::make_shared<RasterArrayTimelineRuntime>(std::move(entries));
@@ -1112,7 +1114,7 @@ bool RasterArraySource::seekRuntimeTimelineToTimestamp(const uint64_t timestamp)
if (!accepted) {
Log::Warning(Event::Render,
"WeatherRuntime: event=SEEK_TIMESTAMP_REJECTED reason=OUT_OF_RANGE sourceId=" + getID() +
" timestamp=" + std::to_string(timestamp));
" timestamp=" + std::to_string(timestamp));
}
return accepted;
}

View File

@@ -124,8 +124,8 @@ particle::ResolvedFieldProfileContract windContract() {
result.profileVersion = 1;
result.requiredPlanes = {
{1, particle::FieldValueClass::VelocityComponent, 1, 1, particle::FieldComponent::UEast, true},
{2, particle::FieldValueClass::VelocityComponent, 1, 1, particle::FieldComponent::VNorth, true},
{3, particle::FieldValueClass::Validity, 0, 0, particle::FieldComponent::None, true}};
{2, particle::FieldValueClass::VelocityComponent, 1, 1, particle::FieldComponent::VNorth, true},
{3, particle::FieldValueClass::Validity, 0, 0, particle::FieldComponent::None, true}};
return result;
}
particle::ResolvedFieldProfileContract waveContract() {
@@ -414,6 +414,7 @@ TEST(Source, RasterArrayV4RendererStateMaterializesBindingsAndMixWithoutTouching
EXPECT_EQ(particles.t0->vector, (std::vector<float>{1, 5, 2, 6, 3, 7, 4, 8}));
EXPECT_EQ(particles.t0->valid, (std::vector<uint8_t>{1, 1, 1, 1}));
EXPECT_EQ(color.t0->scalar, (std::vector<float>{1, 2, 3, 4}));
EXPECT_EQ(color.valueSource, particle::MaterializedColorValueSource::Scalar);
const auto particleIdentity = particles.contentIdentity;
const auto retainedParticleT0 = particles.t0;
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 4));
@@ -429,6 +430,46 @@ TEST(Source, RasterArrayV4RendererStateMaterializesBindingsAndMixWithoutTouching
EXPECT_FALSE(source.impl().v4RendererState.particle);
EXPECT_FALSE(source.impl().v4RendererState.color);
}
TEST(Source, RasterArrayV4VelocityColorReconstructsDecodedUVIndependentlyOfParticles) {
style::RasterArraySource source{"v4-vector-color"};
submit(source, "wind", "wind", Base, 1);
submit(source, "wind", "wind", Base + Hour, 2);
source.setColorBinding(
{"wind", {1, 1, particle::FieldComponent::UEast}, particle::FieldValueClass::VelocityComponent, 1});
ASSERT_TRUE(source.setRequestedForecastTime(Base + Hour / 2));
ASSERT_TRUE(source.impl().v4RendererState.color);
EXPECT_FALSE(source.impl().v4RendererState.particle);
const auto& colorWithoutParticles = *source.impl().v4RendererState.color;
EXPECT_EQ(colorWithoutParticles.valueSource, particle::MaterializedColorValueSource::VectorMagnitude);
ASSERT_TRUE(colorWithoutParticles.t0);
ASSERT_TRUE(colorWithoutParticles.t1);
EXPECT_TRUE(colorWithoutParticles.t0->hasVector());
EXPECT_FALSE(colorWithoutParticles.t0->hasScalar());
EXPECT_EQ(colorWithoutParticles.t0->vector, (std::vector<float>{1, 5, 2, 6, 3, 7, 4, 8}));
EXPECT_EQ(colorWithoutParticles.t0->valid, (std::vector<uint8_t>{1, 1, 1, 1}));
EXPECT_FLOAT_EQ(colorWithoutParticles.sampleState.mix, 0.5f);
source.setParticleBinding({particle::ParticleBindingMode::VectorComponents,
"wind",
{1, 1, particle::FieldComponent::UEast},
{2, 1, particle::FieldComponent::VNorth},
{},
1});
ASSERT_TRUE(source.impl().v4RendererState.color);
ASSERT_TRUE(source.impl().v4RendererState.particle);
const auto& color = *source.impl().v4RendererState.color;
const auto& particles = *source.impl().v4RendererState.particle;
EXPECT_EQ(color.valueSource, particle::MaterializedColorValueSource::VectorMagnitude);
EXPECT_EQ(color.t0->vector, particles.t0->vector);
EXPECT_EQ(color.t1->vector, particles.t1->vector);
EXPECT_EQ(color.t0->valid, particles.t0->valid);
EXPECT_FALSE(source.impl().field);
EXPECT_FALSE(source.impl().nextField);
EXPECT_FALSE(source.impl().timeline);
}
TEST(Source, RasterArrayV4MaterializerUsesExplicitLittleEndianScaleOffsetAndStableRevision) {
const auto t0 = materializerFrame("scalar",
Base,
@@ -457,8 +498,8 @@ TEST(Source, RasterArrayV4MaterializerUsesExplicitLittleEndianScaleOffsetAndStab
particle::ColorBinding binding{
"scalar", {99, 0, particle::FieldComponent::None}, particle::FieldValueClass::Scalar, 77};
particle::ColorProjection projection;
projection.contentIdentity =
{binding, {"scalar", Base}, {"scalar", Base + Hour}, "plane-t0", "plane-t1", "validity-t0", "validity-t1"};
projection.contentIdentity = {
binding, {"scalar", Base}, {"scalar", Base + Hour}, "plane-t0", "plane-t1", "validity-t0", "validity-t1"};
projection.sampleState = {Base + Hour / 2, 0.5f};
projection.t0Frame = t0;
projection.t1Frame = t1;

View File

@@ -102,8 +102,10 @@ build_release() {
(cd "$ROOT" && bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic)
IOS_SOURCE="$ROOT/bazel-out/darwin_x86_64-fastbuild/bin/platform/ios/$IOS_ZIP_NAME"
[[ -f "$IOS_SOURCE" ]] || die "iOS XCFramework zip missing"
unzip -Z1 "$IOS_SOURCE" | grep -Fq "/ios-arm64/" || die "iOS XCFramework missing ios-arm64 device slice"
if unzip -Z1 "$IOS_SOURCE" | grep -Eiq "simulator|ios-arm64_x86_64"; then
local ios_listing
ios_listing="$(unzip -Z1 "$IOS_SOURCE")"
grep -Fq "/ios-arm64/" <<<"$ios_listing" || die "iOS XCFramework missing ios-arm64 device slice"
if grep -Eiq "simulator|ios-arm64_x86_64" <<<"$ios_listing"; then
die "iOS XCFramework unexpectedly contains simulator slice"
fi
cp "$IOS_SOURCE" "$ARTIFACT_DIR/$IOS_ZIP_NAME"