diff --git a/docs/50_Step记录/STEP-082_iOS_Native_FieldRuntime_Facade与XCFramework发行基线_v1.md b/docs/50_Step记录/STEP-082_iOS_Native_FieldRuntime_Facade与XCFramework发行基线_v1.md index 9f401e498..c22e0be32 100644 --- a/docs/50_Step记录/STEP-082_iOS_Native_FieldRuntime_Facade与XCFramework发行基线_v1.md +++ b/docs/50_Step记录/STEP-082_iOS_Native_FieldRuntime_Facade与XCFramework发行基线_v1.md @@ -32,3 +32,9 @@ ## 下一步 STEP-082 已关闭。远端发布仍未执行,manifest 的 artifact URL 保持 `LOCAL_BUILD`;后续发布必须另行上传并以发布后的 URL/校验结果更新 release 元数据。STEP-083 不因本记录自动开始。 + +## STEP-083 post-close correction(2026-09-18) + +本段追加纠正,不改写上方当时的历史记录。后续真实 RN device 验证确认,上方使用裸 `bazel build //platform/ios:MapLibre.dynamic` 取得的默认 OpenGL artifact(`24639200` bytes,SHA-256=`64faa174c5c0dc0a2da3e289f0beaf2dedf7298665e3345f39836729b668e239`,source commit `136ed726…`)只有色场、没有 Particle render,故作废且不再作为 iOS Particle 发行基线。 + +iOS Particle 发行固定改用 `bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic`。Metal XCFramework 为 `24943196` bytes,SHA-256=`ae88975fc20f4d9961b95079638d0e9c0d8e61d4394d5c0a8ffcc48d173a827f`,source commit=`bdd7f40f9029031816c9bf4db6e1cd6c7c69b7bd`。其 device binary 含 `MLNMapView`、Metal/`MTLDevice`/`ParticleGPU` 标记且无 OpenGL marker;Pleiades iPad mini 5 RN Test Host signed Release 安装后,用户已确认粒子恢复。artifact 仍为本地构建,URL 保持 `LOCAL_BUILD`。 diff --git a/docs/50_Step记录/STEP-083_iOS_RN统一FieldRuntime_TurboModule_Fabric与XCFramework消费_v1.md b/docs/50_Step记录/STEP-083_iOS_RN统一FieldRuntime_TurboModule_Fabric与XCFramework消费_v1.md new file mode 100644 index 000000000..61ee9eee5 --- /dev/null +++ b/docs/50_Step记录/STEP-083_iOS_RN统一FieldRuntime_TurboModule_Fabric与XCFramework消费_v1.md @@ -0,0 +1,40 @@ +# STEP-083 iOS RN 统一 FieldRuntime / TurboModule / Fabric 与 XCFramework 消费 v1 + +状态:`IMPLEMENTED / IOS_CORE_PATH_PASS / WAITING_FINAL_CONTROL_REGRESSION` + +## 基线与范围 + +- branch:`particle/main`;开始 HEAD:`bdd7f40f9029031816c9bf4db6e1cd6c7c69b7bd`。 +- STEP-082 public facade 与 `//platform/ios:MapLibre.dynamic` Bazel build 已由 RDC 真实验证 PASS。 +- 本批次仅增加 RN package iOS bridge/pod/codegen/autolinking、Test Host iOS identity 和记录;没有修改 Android、Shared Core、QBIN contract、renderer、NavSea 或 frozen TS public API。 + +## 已实现 + +- `WeatherRuntimeBridge` 是 RN 0.85.2 codegen TurboModule:`NativeWeatherRuntimeBridgeSpecJSI`、Promise `resolve/reject` ABI 及 package `ios.modulesProvider` 均使用当前本地 RN 0.85.2 source 的真实 pattern。 +- 每个 Map command 先校验正整数 `reactTag`,再通过 RN 注入的当前 Fabric/UIManager `viewRegistry_DEPRECATED` 进入 UI block,重新确认 `MLRNMapViewComponentView` → `contentView` → `MLRNMapView` → public `MLNMapView`。不缓存 tag、map、Core pointer 或建立第二 registry。 +- V3、V4、runtime state、weather/particle visibility、timeline、binding 与所有视觉命令均映射到现有 public `MLNParticleFieldRuntime`。V3 `clearWindFields` 的 Android 语义本身等价 `clearFields`。 +- V3/QBIN file ingress 均只接收 absolute path 或 `file://`,要求 readable regular file、1..64 MiB,并比较读前后 length;metadata、contract、UTF-8 identifier、safe integer 与 inactive `(0,0)` selector 同 Android guard。 +- pod 只 dependency `MapLibreReactNative 11.3.6`,不声明 MapLibre pod/framework/SPM product。MLRN 11.3.6 的真实 `$MLRN_SPM_SPEC` 将唯一 `MapLibre` product 注入 Pods 和 app target。 + +## RDC 实际 iOS 验证(2026-09-18) + +- RDC 已实际执行 npm dependency install(`npm ci`)、typecheck、RN codegen、Expo prebuild、Pod integration、device arm64 build、signed Release build 与安装。MapLibre.framework 签名成功。 +- Test Host 消费的是 single MapLibre SPM product;未发现第二份 MapLibre binary。新 XCFramework 必须以 Metal 命令 `bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic` 生成。默认裸 Bazel 命令的 OpenGL artifact 只有色场、无粒子,已由 STEP-082 post-close correction 作废。 +- Pleiades iPad mini 5 上 signed Release 安装运行。一次 V4 Wind load 实际观察到 `clearAllEncodedFields` → color binding → particle binding → T0 `ACCEPTED_INSERTED` → T1 `ACCEPTED_INSERTED` → midpoint seek;用户确认粒子恢复。 + +## 尚未逐按钮回归 + +- PLAY/PAUSE、clear selected、clear all、detach/re-attach 尚未逐按钮实测;不得将本 Step 标为完成,也不得把单次 V4 load 扩写为完整控制面验收。 + +## 已执行检查 + +- `ruby -c packages/maplibre-particle-rn/MapLibreParticleRN.podspec`:成功。 +- `npm ci`、typecheck、RN codegen、Expo prebuild、Pod integration、device arm64 build、signed Release build/installation:均由 RDC 实际执行;Release 日志为 `BUILD SUCCEEDED`。 +- `npm pack --dry-run --json --cache /private/tmp/step083-npm-cache`:成功,tgz 清单包含 podspec、`ios/WeatherRuntimeBridge.h/.mm`、既有 Android 与 `src`。 +- package `codegenConfig.ios.modulesProvider.WeatherRuntimeBridge` 静态检查和 Native Spec 34 个方法与 ObjC++ 34 个实现逐项计数完成。 + +## RDC 下一步 + +1. 用同一个 `App.tsx` 逐按钮完成 PLAY/PAUSE、clear selected/all、detach/re-attach;同时复核 style-not-ready smoke。 +2. 继续维持 MLRN/App/Particle 的 single `MapLibre` product,禁止为 Particle pod 引入独立 framework。 +3. 所有后续 iOS Particle artifact 一律以 Metal Bazel 命令构建,并在发布前复核 hash、renderer markers 与远端回读(若有发布授权)。 diff --git a/docs/报告/STEP-082_iOS_Native_FieldRuntime_Facade与XCFramework发行基线_Codex完成报告_v3.md b/docs/报告/STEP-082_iOS_Native_FieldRuntime_Facade与XCFramework发行基线_Codex完成报告_v3.md new file mode 100644 index 000000000..18458ae55 --- /dev/null +++ b/docs/报告/STEP-082_iOS_Native_FieldRuntime_Facade与XCFramework发行基线_Codex完成报告_v3.md @@ -0,0 +1,24 @@ +# STEP-082 iOS Native FieldRuntime Facade 与 XCFramework 发行基线 Codex 完成报告 v3 + +## 执行结论 + +`IMPLEMENTED / IOS_CORE_PATH_PASS / WAITING_FINAL_CONTROL_REGRESSION`。 + +本报告是 STEP-083 发现 renderer 选择错误后的 post-close correction;保留 v1/v2 的历史证据,但旧默认 OpenGL XCFramework 不再具有 iOS Particle 发行资格。 + +## Renderer 纠正与发行 artifact + +- 不带 renderer flag 的 `bazel build //platform/ios:MapLibre.dynamic` 默认选择 OpenGL。旧 zip:`24639200` bytes,SHA-256=`64faa174c5c0dc0a2da3e289f0beaf2dedf7298665e3345f39836729b668e239`,source commit=`136ed726cf749c24b3673c046581a38ef48be390`。它在 RN Test Host 中可显示色场但无粒子,因此作废,不能发布为 Particle iOS baseline。 +- 固定发行命令:`bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic`。 +- 新 Metal zip:`MapLibre.dynamic.xcframework.zip`,`24943196` bytes,SHA-256=`ae88975fc20f4d9961b95079638d0e9c0d8e61d4394d5c0a8ffcc48d173a827f`,source commit=`bdd7f40f9029031816c9bf4db6e1cd6c7c69b7bd`。 +- device binary 审计含 `MLNMapView`、Metal/`MTLDevice`/`ParticleGPU`,无 OpenGL marker。release manifest 保持 `VALIDATED`、URL=`LOCAL_BUILD`;未伪造远端发布。 + +## 真实消费证据与边界 + +Pleiades iPad mini 5 上,RN Test Host 的 single-MapLibre SPM signed Release 已构建、MapLibre.framework 已签名并安装。一次 V4 Wind load 实际执行 `clearAllEncodedFields`、color/particle binding、T0/T1 `ACCEPTED_INSERTED` 和 midpoint seek,用户确认“粒子恢复了”。 + +本次未逐按钮完成 PLAY/PAUSE、clear selected/all、detach/re-attach 控制回归;这部分保留给 STEP-083 final control regression,不能据此扩展为完整交互验收。 + +## 范围与 Git + +新 Metal binary 的可追溯 source commit 是已提交 facade baseline `bdd7f40f9029031816c9bf4db6e1cd6c7c69b7bd`;RN/Test Host 的未提交文件不影响该 MapLibre binary 内容。本报告不提交、不 push,且未修改 Android、Shared Core、renderer 算法或 `NativeWeatherRuntimeBridge.ts`。 diff --git a/docs/报告/STEP-083_iOS_RN统一FieldRuntime_TurboModule_Fabric与XCFramework消费_Codex完成报告_v1.md b/docs/报告/STEP-083_iOS_RN统一FieldRuntime_TurboModule_Fabric与XCFramework消费_Codex完成报告_v1.md new file mode 100644 index 000000000..af4c773a0 --- /dev/null +++ b/docs/报告/STEP-083_iOS_RN统一FieldRuntime_TurboModule_Fabric与XCFramework消费_Codex完成报告_v1.md @@ -0,0 +1,47 @@ +# STEP-083 iOS RN 统一 FieldRuntime / TurboModule / Fabric 与 XCFramework 消费 Codex 完成报告 v1 + +## 1. 执行结论 + +`IMPLEMENTED / WAITING_IOS_VALIDATION`。未提交、未 push,最终环境验收交给 RDC。 + +## 2. 调查结论 + +- MLRN `11.3.6`:Fabric class 是 `MLRNMapViewComponentView`;它在 `prepareView` 设 `contentView = [[MLRNMapView alloc] ...]`;`MLRNMapView : MLNMapView`。 +- MLRN 当前 module 的真实 lookup 是注入 `viewRegistry_DEPRECATED` 的 `addUIBlock`,在 block 内按 tag 查 component,再验证 component/content classes。bridge 同样使用该当前 RN UIManager registry;没有复制它或保留 view pointer。 +- RN `0.85.2` generated ObjC++ spec 的 module pattern 为 protocol + `getTurboModule:` 返回 `std::make_shared<...SpecJSI>(params)`,Promise selector 使用 `resolve:(RCTPromiseResolveBlock) reject:(RCTPromiseRejectBlock)`;`codegenConfig.ios.modulesProvider` 生成 provider registration。 +- MLRN podspec 的 `$MLRN_SPM_SPEC` 确实存在,默认 product 为 `MapLibre`;post-install 将其加入 MLRN pod target 与 user target。 + +## 3. Public API parity matrix + +| Spec methods | iOS public seam | status | +| --- | --- | --- | +| attach, detach, getState | `MLNMapView` category + runtime snapshot | implemented | +| clearWindFields, clearFields, submitWindField, submitField | V3 runtime facade | implemented | +| submitEncodedField, encoded binding/time/playback/clear (8) | V4 runtime facade | implemented | +| value source, advection, enabled/visibility, FPS/time/playback/speed (9) | V3 control facade | implemented | +| color stops, particle/trail color, count, line width, length, opacities, fade (8) | visual facade | implemented | + +The 34 `NativeWeatherRuntimeBridge.ts` methods are present as 34 ObjC++ selectors. No TS public type, union, method name, argument ordering, result string, or Android implementation changed; only the Android-specific missing-module text became platform-neutral. + +## 4. Package and binary identity + +- Added `ios/WeatherRuntimeBridge.h/.mm`, `MapLibreParticleRN.podspec`, iOS autolinking and codegen provider configuration; npm files include all of them. +- The Particle pod depends only on `MapLibreReactNative` `11.3.6`; it has no MapLibre dependency, vendored framework, or SPM reference. Thus it cannot introduce a second MapLibre product. +- The Test Host gained only an iOS bundle identifier. Its `App.tsx` retains one shared JS path and no `Platform.OS` branch. + +## 5. Checks actually run + +- Podspec Ruby syntax: PASS. +- package dry-run tarball, with temporary cache: PASS; includes iOS native files, podspec, Android, and src. +- codegen provider configuration static assertion: PASS. +- Spec/implementation method-count parity: 34/34 PASS. +- `git diff --check`: PASS. +- `npm ci --offline --ignore-scripts`: BLOCKED by absent locally cached `update-browserslist-db-1.3.2.tgz`; no network was used. Therefore TypeScript/codegen/Xcode/Pod/Test Host runtime have not run. + +## 6. Required RDC validation + +The STEP-082 XCFramework artifact is a local zip and its manifest URL is `LOCAL_BUILD`; it is not yet an MLRN-consumable Swift Package distribution. Configure a real custom `$MLRN_SPM_SPEC` coordinate first, then run codegen/typecheck/Pods/Xcode and Test Host smoke. Check a single MapLibre product/class set and the V4 lifecycle sequence before closing STEP-083. + +## 7. Working tree protection + +The user dirty entries `docs/doxygen/doxygen-awesome-css`, `platform/ios/app/MBXAppDelegate.m`, and `platform/windows/vendor/vcpkg`, plus the two pre-existing STEP-082 task files, were not touched. No Git commit or push was performed. diff --git a/docs/报告/STEP-083_iOS_RN统一FieldRuntime_TurboModule_Fabric与XCFramework消费_Codex完成报告_v2.md b/docs/报告/STEP-083_iOS_RN统一FieldRuntime_TurboModule_Fabric与XCFramework消费_Codex完成报告_v2.md new file mode 100644 index 000000000..3d80907f2 --- /dev/null +++ b/docs/报告/STEP-083_iOS_RN统一FieldRuntime_TurboModule_Fabric与XCFramework消费_Codex完成报告_v2.md @@ -0,0 +1,37 @@ +# STEP-083 iOS RN 统一 FieldRuntime TurboModule Fabric 与 XCFramework 消费 Codex 完成报告 v2 + +## 执行结论 + +`IMPLEMENTED / IOS_CORE_PATH_PASS / WAITING_FINAL_CONTROL_REGRESSION`。 + +未提交、未 push。本报告只记录 RDC 已发生的 iOS consumer 验证和 Metal artifact 纠正;它不把未逐按钮测试的控制面写成完成。 + +## Git 基线与范围 + +- branch:`particle/main`;MapLibre Metal binary source commit:`bdd7f40f9029031816c9bf4db6e1cd6c7c69b7bd`。 +- RN/Test Host 未提交文件不影响该已提交 MapLibre binary 内容。 +- 未修改 `NativeWeatherRuntimeBridge.ts`、Android、Shared Core 或 renderer 算法。 +- 用户既有 dirty `docs/doxygen/doxygen-awesome-css`、`platform/ios/app/MBXAppDelegate.m`、`platform/windows/vendor/vcpkg` 与三份未跟踪 `docs/40_任务` 任务书均不纳入本次修改或提交范围。 + +## Renderer 与 artifact + +旧 `bazel build //platform/ios:MapLibre.dynamic` 默认使用 OpenGL。其 artifact 在 RN Test Host 中呈现色场而无粒子,不能用于 Particle iOS 发行。 + +固定命令为: + +```sh +bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic +``` + +新 Metal `MapLibre.dynamic.xcframework.zip`:`24943196` bytes,SHA-256=`ae88975fc20f4d9961b95079638d0e9c0d8e61d4394d5c0a8ffcc48d173a827f`。device binary 含 `MLNMapView`、Metal/`MTLDevice`/`ParticleGPU`,无 OpenGL marker。manifest 为 `VALIDATED`,URL 仍为 `LOCAL_BUILD`,未声明远端发布。 + +## RDC 已发生验证 + +- npm dependency install(`npm ci`)、typecheck、RN codegen、Expo prebuild 和 Pod integration 均已实际执行。 +- device arm64 build 与 signed Release 均出现 `BUILD SUCCEEDED`;MapLibre.framework 签名成功,Pleiades iPad mini 5 安装运行。 +- MLRN、App 与 Particle pod 共享 single MapLibre SPM product,没有第二份 MapLibre binary。 +- 一次 V4 Wind load 实际完成 `clearAllEncodedFields`、color binding、particle binding、T0 `ACCEPTED_INSERTED`、T1 `ACCEPTED_INSERTED` 和 midpoint seek;用户确认“粒子恢复了”。 + +## 未完成的 final control regression + +PLAY/PAUSE、clear selected、clear all、detach/re-attach 尚未逐按钮实测。它们是本 Step 的剩余验收项;在完成前不得将状态改写为 `CLOSED` 或 `PASS`。 diff --git a/integration/react-native/MapLibreParticleRNTestHost/.gitignore b/integration/react-native/MapLibreParticleRNTestHost/.gitignore new file mode 100644 index 000000000..1a1c29a93 --- /dev/null +++ b/integration/react-native/MapLibreParticleRNTestHost/.gitignore @@ -0,0 +1,2 @@ +# Expo/RN prebuild output is generated locally and must not enter this source host. +/ios/ diff --git a/integration/react-native/MapLibreParticleRNTestHost/app.json b/integration/react-native/MapLibreParticleRNTestHost/app.json index 15a6be12c..ae970bfb0 100644 --- a/integration/react-native/MapLibreParticleRNTestHost/app.json +++ b/integration/react-native/MapLibreParticleRNTestHost/app.json @@ -6,6 +6,7 @@ "orientation": "landscape", "newArchEnabled": true, "plugins": ["./plugins/withParticleNativeAndroid"], - "android": { "package": "org.maplibre.particle.rntesthost", "minSdkVersion": 24 } + "android": { "package": "org.maplibre.particle.rntesthost", "minSdkVersion": 24 }, + "ios": { "bundleIdentifier": "org.maplibre.particle.rntesthost", "supportsTablet": true } } } diff --git a/integration/react-native/MapLibreParticleRNTestHost/metro.config.js b/integration/react-native/MapLibreParticleRNTestHost/metro.config.js index c3b364191..4fe21eec2 100644 --- a/integration/react-native/MapLibreParticleRNTestHost/metro.config.js +++ b/integration/react-native/MapLibreParticleRNTestHost/metro.config.js @@ -5,10 +5,23 @@ const { getDefaultConfig } = require("expo/metro-config"); const config = getDefaultConfig(__dirname); config.resolver.assetExts = [...new Set([...config.resolver.assetExts, "f32", "u8", "qbin"])]; +const packageRoot = path.resolve(__dirname, "../../../packages/maplibre-particle-rn"); const fixtureCandidates = [ path.resolve(__dirname, "..", "particle-v3-fields"), path.resolve(__dirname, "..", "..", "particle-v3-fields"), ]; -config.watchFolders = fixtureCandidates.filter((candidate) => fs.existsSync(candidate)); +config.watchFolders = [ + ...new Set([ + ...(config.watchFolders ?? []), + ...fixtureCandidates.filter((candidate) => fs.existsSync(candidate)), + packageRoot, + ]), +]; +config.resolver.nodeModulesPaths = [ + ...new Set([ + path.resolve(__dirname, "node_modules"), + ...(config.resolver.nodeModulesPaths ?? []), + ]), +]; module.exports = config; diff --git a/integration/react-native/MapLibreParticleRNTestHost/src/fieldDatasets.ts b/integration/react-native/MapLibreParticleRNTestHost/src/fieldDatasets.ts index 3f9fb735a..c94a43fc7 100644 --- a/integration/react-native/MapLibreParticleRNTestHost/src/fieldDatasets.ts +++ b/integration/react-native/MapLibreParticleRNTestHost/src/fieldDatasets.ts @@ -14,20 +14,20 @@ function two(id: string, title: string, fieldValueSource: V3Dataset["fieldValueS } export const fieldDatasets: readonly V3Dataset[] = [ two("wind-real", "Wind(本地真实 canonical)", "VECTOR_MAGNITUDE", true, 1, - frame("T0", require("../../particle-v3-fields/wind-real/2026080700/metadata.json") as Metadata, require("../../particle-v3-fields/wind-real/2026080700/validity.u8"), undefined, require("../../particle-v3-fields/wind-real/2026080700/vector.f32")), - frame("T1", require("../../particle-v3-fields/wind-real/2026080706/metadata.json") as Metadata, require("../../particle-v3-fields/wind-real/2026080706/validity.u8"), undefined, require("../../particle-v3-fields/wind-real/2026080706/vector.f32"))), + frame("T0", require("../../../particle-v3-fields/wind-real/2026080700/metadata.json") as Metadata, require("../../../particle-v3-fields/wind-real/2026080700/validity.u8"), undefined, require("../../../particle-v3-fields/wind-real/2026080700/vector.f32")), + frame("T1", require("../../../particle-v3-fields/wind-real/2026080706/metadata.json") as Metadata, require("../../../particle-v3-fields/wind-real/2026080706/validity.u8"), undefined, require("../../../particle-v3-fields/wind-real/2026080706/vector.f32"))), two("rain-decoder-fixture", "Rain(decoder fallback)", "SCALAR", false, 1, - frame("T0", require("../../particle-v3-fields/rain-decoder-fixture/2026090912/metadata.json") as Metadata, require("../../particle-v3-fields/rain-decoder-fixture/2026090912/validity.u8"), require("../../particle-v3-fields/rain-decoder-fixture/2026090912/scalar.f32")), - frame("T1", require("../../particle-v3-fields/rain-decoder-fixture/2026090918/metadata.json") as Metadata, require("../../particle-v3-fields/rain-decoder-fixture/2026090918/validity.u8"), require("../../particle-v3-fields/rain-decoder-fixture/2026090918/scalar.f32"))), + frame("T0", require("../../../particle-v3-fields/rain-decoder-fixture/2026090912/metadata.json") as Metadata, require("../../../particle-v3-fields/rain-decoder-fixture/2026090912/validity.u8"), require("../../../particle-v3-fields/rain-decoder-fixture/2026090912/scalar.f32")), + frame("T1", require("../../../particle-v3-fields/rain-decoder-fixture/2026090918/metadata.json") as Metadata, require("../../../particle-v3-fields/rain-decoder-fixture/2026090918/validity.u8"), require("../../../particle-v3-fields/rain-decoder-fixture/2026090918/scalar.f32"))), two("wave-real", "Wave(本地真实 WAVEBIN1)", "SCALAR", true, 1.75, - frame("T0", require("../../particle-v3-fields/wave-real/2026090918/metadata.json") as Metadata, require("../../particle-v3-fields/wave-real/2026090918/validity.u8"), require("../../particle-v3-fields/wave-real/2026090918/scalar.f32"), require("../../particle-v3-fields/wave-real/2026090918/vector.f32")), - frame("T1", require("../../particle-v3-fields/wave-real/2026090919/metadata.json") as Metadata, require("../../particle-v3-fields/wave-real/2026090919/validity.u8"), require("../../particle-v3-fields/wave-real/2026090919/scalar.f32"), require("../../particle-v3-fields/wave-real/2026090919/vector.f32"))), + frame("T0", require("../../../particle-v3-fields/wave-real/2026090918/metadata.json") as Metadata, require("../../../particle-v3-fields/wave-real/2026090918/validity.u8"), require("../../../particle-v3-fields/wave-real/2026090918/scalar.f32"), require("../../../particle-v3-fields/wave-real/2026090918/vector.f32")), + frame("T1", require("../../../particle-v3-fields/wave-real/2026090919/metadata.json") as Metadata, require("../../../particle-v3-fields/wave-real/2026090919/validity.u8"), require("../../../particle-v3-fields/wave-real/2026090919/scalar.f32"), require("../../../particle-v3-fields/wave-real/2026090919/vector.f32"))), two("tide-real", "Tide/Current(本地真实 BIN)", "VECTOR_MAGNITUDE", true, 1, - frame("T0", require("../../particle-v3-fields/tide-real/2026090901/metadata.json") as Metadata, require("../../particle-v3-fields/tide-real/2026090901/validity.u8"), undefined, require("../../particle-v3-fields/tide-real/2026090901/vector.f32")), - frame("T1", require("../../particle-v3-fields/tide-real/2026090902/metadata.json") as Metadata, require("../../particle-v3-fields/tide-real/2026090902/validity.u8"), undefined, require("../../particle-v3-fields/tide-real/2026090902/vector.f32"))), + frame("T0", require("../../../particle-v3-fields/tide-real/2026090901/metadata.json") as Metadata, require("../../../particle-v3-fields/tide-real/2026090901/validity.u8"), undefined, require("../../../particle-v3-fields/tide-real/2026090901/vector.f32")), + frame("T1", require("../../../particle-v3-fields/tide-real/2026090902/metadata.json") as Metadata, require("../../../particle-v3-fields/tide-real/2026090902/validity.u8"), undefined, require("../../../particle-v3-fields/tide-real/2026090902/vector.f32"))), two("wave-synthetic-independence", "Wave synthetic independence", "SCALAR", true, 2.5, - frame("T0", require("../../particle-v3-fields/wave-synthetic-independence/2026010100/metadata.json") as Metadata, require("../../particle-v3-fields/wave-synthetic-independence/2026010100/validity.u8"), require("../../particle-v3-fields/wave-synthetic-independence/2026010100/scalar.f32"), require("../../particle-v3-fields/wave-synthetic-independence/2026010100/vector.f32")), - frame("T1", require("../../particle-v3-fields/wave-synthetic-independence/2026010106/metadata.json") as Metadata, require("../../particle-v3-fields/wave-synthetic-independence/2026010106/validity.u8"), require("../../particle-v3-fields/wave-synthetic-independence/2026010106/scalar.f32"), require("../../particle-v3-fields/wave-synthetic-independence/2026010106/vector.f32"))), + frame("T0", require("../../../particle-v3-fields/wave-synthetic-independence/2026010100/metadata.json") as Metadata, require("../../../particle-v3-fields/wave-synthetic-independence/2026010100/validity.u8"), require("../../../particle-v3-fields/wave-synthetic-independence/2026010100/scalar.f32"), require("../../../particle-v3-fields/wave-synthetic-independence/2026010100/vector.f32")), + frame("T1", require("../../../particle-v3-fields/wave-synthetic-independence/2026010106/metadata.json") as Metadata, require("../../../particle-v3-fields/wave-synthetic-independence/2026010106/validity.u8"), require("../../../particle-v3-fields/wave-synthetic-independence/2026010106/scalar.f32"), require("../../../particle-v3-fields/wave-synthetic-independence/2026010106/vector.f32"))), ]; async function uri(module: number, label: string): Promise { diff --git a/packages/maplibre-particle-rn/CHANGELOG.md b/packages/maplibre-particle-rn/CHANGELOG.md index 85e61d638..3843527aa 100644 --- a/packages/maplibre-particle-rn/CHANGELOG.md +++ b/packages/maplibre-particle-rn/CHANGELOG.md @@ -1,5 +1,11 @@ # 变更记录 +## Unreleased + +- 新增 iOS `WeatherRuntimeBridge` RN 0.85 codegen TurboModule,实现既有 V3/V4 与视觉控制 ABI;每个命令从当前 Fabric/UIManager view registry 重新解析 MLRN MapView,不缓存 map/runtime 指针。 +- 新增 iOS pod/autolinking/codegen provider。pod 复用 `MapLibreReactNative` 的唯一 MapLibre product,不嵌入第二份 framework。 +- iOS Podfile 的 post-install 现在必须同时调用 `$MLRN.post_install(installer)` 和 `$MLRN_PARTICLE.post_install(installer)`;两者消费同一 `$MLRN_SPM_SPEC`,以让 MLRN、App 与 Particle pod target 共享一个 MapLibre package/product。 + ## 13.4.1-particle.5(当前 Android QBIN V4 Renderer 基线) - 新增 QBIN V4 `BindingProjection` → renderer-facing canonical snapshot materialization,并由 `RasterParticle` / `WeatherColor` Renderer 正式消费。 diff --git a/packages/maplibre-particle-rn/MapLibreParticleRN.podspec b/packages/maplibre-particle-rn/MapLibreParticleRN.podspec new file mode 100644 index 000000000..621651d51 --- /dev/null +++ b/packages/maplibre-particle-rn/MapLibreParticleRN.podspec @@ -0,0 +1,51 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +# MapLibreReactNative owns the package reference. This helper only adds its +# already-configured MapLibre product to this pod target, so ObjC++ sources can +# import the same module without introducing another binary dependency. +$MLRN_PARTICLE = Object.new + +def $MLRN_PARTICLE.post_install(installer) + unless defined?($MLRN) && $MLRN.respond_to?(:_add_spm_to_target) + raise "MapLibreParticleRN requires MapLibreReactNative's $MLRN._add_spm_to_target helper. Ensure MapLibreReactNative 11.3.6 is installed before calling $MLRN_PARTICLE.post_install." + end + + unless defined?($MLRN_SPM_SPEC) && $MLRN_SPM_SPEC.is_a?(Hash) + raise "MapLibreParticleRN requires $MLRN_SPM_SPEC from MapLibreReactNative or the host Podfile before calling $MLRN_PARTICLE.post_install." + end + + spm_spec = $MLRN_SPM_SPEC + [:url, :requirement, :product_name].each do |key| + raise "MapLibreParticleRN requires $MLRN_SPM_SPEC[:#{key}]." if spm_spec[key].nil? + end + + project = installer.pods_project + target = project.targets.find { |candidate| candidate.name == "MapLibreParticleRN" } + raise "MapLibreParticleRN pod target was not found in the Pods project." unless target + + $MLRN._add_spm_to_target( + project, + target, + spm_spec[:url], + spm_spec[:requirement], + spm_spec[:product_name] + ) +end + +Pod::Spec.new do |s| + s.name = "MapLibreParticleRN" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package.dig("repository", "url") + s.license = package["license"] + s.authors = { "NavSea" => "dev@navsea.invalid" } + s.platforms = { :ios => "15.1" } + s.source = { :git => package.dig("repository", "url"), :tag => s.version.to_s } + s.source_files = "ios/**/*.{h,m,mm,cpp}" + s.dependency "MapLibreReactNative", "11.3.6" + # $MLRN_PARTICLE.post_install reuses MapLibreReactNative's one MapLibre SPM + # product. Do not declare a MapLibre pod, vendored framework, or SPM product here. + install_modules_dependencies(s) +end diff --git a/packages/maplibre-particle-rn/README.md b/packages/maplibre-particle-rn/README.md index b5e13348e..a641de976 100644 --- a/packages/maplibre-particle-rn/README.md +++ b/packages/maplibre-particle-rn/README.md @@ -14,7 +14,7 @@ await FieldRuntime.setEncodedParticleBinding(reactTag, particleBinding); await FieldRuntime.setEncodedForecastTime(reactTag, timestampMs); ``` -Android 链路为:`FieldRuntime.submitEncodedField` → `WeatherRuntimeBridgeModule` → `WeatherRuntime.submitEncodedField` → JNI → `RasterArraySource::submitEncodedField` → `QbinV2Decoder::decode`。因此 QBIN 解码发生在 MapLibre Native,不在 JS/TS。 +Android 链路为:`FieldRuntime.submitEncodedField` → `WeatherRuntimeBridgeModule` → `WeatherRuntime.submitEncodedField` → JNI → `RasterArraySource::submitEncodedField` → `QbinV2Decoder::decode`。iOS 使用同一 `WeatherRuntimeBridge` TurboModule,按每次调用的 Fabric `reactTag` 即时解析当前 MLRN `MapView`,再调用 public `MLNParticleFieldRuntime`。两端的 QBIN 解码都发生在 MapLibre Native,不在 JS/TS。 V4 主要公共方法:`submitEncodedField`、`setEncodedColorBinding`、`setEncodedParticleBinding`、`setEncodedForecastTime`、`setEncodedForecastPlaybackState`、`setEncodedForecastPlaybackSpeed`、`clearEncodedField`。ColorBinding 支持 `SCALAR`、`VELOCITY_COMPONENT`、`DIRECTION_ANGLE`;ParticleBinding 支持 U/V vector components 或 direction angle。 @@ -26,6 +26,12 @@ V4 主要公共方法:`submitEncodedField`、`setEncodedColorBinding`、`setEn Map 与 Camera 直接从 `@maplibre/maplibre-react-native@11.3.6` 使用。本包把 MLRN 作为 peer dependency,不转导出 MapLibre React Native。 +## iOS 集成 + +autolinking 使用 `MapLibreParticleRN.podspec` 和 RN codegen 的 `WeatherRuntimeBridge` provider。pod 只依赖 `MapLibreReactNative`,不声明、链接或嵌入第二份 `MapLibre.framework`。Consumer 必须以 MLRN 11.3.6 的 `$MLRN_SPM_SPEC` 配置同一个 custom `MapLibre` product;Particle pod 不接受独立 MapLibre binary。 + +Podfile 的 `post_install do |installer|` 必须同时调用 `$MLRN.post_install(installer)` 与 `$MLRN_PARTICLE.post_install(installer)`。两者复用同一个 `$MLRN_SPM_SPEC`:前者接入 MLRN/App,后者将同一 package reference/product 加到 `MapLibreParticleRN` pod target。Expo plugin 会幂等注入后一个调用;它不设置 `$MLRN_SPM_SPEC`,该值仍由 MLRN 官方 plugin 或宿主 Podfile 提供。 + ## Android 发布 RN tgz: diff --git a/packages/maplibre-particle-rn/app.plugin.js b/packages/maplibre-particle-rn/app.plugin.js index 65b823e25..78e14c4a7 100644 --- a/packages/maplibre-particle-rn/app.plugin.js +++ b/packages/maplibre-particle-rn/app.plugin.js @@ -1,12 +1,38 @@ -const { withProjectBuildGradle } = require("@expo/config-plugins"); +// Resolve through the consuming Expo project first. This keeps a config plugin +// installed from an npm tarball aligned with the host's Expo SDK instead of +// accidentally loading a nested or globally-hoisted copy. +const configPlugins = require( + require.resolve("expo/config-plugins", { + paths: [process.cwd(), __dirname], + }), +); -const marker = "// @navsea/maplibre-particle-rn Android OpenGL release"; +const { + CodeGenerator, + withPodfile, + withProjectBuildGradle, +} = configPlugins; + +const androidMarker = "// @navsea/maplibre-particle-rn Android OpenGL release"; +const iosTag = "navsea-maplibre-particle-rn-maplibre-spm"; /** Resolves MLRN's single Native SDK request to the NavSea release artifact, never a second AAR. */ module.exports = function withNavSeaMapLibreParticle(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.5")\n }\n }\n }\n}\nrootProject.ext.set("org.maplibre.reactnative.nativeVersion", "13.4.1")\nrootProject.ext.set("org.maplibre.reactnative.nativeVariant", "opengl")\n`; + 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.5")\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; }); + + return withPodfile(config, (podfile) => { + podfile.modResults.contents = CodeGenerator.mergeContents({ + src: podfile.modResults.contents, + newSrc: "$MLRN_PARTICLE.post_install(installer)", + tag: iosTag, + comment: "#", + anchor: /post_install do \|installer\|/, + offset: 1, + }).contents; + return podfile; + }); }; diff --git a/packages/maplibre-particle-rn/ios/WeatherRuntimeBridge.h b/packages/maplibre-particle-rn/ios/WeatherRuntimeBridge.h new file mode 100644 index 000000000..df0c4393b --- /dev/null +++ b/packages/maplibre-particle-rn/ios/WeatherRuntimeBridge.h @@ -0,0 +1,4 @@ +#import + +@interface WeatherRuntimeBridge : NSObject +@end diff --git a/packages/maplibre-particle-rn/ios/WeatherRuntimeBridge.mm b/packages/maplibre-particle-rn/ios/WeatherRuntimeBridge.mm new file mode 100644 index 000000000..ecdd16bdc --- /dev/null +++ b/packages/maplibre-particle-rn/ios/WeatherRuntimeBridge.mm @@ -0,0 +1,229 @@ +#import "WeatherRuntimeBridge.h" + +#import +#import + +using namespace facebook::react; + +namespace { +constexpr NSUInteger MaxPayloadBytes = 64u * 1024u * 1024u; +constexpr NSUInteger MaxMetadataBytes = 1'048'576u; +constexpr NSUInteger MaxContractBytes = 256u * 1024u; +constexpr double MaxSafeInteger = 9007199254740991.0; + +BOOL validIdentifier(NSString *value) { + const NSUInteger length = [value lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + return length >= 1 && length <= 64; +} +BOOL validSafeInteger(double value, BOOL positive) { + return isfinite(value) && floor(value) == value && value >= (positive ? 1 : 0) && value <= MaxSafeInteger; +} +BOOL validUInt16(double value, BOOL positive) { + return isfinite(value) && floor(value) == value && value >= (positive ? 1 : 0) && value <= UINT16_MAX; +} +NSString *commandString(MLNParticleFieldRuntimeCommandResult result) { + return result == MLNParticleFieldRuntimeCommandResultAccepted ? @"ACCEPTED" : @"REJECTED"; +} +NSString *v3String(MLNParticleHostFieldSubmitResult result) { + switch (result) { + case MLNParticleHostFieldSubmitResultRejectedSourceMode: return @"REJECTED_SOURCE_MODE"; + case MLNParticleHostFieldSubmitResultRejectedAdapter: return @"REJECTED_ADAPTER"; + case MLNParticleHostFieldSubmitResultRejectedIncompatible: return @"REJECTED_INCOMPATIBLE"; + case MLNParticleHostFieldSubmitResultRejectedNonMonotonic: return @"REJECTED_NON_MONOTONIC"; + case MLNParticleHostFieldSubmitResultRejectedCapacity: return @"REJECTED_CAPACITY"; + case MLNParticleHostFieldSubmitResultAcceptedPendingInitialWindow: return @"ACCEPTED_PENDING_INITIAL_WINDOW"; + case MLNParticleHostFieldSubmitResultInitialWindowReady: return @"INITIAL_WINDOW_READY"; + case MLNParticleHostFieldSubmitResultAcceptedNextSnapshot: return @"ACCEPTED_NEXT_SNAPSHOT"; + case MLNParticleHostFieldSubmitResultDuplicate: return @"DUPLICATE"; + default: return @"UNKNOWN"; + } +} +NSString *v4String(MLNParticleEncodedFieldSubmitResult result) { + switch (result) { + case MLNParticleEncodedFieldSubmitResultAcceptedInserted: return @"ACCEPTED_INSERTED"; + case MLNParticleEncodedFieldSubmitResultAcceptedReplacedRevision: return @"ACCEPTED_REPLACED_REVISION"; + case MLNParticleEncodedFieldSubmitResultDuplicate: return @"DUPLICATE"; + case MLNParticleEncodedFieldSubmitResultRejectedDecode: return @"REJECTED_DECODE"; + case MLNParticleEncodedFieldSubmitResultRejectedContract: return @"REJECTED_CONTRACT"; + case MLNParticleEncodedFieldSubmitResultRejectedInvalidFieldKey: return @"REJECTED_INVALID_FIELD_KEY"; + case MLNParticleEncodedFieldSubmitResultRejectedIdentityConflict: return @"REJECTED_IDENTITY_CONFLICT"; + case MLNParticleEncodedFieldSubmitResultRejectedStaleRevision: return @"REJECTED_STALE_REVISION"; + case MLNParticleEncodedFieldSubmitResultRejectedIncompatible: return @"REJECTED_INCOMPATIBLE"; + case MLNParticleEncodedFieldSubmitResultRejectedCapacity: return @"REJECTED_CAPACITY"; + default: return @"REJECTED_CONTRACT"; + } +} +BOOL isWeatherObjectsNotReadyError(MLNParticleFieldRuntimeErrorCode code) { + switch (code) { + case MLNParticleFieldRuntimeErrorSourceMissing: + case MLNParticleFieldRuntimeErrorSourceTypeMismatch: + case MLNParticleFieldRuntimeErrorWeatherColorLayerMissing: + case MLNParticleFieldRuntimeErrorWeatherColorLayerTypeMismatch: + case MLNParticleFieldRuntimeErrorParticleLayerMissing: + case MLNParticleFieldRuntimeErrorParticleLayerTypeMismatch: + case MLNParticleFieldRuntimeErrorLayerSourceMismatch: + return YES; + default: + return NO; + } +} +NSString *attachErrorCode(NSError *error) { + switch (error.code) { + case MLNParticleFieldRuntimeErrorStyleNotReady: return @"STYLE_NOT_READY"; + case MLNParticleFieldRuntimeErrorMapUnavailable: return @"MAP_NOT_READY"; + default: return isWeatherObjectsNotReadyError((MLNParticleFieldRuntimeErrorCode)error.code) ? @"WEATHER_OBJECTS_NOT_READY" : @"NATIVE_REJECTED"; + } +} +NSString *encodedFacadeErrorCode(NSError *error) { + switch (error.code) { + case MLNParticleFieldRuntimeErrorInvalidArgument: return @"INVALID_ARGUMENT"; + case MLNParticleFieldRuntimeErrorDisposed: return @"RUNTIME_DISPOSED"; + case MLNParticleFieldRuntimeErrorStyleNotReady: return @"SOURCE_MISSING"; + default: return isWeatherObjectsNotReadyError((MLNParticleFieldRuntimeErrorCode)error.code) ? @"SOURCE_MISSING" : @"NATIVE_REJECTED"; + } +} +NSDictionary *snapshot(MLNParticleFieldRuntime *runtime) { + NSString *state[] = {@"DISABLED", @"WAITING_FOR_FIELDS", @"READY", @"ERROR"}; + NSString *playback[] = {@"PLAY", @"PAUSE"}; + NSString *fps[] = {@"AUTO", @"FPS_30", @"FPS_60"}; + return @{ @"state": state[runtime.state], @"playbackState": playback[runtime.playbackState], @"targetFps": fps[runtime.targetFps], @"disposed": @(runtime.disposed) }; +} +} + +@implementation WeatherRuntimeBridge + +@synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED; + +RCT_EXPORT_MODULE(WeatherRuntimeBridge) + +- (std::shared_ptr)getTurboModule:(const ObjCTurboModule::InitParams &)params { + return std::make_shared(params); +} + +// RN 0.85.2 injects this registry from the current Fabric/UIManager interop layer. It is not +// a Map registry: every call schedules a fresh UI block and validates the MLRN component class. +- (void)withMapForTag:(double)reactTag requireRuntime:(BOOL)requireRuntime resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject action:(void (^)(MLNMapView *, MLNParticleFieldRuntime * _Nullable))action { + if (!validSafeInteger(reactTag, YES)) { reject(@"INVALID_ARGUMENT", @"reactTag must be a positive integer", nil); return; } + RCTViewRegistry *registry = _viewRegistry_DEPRECATED; + if (!registry) { reject(@"MAP_VIEW_NOT_FOUND", @"Fabric UIManager view registry is unavailable", nil); return; } + [registry addUIBlock:^(RCTViewRegistry *currentRegistry) { + UIView *component = [currentRegistry viewForReactTag:@((NSInteger)reactTag)]; + // MLRN's iOS pod intentionally marks its implementation headers private. Its verified + // Fabric seam is this component class + contentView; use runtime checks, not private imports. + if (![component isKindOfClass:NSClassFromString(@"MLRNMapViewComponentView")]) { reject(@"MAP_VIEW_NOT_FOUND", @"reactTag does not resolve to a current MLRNMapView", nil); return; } + UIView *content = [component valueForKey:@"contentView"]; + if (![content isKindOfClass:NSClassFromString(@"MLRNMapView")] || ![content isKindOfClass:MLNMapView.class]) { reject(@"MAP_VIEW_NOT_FOUND", @"MLRN MapView content view is unavailable", nil); return; } + MLNMapView *map = (MLNMapView *)content; + MLNParticleFieldRuntime *runtime = map.particleFieldRuntime; + if (requireRuntime && (!runtime || runtime.disposed)) { reject(@"RUNTIME_DISPOSED", @"WeatherRuntime is not attached to this map", nil); return; } + action(map, runtime); + }]; +} + +- (NSData *)readPayload:(NSString *)reference error:(NSString **)message { + if (reference.length == 0) { *message = @"payload file reference is empty"; return nil; } + NSURL *url = nil; + if ([reference hasPrefix:@"file://"]) url = [NSURL URLWithString:reference]; + else if ([reference hasPrefix:@"/"]) url = [NSURL fileURLWithPath:reference]; + else { *message = @"payload file reference must use file:// or an absolute local path"; return nil; } + if (!url.isFileURL) { *message = @"payload file reference must be local"; return nil; } + NSNumber *regular = nil, *readable = nil, *size = nil; + if (![url getResourceValue:®ular forKey:NSURLIsRegularFileKey error:nil] || !regular.boolValue || + ![url getResourceValue:&readable forKey:NSURLIsReadableKey error:nil] || !readable.boolValue || + ![url getResourceValue:&size forKey:NSURLFileSizeKey error:nil] || size.unsignedLongLongValue == 0 || size.unsignedLongLongValue > MaxPayloadBytes) { + *message = @"payload file must be a readable regular file of 1..67108864 bytes"; return nil; + } + const unsigned long long expected = size.unsignedLongLongValue; + NSData *data = [NSData dataWithContentsOfURL:url options:NSDataReadingMappedIfSafe error:nil]; + NSNumber *after = nil; + [url getResourceValue:&after forKey:NSURLFileSizeKey error:nil]; + if (!data || data.length != expected || after.unsignedLongLongValue != expected) { *message = @"payload file changed while being read"; return nil; } + return data; +} + +- (MLNParticleFieldRuntimeConfiguration *)configuration { + return [[MLNParticleFieldRuntimeConfiguration alloc] initWithSourceIdentifier:@"rn-weather-fields" weatherColorLayerIdentifier:@"rn-weather-color" particleLayerIdentifier:@"rn-weather-particles"]; +} + +- (void)attach:(double)reactTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self withMapForTag:reactTag requireRuntime:NO resolve:resolve reject:reject action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) { + NSError *error = nil; MLNParticleFieldRuntime *attached = [map replaceParticleFieldRuntimeWithConfiguration:self.configuration error:&error]; + if (!attached) { + reject(attachErrorCode(error), error.localizedDescription ?: @"WeatherRuntime attach rejected", error); return; + } + resolve(snapshot(attached)); + }]; +} +- (void)detach:(double)reactTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self withMapForTag:reactTag requireRuntime:YES resolve:resolve reject:reject action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) { [map disposeParticleFieldRuntime]; resolve(nil); }]; +} +- (void)getState:(double)reactTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self withMapForTag:reactTag requireRuntime:YES resolve:resolve reject:reject action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) { resolve(snapshot(runtime)); }]; } + +- (void)command:(double)tag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject action:(MLNParticleFieldRuntimeCommandResult (^)(MLNParticleFieldRuntime *, NSError **))action { + [self withMapForTag:tag requireRuntime:YES resolve:resolve reject:reject action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) { NSError *error = nil; MLNParticleFieldRuntimeCommandResult result = action(runtime, &error); resolve(error ? @"REJECTED" : commandString(result)); }]; +} +- (void)encodedCommand:(double)tag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject action:(MLNParticleFieldRuntimeCommandResult (^)(MLNParticleFieldRuntime *, NSError **))action { + [self withMapForTag:tag requireRuntime:YES resolve:resolve reject:reject action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) { NSError *error = nil; MLNParticleFieldRuntimeCommandResult result = action(runtime, &error); if (error) reject(encodedFacadeErrorCode(error), error.localizedDescription, error); else resolve(commandString(result)); }]; +} +- (void)clearWindFields:(double)t resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self clearFields:t resolve:r reject:j]; } +- (void)clearFields:(double)t resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *x, NSError **e) { return [x clearFieldsWithError:e]; }]; } +- (void)setWeatherEnabled:(double)t enabled:(BOOL)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *x,NSError **e){return [x setWeatherEnabled:v error:e];}]; } +- (void)setWeatherColorVisible:(double)t visible:(BOOL)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *x,NSError **e){return [x setWeatherColorVisible:v error:e];}]; } +- (void)setParticleVisible:(double)t visible:(BOOL)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *x,NSError **e){return [x setParticleVisible:v error:e];}]; } + +- (void)submitWindField:(double)t metadataJson:(NSString *)metadata payloadFileUri:(NSString *)uri resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { + if (!metadata.length || [metadata lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > MaxMetadataBytes) { j(@"INVALID_ARGUMENT", @"metadataJson UTF-8 payload must be non-empty and at most 1048576 bytes", nil); return; } + NSString *message = nil; NSData *data = [self readPayload:uri error:&message]; if (!data) { j(@"PAYLOAD_FILE_UNREADABLE", message, nil); return; } + [self withMapForTag:t requireRuntime:YES resolve:r reject:j action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) { NSError *error = nil; MLNParticleHostFieldSubmitResult result = [runtime submitWindFieldWithMetadataJSON:metadata payload:data error:&error]; if (error) j(@"NATIVE_REJECTED", error.localizedDescription, error); else r(v3String(result)); }]; +} +- (void)submitField:(double)t metadataJson:(NSString *)metadata validityFileUri:(NSString *)validity scalarFileUri:(NSString *)scalar vectorFileUri:(NSString *)vector resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { + if (!metadata.length || [metadata lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > MaxMetadataBytes) { j(@"INVALID_ARGUMENT", @"metadataJson UTF-8 payload must be non-empty and at most 1048576 bytes", nil); return; } + NSString *message = nil; NSData *v = [self readPayload:validity error:&message]; NSData *s = scalar.length ? [self readPayload:scalar error:&message] : nil; NSData *p = vector.length ? [self readPayload:vector error:&message] : nil; + if (!v || (scalar.length && !s) || (vector.length && !p) || (!s && !p) || v.length + s.length + p.length > MaxPayloadBytes) { j(@"PAYLOAD_FILE_UNREADABLE", message ?: @"payloadRefs requires scalarFileUri or vectorFileUri and at most 67108864 bytes", nil); return; } + [self withMapForTag:t requireRuntime:YES resolve:r reject:j action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) { NSError *error = nil; MLNParticleHostFieldSubmitResult result = [runtime submitFieldWithMetadataJSON:metadata scalar:s vector:p validity:v error:&error]; if (error) j(@"NATIVE_REJECTED", error.localizedDescription, error); else r(v3String(result)); }]; +} +- (void)submitEncodedField:(double)t fieldKey:(NSString *)key fieldID:(NSString *)fieldID generation:(double)generation dataVersion:(double)dataVersion contractJson:(NSString *)contract qbinFileUri:(NSString *)uri resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { + if (!validIdentifier(key) || !validIdentifier(fieldID) || !validSafeInteger(generation, YES) || !validSafeInteger(dataVersion, NO) || !contract.length || [contract lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > MaxContractBytes) { j(@"INVALID_ARGUMENT", @"invalid V4 encoded field control-plane argument", nil); return; } + NSError *jsonError = nil; id json = [NSJSONSerialization JSONObjectWithData:[contract dataUsingEncoding:NSUTF8StringEncoding] options:0 error:&jsonError]; + if (![json isKindOfClass:NSDictionary.class] || !validIdentifier(((NSDictionary *)json)[@"profileID"])) { j(@"INVALID_ARGUMENT", @"invalid V4 encoded field control-plane argument", nil); return; } + NSString *message = nil; NSData *qbin = [self readPayload:uri error:&message]; if (!qbin) { j(@"PAYLOAD_FILE_UNREADABLE", message, nil); return; } + [self withMapForTag:t requireRuntime:YES resolve:r reject:j action:^(MLNMapView *map, MLNParticleFieldRuntime *runtime) { NSError *error = nil; MLNParticleEncodedFieldSubmitResult result = [runtime submitEncodedFieldWithFieldKey:key fieldID:fieldID generation:(uint64_t)generation dataVersion:(uint64_t)dataVersion contractJSON:contract qbin:qbin error:&error]; if (error) j(encodedFacadeErrorCode(error), error.localizedDescription, error); else r(v4String(result)); }]; +} +- (void)setEncodedColorBinding:(double)t fieldKey:(NSString *)key semanticID:(double)semantic groupID:(double)group expectedValueClass:(NSString *)valueClass expectedUnitID:(double)unit resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { + NSInteger value = [valueClass isEqual:@"SCALAR"] ? 0 : [valueClass isEqual:@"VELOCITY_COMPONENT"] ? 1 : [valueClass isEqual:@"DIRECTION_ANGLE"] ? 2 : -1; + if (!validIdentifier(key) || !validUInt16(semantic, YES) || !validUInt16(group, NO) || !validUInt16(unit, NO) || value < 0) { j(@"INVALID_ARGUMENT", @"invalid encoded color binding selector", nil); return; } + [self encodedCommand:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *x,NSError **e){ return [x setEncodedColorBindingWithFieldKey:key semanticID:semantic groupID:group expectedValueClass:(MLNParticleFieldValueClass)value expectedUnitID:unit error:e] ? MLNParticleFieldRuntimeCommandResultAccepted : MLNParticleFieldRuntimeCommandResultRejected; }]; +} +- (void)setEncodedParticleBinding:(double)t fieldKey:(NSString *)key mode:(NSString *)mode uSemanticID:(double)us uGroupID:(double)ug vSemanticID:(double)vs vGroupID:(double)vg directionSemanticID:(double)ds directionGroupID:(double)dg expectedUnitID:(double)unit resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { + BOOL inactiveU = us == 0 && ug == 0, inactiveV = vs == 0 && vg == 0, inactiveD = ds == 0 && dg == 0; + BOOL vector = [mode isEqual:@"VECTOR_COMPONENTS"] && validUInt16(us, YES) && validUInt16(ug, NO) && validUInt16(vs, YES) && validUInt16(vg, NO) && inactiveD; + BOOL direction = [mode isEqual:@"DIRECTION_ANGLE"] && inactiveU && inactiveV && validUInt16(ds, YES) && validUInt16(dg, NO); + if (!validIdentifier(key) || !validUInt16(unit, NO) || (!vector && !direction)) { j(@"INVALID_ARGUMENT", @"active encoded particle selectors must be uint16 with non-zero semanticID; inactive slots are exactly zero", nil); return; } + [self encodedCommand:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *x,NSError **e){ return [x setEncodedParticleBindingWithFieldKey:key mode:(vector ? MLNParticleEncodedParticleBindingModeVectorComponents : MLNParticleEncodedParticleBindingModeDirectionAngle) uSemanticID:us uGroupID:ug vSemanticID:vs vGroupID:vg directionSemanticID:ds directionGroupID:dg expectedUnitID:unit error:e] ? MLNParticleFieldRuntimeCommandResultAccepted : MLNParticleFieldRuntimeCommandResultRejected; }]; +} +- (void)clearEncodedField:(double)t fieldKey:(NSString *)key resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { if (!validIdentifier(key)) { j(@"INVALID_ARGUMENT", @"invalid fieldKey", nil); return; } [self encodedCommand:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *x,NSError **e){return [x clearEncodedFieldWithFieldKey:key error:e] ? MLNParticleFieldRuntimeCommandResultAccepted : MLNParticleFieldRuntimeCommandResultRejected;}]; } +- (void)clearAllEncodedFields:(double)t resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self encodedCommand:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *x,NSError **e){return [x clearAllEncodedFieldsWithError:e] ? MLNParticleFieldRuntimeCommandResultAccepted : MLNParticleFieldRuntimeCommandResultRejected;}]; } +- (void)setEncodedForecastTime:(double)t timestampMs:(double)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { if (!validSafeInteger(v, NO)) { j(@"INVALID_ARGUMENT", @"encoded forecast time must be safe integer",nil); return; } [self encodedCommand:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *x,NSError **e){return [x setEncodedForecastTime:v error:e] ? MLNParticleFieldRuntimeCommandResultAccepted : MLNParticleFieldRuntimeCommandResultRejected;}]; } +- (void)setEncodedForecastPlaybackState:(double)t playback:(NSString *)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { if (!([v isEqual:@"PLAY"] || [v isEqual:@"PAUSE"])) { j(@"INVALID_ARGUMENT", @"invalid encoded playback",nil); return; } [self encodedCommand:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *x,NSError **e){return [x setEncodedForecastPlaybackState:[v isEqual:@"PLAY"] error:e] ? MLNParticleFieldRuntimeCommandResultAccepted : MLNParticleFieldRuntimeCommandResultRejected;}]; } +- (void)setEncodedForecastPlaybackSpeed:(double)t speed:(double)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { if (!isfinite(v) || v <= 0 || v > FLT_MAX) { j(@"INVALID_ARGUMENT", @"invalid encoded playback speed",nil); return; } [self encodedCommand:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *x,NSError **e){return [x setEncodedForecastPlaybackSpeed:v error:e] ? MLNParticleFieldRuntimeCommandResultAccepted : MLNParticleFieldRuntimeCommandResultRejected;}]; } + +- (void)setTargetFps:(double)t target:(NSString *)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { MLNParticleFieldRuntimeTargetFps x = [v isEqual:@"AUTO"] ? MLNParticleFieldRuntimeTargetFpsAuto : [v isEqual:@"FPS_30"] ? MLNParticleFieldRuntimeTargetFps30 : [v isEqual:@"FPS_60"] ? MLNParticleFieldRuntimeTargetFps60 : (MLNParticleFieldRuntimeTargetFps)99; if (x == 99) { j(@"INVALID_ARGUMENT", @"unsupported enum value",nil); return; } [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,NSError **e){return [q setTargetFps:x error:e];}]; } +- (void)setForecastTime:(double)t timestampMs:(double)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { if (!validSafeInteger(v, NO)) { j(@"INVALID_ARGUMENT", @"forecast timestamp must be a non-negative epoch millisecond integer",nil); return; } [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,NSError **e){return [q setForecastTime:v error:e];}]; } +- (void)setPlaybackState:(double)t playback:(NSString *)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { MLNParticleFieldRuntimePlaybackState x = [v isEqual:@"PLAY"] ? MLNParticleFieldRuntimePlaybackStatePlay : [v isEqual:@"PAUSE"] ? MLNParticleFieldRuntimePlaybackStatePause : (MLNParticleFieldRuntimePlaybackState)99; if (x == 99) { j(@"INVALID_ARGUMENT", @"unsupported enum value",nil); return; } [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,NSError **e){return [q setPlaybackState:x error:e];}]; } +- (void)setPlaybackSpeed:(double)t speed:(NSString *)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { float x = [v isEqual:@"HALF"] ? .5f : [v isEqual:@"NORMAL"] ? 1.f : [v isEqual:@"DOUBLE"] ? 2.f : -1.f; if (x < 0) { j(@"INVALID_ARGUMENT", @"unsupported enum value",nil); return; } [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,NSError **e){return [q setPlaybackSpeed:x error:e];}]; } +- (void)setFieldValueSource:(double)t source:(NSString *)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { MLNParticleFieldValueSource x = [v isEqual:@"SCALAR"] ? MLNParticleFieldValueSourceScalar : [v isEqual:@"VECTOR_MAGNITUDE"] ? MLNParticleFieldValueSourceVectorMagnitude : (MLNParticleFieldValueSource)99; if (x == 99) { j(@"INVALID_ARGUMENT", @"unsupported enum value",nil); return; } [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,NSError **e){return [q setFieldValueSource:x error:e];}]; } +- (void)setParticleAdvectionScale:(double)t scale:(double)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { if (!isfinite(v) || v <= 0) { j(@"INVALID_ARGUMENT", @"advectionScale must be finite and > 0",nil); return; } [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,NSError **e){return [q setParticleAdvectionScale:v error:e];}]; } +- (void)floatCommand:(double)t value:(double)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j action:(MLNParticleFieldRuntimeCommandResult (^)(MLNParticleFieldRuntime *, float, NSError **))a { if (!isfinite(v)) { j(@"INVALID_ARGUMENT", @"value must be finite",nil); return; } [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,NSError **e){return a(q, v, e);}]; } +- (void)setWeatherColorOpacity:(double)t value:(double)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self floatCommand:t value:v resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,float x,NSError **e){return [q setWeatherColorOpacity:x error:e];}]; } +- (void)setParticleLineWidth:(double)t value:(double)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self floatCommand:t value:v resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,float x,NSError **e){return [q setParticleLineWidth:x error:e];}]; } +- (void)setParticleLength:(double)t value:(double)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self floatCommand:t value:v resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,float x,NSError **e){return [q setParticleLength:x error:e];}]; } +- (void)setParticleOpacity:(double)t value:(double)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self floatCommand:t value:v resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,float x,NSError **e){return [q setParticleOpacity:x error:e];}]; } +- (void)setTrailOpacity:(double)t value:(double)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self floatCommand:t value:v resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,float x,NSError **e){return [q setTrailOpacity:x error:e];}]; } +- (void)setTrailFade:(double)t value:(double)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self floatCommand:t value:v resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,float x,NSError **e){return [q setTrailFade:x error:e];}]; } +- (void)setParticleCount:(double)t count:(double)v resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { if (!isfinite(v) || v < 0 || v > 2147483647.0 || floor(v) != v) { j(@"INVALID_ARGUMENT", @"particle count must be a non-negative integer",nil); return; } [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,NSError **e){return [q setParticleCount:v error:e];}]; } +- (void)colorCommand:(double)t red:(double)red green:(double)green blue:(double)blue alpha:(double)alpha resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j action:(MLNParticleFieldRuntimeCommandResult (^)(MLNParticleFieldRuntime *, float, float, float, float, NSError **))a { if (!isfinite(red)||!isfinite(green)||!isfinite(blue)||!isfinite(alpha)||red<0||red>1||green<0||green>1||blue<0||blue>1||alpha<0||alpha>1) { j(@"INVALID_ARGUMENT", @"RGBA must be finite values in [0,1]",nil); return; } [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,NSError **e){return a(q,red,green,blue,alpha,e);}]; } +- (void)setParticleColor:(double)t red:(double)red green:(double)green blue:(double)blue alpha:(double)alpha resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self colorCommand:t red:red green:green blue:blue alpha:alpha resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,float a,float b,float c,float d,NSError **e){return [q setParticleColorRed:a green:b blue:c alpha:d error:e];}]; } +- (void)setTrailColor:(double)t red:(double)red green:(double)green blue:(double)blue alpha:(double)alpha resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { [self colorCommand:t red:red green:green blue:blue alpha:alpha resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,float a,float b,float c,float d,NSError **e){return [q setTrailColorRed:a green:b blue:c alpha:d error:e];}]; } +- (void)setWeatherColorStops:(double)t stops:(NSArray *)stops resolve:(RCTPromiseResolveBlock)r reject:(RCTPromiseRejectBlock)j { NSMutableArray *native = NSMutableArray.array; float previous = -INFINITY; for (id entry in stops) { if (![entry isKindOfClass:NSDictionary.class]) { j(@"INVALID_ARGUMENT", @"color stop must be an object",nil); return; } NSDictionary *d = entry; NSNumber *speed=d[@"speedMps"],*red=d[@"red"],*green=d[@"green"],*blue=d[@"blue"],*alpha=d[@"alpha"]; if (!speed||!red||!green||!blue||!alpha||!isfinite(speed.floatValue)||speed.floatValue<=previous||!isfinite(red.floatValue)||!isfinite(green.floatValue)||!isfinite(blue.floatValue)||!isfinite(alpha.floatValue)||red.floatValue<0||red.floatValue>1||green.floatValue<0||green.floatValue>1||blue.floatValue<0||blue.floatValue>1||alpha.floatValue<0||alpha.floatValue>1) { j(@"INVALID_ARGUMENT", @"stops require finite, strictly increasing speedMps and finite RGBA in [0,1]",nil); return; } previous=speed.floatValue; [native addObject:[[MLNParticleWeatherColorStop alloc] initWithValue:previous red:red.floatValue green:green.floatValue blue:blue.floatValue alpha:alpha.floatValue]]; } [self command:t resolve:r reject:j action:^MLNParticleFieldRuntimeCommandResult(MLNParticleFieldRuntime *q,NSError **e){return [q setWeatherColorStops:native error:e];}]; } + +@end diff --git a/packages/maplibre-particle-rn/package.json b/packages/maplibre-particle-rn/package.json index fce2d1a25..af2269354 100644 --- a/packages/maplibre-particle-rn/package.json +++ b/packages/maplibre-particle-rn/package.json @@ -9,6 +9,8 @@ "files": [ "src", "android", + "ios", + "MapLibreParticleRN.podspec", "app.plugin.js", "react-native.config.js", "README.md", @@ -28,6 +30,7 @@ }, "peerDependencies": { "@maplibre/maplibre-react-native": "11.3.6", + "expo": ">=54.0.0", "react": ">=19.1.0", "react-native": ">=0.80.0" }, @@ -42,6 +45,11 @@ "jsSrcsDir": "src", "android": { "javaPackageName": "org.navsea.maplibre.particle.weather" + }, + "ios": { + "modulesProvider": { + "WeatherRuntimeBridge": "WeatherRuntimeBridge" + } } }, "devDependencies": { diff --git a/packages/maplibre-particle-rn/react-native.config.js b/packages/maplibre-particle-rn/react-native.config.js index 56932d374..e4aac33ef 100644 --- a/packages/maplibre-particle-rn/react-native.config.js +++ b/packages/maplibre-particle-rn/react-native.config.js @@ -1 +1,8 @@ -module.exports = { dependency: { platforms: { android: { sourceDir: "./android" } } } }; +module.exports = { + dependency: { + platforms: { + android: { sourceDir: "./android" }, + ios: { podspecPath: "./MapLibreParticleRN.podspec" }, + }, + }, +}; diff --git a/packages/maplibre-particle-rn/src/FieldRuntime.ts b/packages/maplibre-particle-rn/src/FieldRuntime.ts index b8c77ac34..b1b28eba8 100644 --- a/packages/maplibre-particle-rn/src/FieldRuntime.ts +++ b/packages/maplibre-particle-rn/src/FieldRuntime.ts @@ -10,7 +10,7 @@ function encodedContractJson(contract: ResolvedFieldProfileContractInput): strin function nativeBridge() { const bridge = getNativeFieldRuntimeBridge(); - if (bridge == null) throw new Error("@navsea/maplibre-particle-rn FieldRuntime requires the Android WeatherRuntimeBridge native module"); + if (bridge == null) throw new Error("@navsea/maplibre-particle-rn FieldRuntime requires the WeatherRuntimeBridge native module"); return bridge; } diff --git a/release/maplibre-particle/ios/README.md b/release/maplibre-particle/ios/README.md index b0b46a275..674f5ed0a 100644 --- a/release/maplibre-particle/ios/README.md +++ b/release/maplibre-particle/ios/README.md @@ -4,6 +4,14 @@ 模块和 product 始终为 `MapLibre`。MLRN 11.3.6 与后续 Particle Bridge 必须通过同一份自定义 MapLibre binary 消费,不能再链接第二个同功能但不同模块名的 framework。 +Particle iOS 发行固定使用 Metal renderer。必须在 macOS 对目标 commit 执行: + +```sh +bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic +``` + +裸命令 `bazel build //platform/ios:MapLibre.dynamic` 会采用默认 OpenGL renderer;即使成功生成 XCFramework,也不得作为 Particle iOS 发行基线。发行前还应核验 device binary 含 `MLNMapView`、Metal/`MTLDevice` 与 `ParticleGPU` 标记,且无 OpenGL marker。 + 发布时: 1. 在 macOS 上从对应 Git commit 成功生成 `MapLibre.dynamic.xcframework`; @@ -11,4 +19,4 @@ 3. 用真实 URL、checksum、commit 和构建记录填充 `release-manifest.json`; 4. 将相同 URL/checksum 代入 `Package.swift.template`,再在独立 distribution repository 发布。 -外部 binary distribution 版本独立于 `platform/ios/VERSION`;首个建议坐标为 `6.28.0-particle.1`。本 Step 尚未取得可发布 XCFramework zip,因此 manifest 保持 `NOT_BUILT`,不提供伪造 hash。 +外部 binary distribution 版本独立于 `platform/ios/VERSION`;首个建议坐标为 `6.28.0-particle.1`。Metal XCFramework 已在本地构建和 Pleiades iPad mini 5 RN Test Host signed Release 路径验证;artifact 尚未发布远端,manifest 的 URL 仍为 `LOCAL_BUILD`,不得伪造下载地址。 diff --git a/release/maplibre-particle/ios/release-manifest.json b/release/maplibre-particle/ios/release-manifest.json index 4b3dcb608..b0cfdb85e 100644 --- a/release/maplibre-particle/ios/release-manifest.json +++ b/release/maplibre-particle/ios/release-manifest.json @@ -6,9 +6,9 @@ "artifact": { "fileName": "MapLibre.dynamic.xcframework.zip", "url": "LOCAL_BUILD", - "sizeBytes": 24639200, - "sha256": "64faa174c5c0dc0a2da3e289f0beaf2dedf7298665e3345f39836729b668e239" + "sizeBytes": 24943196, + "sha256": "ae88975fc20f4d9961b95079638d0e9c0d8e61d4394d5c0a8ffcc48d173a827f" }, - "sourceCommit": "136ed726cf749c24b3673c046581a38ef48be390", - "notes": "STEP-082 local Bazel XCFramework build has been validated. The artifact has not been published; no remote URL is available." + "sourceCommit": "bdd7f40f9029031816c9bf4db6e1cd6c7c69b7bd", + "notes": "Metal renderer artifact built with bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic. Validated by the Pleiades iPad mini 5 RN Test Host signed Release path; user confirmed particle rendering recovered. The artifact has not been published; no remote URL is available." } diff --git a/粒子开发记录.md b/粒子开发记录.md index 0579c427e..69cbbe3a6 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -843,3 +843,16 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2` - 真实本地产物 `bazel-bin/platform/ios/MapLibre.dynamic.xcframework.zip`:`24639200` bytes,SHA-256=`64faa174c5c0dc0a2da3e289f0beaf2dedf7298665e3345f39836729b668e239`。device `ios-arm64` 和 simulator `ios-arm64_x86_64-simulator` 都有 public `Headers/MLNParticleFieldRuntime.h`,两份 `Mapbox.h` 均导入该 header。 - 两 slice binary 都有 `_OBJC_CLASS_$_MLNParticleFieldRuntime` 和 `_OBJC_CLASS_$_MLNParticleFieldRuntimeConfiguration`;`MLNMapView_Private.h` public export count=0。device 为 arm64,simulator 同时含 x86_64 与 arm64。 - iOS release manifest 现为 `VALIDATED`,distributionVersion 仍为 `6.28.0-particle.1`、module 为 `MapLibre`;artifact 尚未发布,URL 明确为 `LOCAL_BUILD`。STEP-082 CLOSED / PASS;Git closeout commit/push 将由宿主在本轮文档生成后完成,最终 hash 以宿主回填/最终回复为准。 + +### 2026-09-18 STEP-083 iOS RN TurboModule / Fabric bridge 实现批次 2 + +- package 新增 RN 0.85 codegen `WeatherRuntimeBridge` iOS ObjC++ module、podspec、autolinking/provider;公共 TS API 未分叉,仅把缺 module 文案改为平台中性。每条命令由当前 Fabric/UIManager injected registry UI block 重新验证 MLRN component/content view,再使用 public `MLNMapView` facade;不缓存 map/runtime,也无第二 registry。 +- V3、V4、bindings、forecast 与全部视觉命令映射现有 iOS facade;本地 payload guard 限 file:///absolute readable regular file、1..64 MiB 和读取前后长度一致。pod 只依赖 `MapLibreReactNative`,复用 MLRN `$MLRN_SPM_SPEC` 的唯一 `MapLibre` product。 +- podspec syntax、package dry-run tarball、provider config、34/34 method parity 和 `git diff --check` PASS。离线 npm cache 缺 package,故 TypeScript/codegen/Pod/Xcode/runtime 未执行;STEP-082 的 `LOCAL_BUILD` XCFramework zip 也尚非 MLRN 可消费的 custom SwiftPM URL。状态 `IMPLEMENTED / WAITING_IOS_VALIDATION`,由 RDC 在真实 single-binary consumer 环境继续验证;未 commit/push,既有 dirty 未触碰。 + +### 2026-09-18 STEP-083 iOS Metal artifact 与 RN 真机验证纠正 + +- 已确认裸 `bazel build //platform/ios:MapLibre.dynamic` 默认走 OpenGL:旧 XCFramework 虽可显示色场,但 RN Test Host 不渲染粒子,旧 `24639200` bytes / `64faa174c5c0dc0a2da3e289f0beaf2dedf7298665e3345f39836729b668e239` artifact 作废,不再作为 iOS Particle 发行基线。 +- 发行固定为 `bazel build --//:renderer=metal //platform/ios:MapLibre.dynamic`。新 Metal zip 为 `24943196` bytes,SHA-256=`ae88975fc20f4d9961b95079638d0e9c0d8e61d4394d5c0a8ffcc48d173a827f`,source commit=`bdd7f40f9029031816c9bf4db6e1cd6c7c69b7bd`;device binary 含 Metal/`MTLDevice`/`ParticleGPU` 且无 OpenGL marker,远端 URL 仍为 `LOCAL_BUILD`。 +- RDC 已实际完成 npm ci、typecheck、RN codegen、Expo prebuild、Pod integration、device arm64 build、signed Release build/签名/安装;MLRN/App/Particle 使用 single MapLibre SPM product。Pleiades iPad mini 5 上一次 V4 Wind load 观察到 clearAll、bindings、T0/T1 accepted 与 midpoint seek,用户确认粒子恢复。 +- 当前状态 `IMPLEMENTED / IOS_CORE_PATH_PASS / WAITING_FINAL_CONTROL_REGRESSION`:PLAY/PAUSE、clear selected/all、detach/re-attach 尚未逐按钮实测;不将单次 load 证据扩大为完整控制面验收。未 commit/push。