diff --git a/docs/30_设计/STEP-082_iOS_Native_FieldRuntime_Facade与XCFramework发行设计_v1.md b/docs/30_设计/STEP-082_iOS_Native_FieldRuntime_Facade与XCFramework发行设计_v1.md new file mode 100644 index 000000000..5b881f2dc --- /dev/null +++ b/docs/30_设计/STEP-082_iOS_Native_FieldRuntime_Facade与XCFramework发行设计_v1.md @@ -0,0 +1,27 @@ +# STEP-082 iOS Native FieldRuntime Facade 与 XCFramework 发行设计 v1 + +状态:IMPLEMENTED / WAITING_MAC_XCFRAMEWORK_VALIDATION + +## 公开边界 + +新增 `MLNParticleFieldRuntimeConfiguration`、`MLNParticleFieldRuntime` 和 `MLNMapView (ParticleFieldRuntime)`。公开头只依赖 Foundation 与已有 public Objective-C 前置类型;QBIN 以 `NSData` 传入,不暴露 `mbgl`、STL、Core 指针或 `MLNMapView_Private.h`。 + +Configuration 保存 Host-neutral 的 `sourceIdentifier`、`weatherColorLayerIdentifier`、`particleLayerIdentifier`。它不硬编码 RN ID;后续 RN 可继续传 `rn-weather-fields`、`rn-weather-color`、`rn-weather-particles`。 + +## 生命周期与 fail-closed + +Runtime 被 `MLNMapView` 的 associated object 持有,Runtime 只弱引用 MapView。replace 先 dispose 并移除旧实例,attach 仅在当前 style 同时存在正确 Core 类型的 `RasterArraySource`、`WeatherColorLayer`、`RasterParticleLayer` 且两个 layer 都引用该 source 时成功。任何不满足条件的 style-ready/source/type/layer 错误都以 `NSError` 返回,不创建 source/layer,不替换 style。 + +每条命令重新从 current MapView/current style 查找并验证三对象;不缓存 `RasterArraySource*` 或任何跨 style 生命周期 Core pointer。因此 style reload 后旧 runtime 不会操作悬空对象,而是 fail-closed,Host 应在 style ready 后 replace/attach。 + +## V4 转发 + +`submitEncodedField` 先守住与 Android 相同的 UTF-8 ID 1..64、contract 1..256 KiB、QBIN 1..64 MiB、generation>0 边界,再调用 Core `parseResolvedFieldProfileContractJson(...)` 和 `RasterArraySource::submitEncodedField(...)`。十个 Core stable result 逐值映射为 ObjC enum;facade 生命周期错误不会伪装成 submit result。 + +Color binding 传递 fieldKey、semantic/group、SCALAR/VELOCITY_COMPONENT/DIRECTION_ANGLE 和 expected unit。Particle binding 保留 VECTOR_COMPONENTS 与 DIRECTION_ANGLE,inactive selector 严格为 `(0, 0)`。forecast time/play/pause/speed 和 clear 都只转发给 Core;T0/T1/mix、boundary reuse、QBIN decoder 与 renderer 不在 ObjC 实现重写。 + +## 导出与 distribution + +`MLNParticleFieldRuntime.h` 列入 `MLN_IOS_SDK_HEADERS`,由现有 `public_hdrs` 进入 `MapLibre.dynamic`,并由 `Mapbox.h` 导入。`MLNMapView_Private.h` 继续仅列于 private list。 + +`release/maplibre-particle/ios/` 建立 module 名不变的 SwiftPM binary template。后续 STEP-083 的链路固定为:`reactTag -> React View Registry -> Fabric ComponentView -> contentView -> MLNMapView -> public MLNParticleFieldRuntime`。当前 peer dependency 是 MLRN 11.3.6;STEP-083 不建立第二个 Map registry、不把 MLRN private header 作为长期 API,并用 `$MLRN_SPM_SPEC` 令 MLRN 与 Bridge 消费同一 MapLibre binary。 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 new file mode 100644 index 000000000..9d80ff3bc --- /dev/null +++ b/docs/50_Step记录/STEP-082_iOS_Native_FieldRuntime_Facade与XCFramework发行基线_v1.md @@ -0,0 +1,32 @@ +# STEP-082 iOS Native FieldRuntime Facade 与 XCFramework 发行基线 v1 + +状态:IMPLEMENTED / WAITING_MAC_XCFRAMEWORK_VALIDATION + +## 目标与基线 + +起始分支 `particle/main`,起始 HEAD `3e03949d3411c830cfff4bf320cd350f5b6a7527`。本 Step 只新增 iOS public V4 facade 和 XCFramework distribution 基线;不重做 STEP-081,也不进入 STEP-083/RN。 + +## 执行清单 + +- [x] iOS public Native FieldRuntime facade。 +- [x] MapView-owned replace/current/dispose 生命周期。 +- [x] V4 submit、binding、forecast、clear 转发。 +- [x] style reload/source missing fail-closed 与 public header 无 Core/STL 泄漏。 +- [x] XCFramework public header 构建图接线。 +- [x] distribution-ready 模板和中文设计文档。 +- [ ] macOS XCFramework 成功构建、Headers/symbol/zip SHA-256 验证。 +- [ ] `git diff --check`、scoped commit、push 与最终 HEAD(在实现和文档收尾后执行)。 + +## 允许与禁止 + +允许修改 iOS public header/implementation、Bazel public export、release 模板以及指定中文文档。禁止改 QBIN V2、FieldProfile、BindingProjection、renderer/Metal physics、STEP-081 smoke、Android/RN/NavSea;本 Step 未修改这些范围。 + +## 构建记录与阻塞 + +macOS 15.7.7 / Xcode 26.3 / Bazel 8.6.0。已执行 `bazel build //platform/ios:MapLibre.dynamic`;在本 facade 编译前,基线 `//:generated_style_code` 失败:`bazel/core.bzl` 要求 generator 输出 `src/mbgl/style/layers/particle_validation_layer.cpp`,但 `scripts/generate-style-code.mjs` 不生成该文件。该 Core/Bazel codegen 问题不属于本 Step 允许范围,未以扩大修改规避。 + +iPhoneOS arm64 CMake `ios-sdk-static` 编译正在以现有 `build-ios` 进行;最终结果必须在完成报告据实更新。未生成 zip,manifest 为 `NOT_BUILT`。 + +## 下一步 + +先由独立允许范围处理 Bazel style-codegen 基线,随后在相同 commit 重跑 XCFramework 并检查 Headers、public ObjC symbol 和 zip SHA-256。通过前不得写 CLOSED/PASS 或进入 STEP-083。 diff --git a/platform/ios/bazel/files.bzl b/platform/ios/bazel/files.bzl index b2f43b6ea..240631997 100644 --- a/platform/ios/bazel/files.bzl +++ b/platform/ios/bazel/files.bzl @@ -5,6 +5,7 @@ MLN_IOS_SDK_HEADERS = [ "src/MLNCameraChangeReason.h", "src/MLNCompassButton.h", "src/MLNMapProjection.h", + "src/MLNParticleFieldRuntime.h", "src/MLNMapView+IBAdditions.h", "src/MLNMapViewDelegate.h", "src/MLNMapView.h", @@ -67,6 +68,7 @@ MLN_IOS_PUBLIC_OBJCPP_SOURCE = [ "src/MLNMapProjection.mm", "src/MLNMapView.mm", "src/MLNMapView+Impl.mm", + "src/MLNParticleFieldRuntime.mm", "src/MLNScaleBar.mm", "src/UIColor+MLNAdditions.mm", "src/UIImage+MLNAdditions.mm", diff --git a/platform/ios/src/MLNParticleFieldRuntime.h b/platform/ios/src/MLNParticleFieldRuntime.h new file mode 100644 index 000000000..9542e4a2f --- /dev/null +++ b/platform/ios/src/MLNParticleFieldRuntime.h @@ -0,0 +1,122 @@ +#import + +#import "MLNFoundation.h" +#import "MLNMapView.h" + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXPORT NSErrorDomain const MLNParticleFieldRuntimeErrorDomain; + +typedef NS_ERROR_ENUM(MLNParticleFieldRuntimeErrorDomain, MLNParticleFieldRuntimeErrorCode) { + MLNParticleFieldRuntimeErrorInvalidArgument = 1, + MLNParticleFieldRuntimeErrorDisposed, + MLNParticleFieldRuntimeErrorMapUnavailable, + MLNParticleFieldRuntimeErrorStyleNotReady, + MLNParticleFieldRuntimeErrorSourceMissing, + MLNParticleFieldRuntimeErrorSourceTypeMismatch, + MLNParticleFieldRuntimeErrorWeatherColorLayerMissing, + MLNParticleFieldRuntimeErrorWeatherColorLayerTypeMismatch, + MLNParticleFieldRuntimeErrorParticleLayerMissing, + MLNParticleFieldRuntimeErrorParticleLayerTypeMismatch, + MLNParticleFieldRuntimeErrorLayerSourceMismatch, +}; + +typedef NS_ENUM(NSInteger, MLNParticleEncodedFieldSubmitResult) { + /// Facade/lifecycle errors are returned through NSError and use no Core result. + MLNParticleEncodedFieldSubmitResultNone = 0, + MLNParticleEncodedFieldSubmitResultAcceptedInserted = 1, + MLNParticleEncodedFieldSubmitResultAcceptedReplacedRevision = 2, + MLNParticleEncodedFieldSubmitResultDuplicate = 3, + MLNParticleEncodedFieldSubmitResultRejectedDecode = -1, + MLNParticleEncodedFieldSubmitResultRejectedContract = -2, + MLNParticleEncodedFieldSubmitResultRejectedInvalidFieldKey = -3, + MLNParticleEncodedFieldSubmitResultRejectedIdentityConflict = -4, + MLNParticleEncodedFieldSubmitResultRejectedStaleRevision = -5, + MLNParticleEncodedFieldSubmitResultRejectedIncompatible = -6, + MLNParticleEncodedFieldSubmitResultRejectedCapacity = -7, +}; + +typedef NS_ENUM(NSInteger, MLNParticleFieldValueClass) { + MLNParticleFieldValueClassScalar = 0, + MLNParticleFieldValueClassVelocityComponent = 1, + MLNParticleFieldValueClassDirectionAngle = 2, +}; + +typedef NS_ENUM(NSInteger, MLNParticleEncodedParticleBindingMode) { + MLNParticleEncodedParticleBindingModeVectorComponents = 0, + MLNParticleEncodedParticleBindingModeDirectionAngle = 1, +}; + +/** Host-neutral identifiers for the existing style JSON raster-array and particle layers. */ +MLN_EXPORT +@interface MLNParticleFieldRuntimeConfiguration : NSObject + +@property (nonatomic, copy, readonly) NSString *sourceIdentifier; +@property (nonatomic, copy, readonly) NSString *weatherColorLayerIdentifier; +@property (nonatomic, copy, readonly) NSString *particleLayerIdentifier; + +- (instancetype)initWithSourceIdentifier:(NSString *)sourceIdentifier + weatherColorLayerIdentifier:(NSString *)weatherColorLayerIdentifier + particleLayerIdentifier:(NSString *)particleLayerIdentifier NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +@end + +/** + A map-scoped V4 QBIN control facade. It owns neither a provider nor Core field + state; each operation resolves the current style again so style reloads fail closed. + */ +MLN_EXPORT +@interface MLNParticleFieldRuntime : NSObject + +@property (nonatomic, copy, readonly) MLNParticleFieldRuntimeConfiguration *configuration; +@property (nonatomic, readonly, getter=isDisposed) BOOL disposed; + +- (instancetype)init NS_UNAVAILABLE; +- (void)dispose; + +- (MLNParticleEncodedFieldSubmitResult)submitEncodedFieldWithFieldKey:(NSString *)fieldKey + fieldID:(NSString *)fieldID + generation:(uint64_t)generation + dataVersion:(uint64_t)dataVersion + contractJSON:(NSString *)contractJSON + qbin:(NSData *)qbin + error:(NSError * _Nullable * _Nullable)error; + +- (BOOL)setEncodedColorBindingWithFieldKey:(NSString *)fieldKey + semanticID:(uint16_t)semanticID + groupID:(uint16_t)groupID + expectedValueClass:(MLNParticleFieldValueClass)expectedValueClass + expectedUnitID:(uint16_t)expectedUnitID + error:(NSError * _Nullable * _Nullable)error; + +- (BOOL)setEncodedParticleBindingWithFieldKey:(NSString *)fieldKey + mode:(MLNParticleEncodedParticleBindingMode)mode + uSemanticID:(uint16_t)uSemanticID + uGroupID:(uint16_t)uGroupID + vSemanticID:(uint16_t)vSemanticID + vGroupID:(uint16_t)vGroupID + directionSemanticID:(uint16_t)directionSemanticID + directionGroupID:(uint16_t)directionGroupID + expectedUnitID:(uint16_t)expectedUnitID + error:(NSError * _Nullable * _Nullable)error; + +- (BOOL)setEncodedForecastTime:(uint64_t)timestamp error:(NSError * _Nullable * _Nullable)error; +- (BOOL)setEncodedForecastPlaybackState:(BOOL)playing error:(NSError * _Nullable * _Nullable)error; +- (BOOL)setEncodedForecastPlaybackSpeed:(float)speed error:(NSError * _Nullable * _Nullable)error; +- (BOOL)clearEncodedFieldWithFieldKey:(NSString *)fieldKey error:(NSError * _Nullable * _Nullable)error; +- (BOOL)clearAllEncodedFieldsWithError:(NSError * _Nullable * _Nullable)error; + +@end + +/** MapView owns the runtime and disposes a replaced runtime before attaching a new one. */ +@interface MLNMapView (ParticleFieldRuntime) + +- (nullable MLNParticleFieldRuntime *)replaceParticleFieldRuntimeWithConfiguration:(MLNParticleFieldRuntimeConfiguration *)configuration + error:(NSError * _Nullable * _Nullable)error; +- (nullable MLNParticleFieldRuntime *)particleFieldRuntime; +- (void)disposeParticleFieldRuntime; + +@end + +NS_ASSUME_NONNULL_END diff --git a/platform/ios/src/MLNParticleFieldRuntime.mm b/platform/ios/src/MLNParticleFieldRuntime.mm new file mode 100644 index 000000000..b7e88f7a1 --- /dev/null +++ b/platform/ios/src/MLNParticleFieldRuntime.mm @@ -0,0 +1,253 @@ +#import "MLNParticleFieldRuntime.h" + +#import "MLNMapView_Private.h" + +#import + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +NSErrorDomain const MLNParticleFieldRuntimeErrorDomain = @"org.maplibre.MLNParticleFieldRuntime"; + +namespace { +constexpr NSUInteger MaxEncodedIdentifierUTF8Bytes = 64; +constexpr NSUInteger MaxContractUTF8Bytes = 256 * 1024; +constexpr NSUInteger MaxQbinBytes = 64 * 1024 * 1024; + +void setError(NSError **error, MLNParticleFieldRuntimeErrorCode code, NSString *description) { + if (error) *error = [NSError errorWithDomain:MLNParticleFieldRuntimeErrorDomain + code:code + userInfo:@{NSLocalizedDescriptionKey: description}]; +} + +bool hasValidIdentifier(NSString *value) { + return value && value.length > 0 && [value lengthOfBytesUsingEncoding:NSUTF8StringEncoding] <= MaxEncodedIdentifierUTF8Bytes; +} + +bool hasValidContract(NSString *value) { + return value && value.length > 0 && [value lengthOfBytesUsingEncoding:NSUTF8StringEncoding] <= MaxContractUTF8Bytes; +} + +std::string stringFromNSString(NSString *value) { + const char *utf8 = value.UTF8String; + return utf8 ? std::string(utf8) : std::string(); +} + +} // namespace + +@implementation MLNParticleFieldRuntimeConfiguration + +- (instancetype)initWithSourceIdentifier:(NSString *)sourceIdentifier + weatherColorLayerIdentifier:(NSString *)weatherColorLayerIdentifier + particleLayerIdentifier:(NSString *)particleLayerIdentifier { + NSParameterAssert(sourceIdentifier.length > 0); + NSParameterAssert(weatherColorLayerIdentifier.length > 0); + NSParameterAssert(particleLayerIdentifier.length > 0); + if (sourceIdentifier.length == 0 || weatherColorLayerIdentifier.length == 0 || particleLayerIdentifier.length == 0) { + [NSException raise:NSInvalidArgumentException format:@"Particle field runtime identifiers must be non-empty."]; + } + if ((self = [super init])) { + _sourceIdentifier = [sourceIdentifier copy]; + _weatherColorLayerIdentifier = [weatherColorLayerIdentifier copy]; + _particleLayerIdentifier = [particleLayerIdentifier copy]; + } + return self; +} + +- (id)copyWithZone:(NSZone *)zone { + return [[[self class] allocWithZone:zone] initWithSourceIdentifier:_sourceIdentifier + weatherColorLayerIdentifier:_weatherColorLayerIdentifier + particleLayerIdentifier:_particleLayerIdentifier]; +} + +@end + +@interface MLNParticleFieldRuntime () { + __weak MLNMapView *_mapView; + BOOL _disposed; +} +- (instancetype)initWithMapView:(MLNMapView *)mapView + configuration:(MLNParticleFieldRuntimeConfiguration *)configuration + error:(NSError **)error; +@end + +@implementation MLNParticleFieldRuntime + +- (instancetype)initWithMapView:(MLNMapView *)mapView + configuration:(MLNParticleFieldRuntimeConfiguration *)configuration + error:(NSError **)error { + if ((self = [super init])) { + _mapView = mapView; + _configuration = [configuration copy]; + if (![self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &) { return true; }]) return nil; + } + return self; +} + +- (BOOL)isDisposed { return _disposed; } + +- (void)dispose { + _disposed = YES; + _mapView = nil; +} + +- (BOOL)withCurrentSource:(NSError **)error action:(bool (^)(mbgl::style::RasterArraySource &))action { + if (_disposed) { + setError(error, MLNParticleFieldRuntimeErrorDisposed, @"Particle field runtime has been disposed."); + return NO; + } + MLNMapView *mapView = _mapView; + if (!mapView) { + setError(error, MLNParticleFieldRuntimeErrorMapUnavailable, @"The owning map view is no longer available."); + return NO; + } + @try { + auto& style = [mapView mbglMap].getStyle(); + const auto sourceID = stringFromNSString(_configuration.sourceIdentifier); + auto *untypedSource = style.getSource(sourceID); + auto *source = untypedSource ? untypedSource->as() : nullptr; + if (!source) { + setError(error, untypedSource ? MLNParticleFieldRuntimeErrorSourceTypeMismatch : MLNParticleFieldRuntimeErrorSourceMissing, + untypedSource ? @"The configured source is not a raster-array source." : @"The configured raster-array source is missing from the current style."); + return NO; + } + const auto weatherID = stringFromNSString(_configuration.weatherColorLayerIdentifier); + auto *untypedWeather = style.getLayer(weatherID); + auto *weather = untypedWeather && mbgl::style::layerTypeInfoEquals(untypedWeather->getTypeInfo(), mbgl::style::WeatherColorLayer::Impl::staticTypeInfo()) + ? static_cast(untypedWeather) : nullptr; + if (!weather) { + setError(error, untypedWeather ? MLNParticleFieldRuntimeErrorWeatherColorLayerTypeMismatch : MLNParticleFieldRuntimeErrorWeatherColorLayerMissing, + untypedWeather ? @"The configured weather-color layer has the wrong Core type." : @"The configured weather-color layer is missing from the current style."); + return NO; + } + const auto particleID = stringFromNSString(_configuration.particleLayerIdentifier); + auto *untypedParticle = style.getLayer(particleID); + auto *particle = untypedParticle && mbgl::style::layerTypeInfoEquals(untypedParticle->getTypeInfo(), mbgl::style::RasterParticleLayer::Impl::staticTypeInfo()) + ? static_cast(untypedParticle) : nullptr; + if (!particle) { + setError(error, untypedParticle ? MLNParticleFieldRuntimeErrorParticleLayerTypeMismatch : MLNParticleFieldRuntimeErrorParticleLayerMissing, + untypedParticle ? @"The configured raster-particle layer has the wrong Core type." : @"The configured raster-particle layer is missing from the current style."); + return NO; + } + if (weather->getSourceID() != sourceID || particle->getSourceID() != sourceID) { + setError(error, MLNParticleFieldRuntimeErrorLayerSourceMismatch, @"Configured particle layers do not reference the configured raster-array source."); + return NO; + } + return action(*source); + } @catch (NSException *exception) { + setError(error, MLNParticleFieldRuntimeErrorMapUnavailable, exception.reason ?: @"The underlying map is unavailable."); + return NO; + } +} + +- (MLNParticleEncodedFieldSubmitResult)submitEncodedFieldWithFieldKey:(NSString *)fieldKey + fieldID:(NSString *)fieldID + generation:(uint64_t)generation + dataVersion:(uint64_t)dataVersion + contractJSON:(NSString *)contractJSON + qbin:(NSData *)qbin + error:(NSError **)error { + if (!hasValidIdentifier(fieldKey) || !hasValidIdentifier(fieldID) || !hasValidContract(contractJSON) || generation == 0 || qbin.length == 0 || qbin.length > MaxQbinBytes) { + setError(error, MLNParticleFieldRuntimeErrorInvalidArgument, @"Encoded field arguments violate the V4 transport contract."); + return MLNParticleEncodedFieldSubmitResultNone; + } + const auto contract = mbgl::particle::parseResolvedFieldProfileContractJson(stringFromNSString(contractJSON)); + if (!contract || !hasValidIdentifier([NSString stringWithUTF8String:contract->profileID.c_str()])) { + setError(error, MLNParticleFieldRuntimeErrorInvalidArgument, @"contractJSON is not a valid V4 field profile contract."); + return MLNParticleEncodedFieldSubmitResultNone; + } + std::vector bytes(qbin.length); + [qbin getBytes:bytes.data() length:bytes.size()]; + __block MLNParticleEncodedFieldSubmitResult result = MLNParticleEncodedFieldSubmitResultNone; + const BOOL completed = [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source) { + const auto coreResult = source.submitEncodedField(stringFromNSString(fieldKey), + {stringFromNSString(fieldID), generation, dataVersion}, *contract, bytes); + result = static_cast(coreResult); + return true; + }]; + return completed ? result : MLNParticleEncodedFieldSubmitResultNone; +} + +- (BOOL)setEncodedColorBindingWithFieldKey:(NSString *)fieldKey semanticID:(uint16_t)semanticID groupID:(uint16_t)groupID expectedValueClass:(MLNParticleFieldValueClass)expectedValueClass expectedUnitID:(uint16_t)expectedUnitID error:(NSError **)error { + if (!hasValidIdentifier(fieldKey) || semanticID == 0 || expectedValueClass < MLNParticleFieldValueClassScalar || expectedValueClass > MLNParticleFieldValueClassDirectionAngle) { + setError(error, MLNParticleFieldRuntimeErrorInvalidArgument, @"Invalid encoded color binding."); return NO; + } + return [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source) { + source.setColorBinding({stringFromNSString(fieldKey), {semanticID, groupID}, static_cast(expectedValueClass), expectedUnitID}); + return true; + }]; +} + +- (BOOL)setEncodedParticleBindingWithFieldKey:(NSString *)fieldKey mode:(MLNParticleEncodedParticleBindingMode)mode uSemanticID:(uint16_t)uSemanticID uGroupID:(uint16_t)uGroupID vSemanticID:(uint16_t)vSemanticID vGroupID:(uint16_t)vGroupID directionSemanticID:(uint16_t)directionSemanticID directionGroupID:(uint16_t)directionGroupID expectedUnitID:(uint16_t)expectedUnitID error:(NSError **)error { + if (!hasValidIdentifier(fieldKey)) { setError(error, MLNParticleFieldRuntimeErrorInvalidArgument, @"Invalid encoded particle field key."); return NO; } + const bool vector = mode == MLNParticleEncodedParticleBindingModeVectorComponents && uSemanticID != 0 && vSemanticID != 0 && directionSemanticID == 0 && directionGroupID == 0; + const bool direction = mode == MLNParticleEncodedParticleBindingModeDirectionAngle && uSemanticID == 0 && uGroupID == 0 && vSemanticID == 0 && vGroupID == 0 && directionSemanticID != 0; + if (!vector && !direction) { setError(error, MLNParticleFieldRuntimeErrorInvalidArgument, @"Inactive particle selectors must be exactly semanticID = 0 and groupID = 0."); return NO; } + return [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source) { + mbgl::particle::ParticleBinding binding; + binding.fieldKey = stringFromNSString(fieldKey); + binding.expectedUnitID = expectedUnitID; + if (vector) { binding.mode = mbgl::particle::ParticleBindingMode::VectorComponents; binding.uPlane = {uSemanticID, uGroupID}; binding.vPlane = {vSemanticID, vGroupID}; } + else { binding.mode = mbgl::particle::ParticleBindingMode::DirectionAngle; binding.directionPlane = {directionSemanticID, directionGroupID}; } + source.setParticleBinding(std::move(binding)); + return true; + }]; +} + +- (BOOL)setEncodedForecastTime:(uint64_t)timestamp error:(NSError **)error { + return [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source) { return source.setRequestedForecastTime(timestamp); }]; +} + +- (BOOL)setEncodedForecastPlaybackState:(BOOL)playing error:(NSError **)error { + return [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source) { source.setForecastPlaybackState(playing ? mbgl::particle::ForecastPlaybackState::Playing : mbgl::particle::ForecastPlaybackState::Paused); return true; }]; +} + +- (BOOL)setEncodedForecastPlaybackSpeed:(float)speed error:(NSError **)error { + if (!std::isfinite(speed) || speed <= 0) { setError(error, MLNParticleFieldRuntimeErrorInvalidArgument, @"Forecast playback speed must be finite and greater than zero."); return NO; } + return [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source) { return source.setForecastPlaybackSpeed(speed); }]; +} + +- (BOOL)clearEncodedFieldWithFieldKey:(NSString *)fieldKey error:(NSError **)error { + if (!hasValidIdentifier(fieldKey)) { setError(error, MLNParticleFieldRuntimeErrorInvalidArgument, @"Invalid encoded field key."); return NO; } + return [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source) { return source.clearEncodedField(stringFromNSString(fieldKey)); }]; +} + +- (BOOL)clearAllEncodedFieldsWithError:(NSError **)error { + return [self withCurrentSource:error action:^bool(mbgl::style::RasterArraySource &source) { source.clearAllEncodedFields(); return true; }]; +} + +@end + +namespace { const void *ParticleFieldRuntimeAssociationKey = &ParticleFieldRuntimeAssociationKey; } + +@implementation MLNMapView (ParticleFieldRuntime) + +- (MLNParticleFieldRuntime *)replaceParticleFieldRuntimeWithConfiguration:(MLNParticleFieldRuntimeConfiguration *)configuration error:(NSError **)error { + [self disposeParticleFieldRuntime]; + if (!configuration) { setError(error, MLNParticleFieldRuntimeErrorInvalidArgument, @"A particle field runtime configuration is required."); return nil; } + MLNParticleFieldRuntime *runtime = [[MLNParticleFieldRuntime alloc] initWithMapView:self configuration:configuration error:error]; + if (!runtime) return nil; + objc_setAssociatedObject(self, ParticleFieldRuntimeAssociationKey, runtime, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + return runtime; +} + +- (MLNParticleFieldRuntime *)particleFieldRuntime { + return objc_getAssociatedObject(self, ParticleFieldRuntimeAssociationKey); +} + +- (void)disposeParticleFieldRuntime { + MLNParticleFieldRuntime *runtime = self.particleFieldRuntime; + [runtime dispose]; + objc_setAssociatedObject(self, ParticleFieldRuntimeAssociationKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +@end diff --git a/platform/ios/src/Mapbox.h b/platform/ios/src/Mapbox.h index ae1ee3057..d46627117 100644 --- a/platform/ios/src/Mapbox.h +++ b/platform/ios/src/Mapbox.h @@ -42,6 +42,7 @@ FOUNDATION_EXPORT MLN_EXPORT const unsigned char MapboxVersionString[]; #import "MLNMapView+IBAdditions.h" #import "MLNMapView.h" #import "MLNMapViewDelegate.h" +#import "MLNParticleFieldRuntime.h" #import "MLNMultiPoint.h" #import "MLNNetworkConfiguration.h" #import "MLNOfflinePack.h" diff --git a/release/maplibre-particle/ios/Package.swift.template b/release/maplibre-particle/ios/Package.swift.template new file mode 100644 index 000000000..cb7025fb8 --- /dev/null +++ b/release/maplibre-particle/ios/Package.swift.template @@ -0,0 +1,18 @@ +// Replace every <...> placeholder only after a reproducible XCFramework zip exists. +// The module/product name must remain MapLibre for MLRN compatibility. +// swift-tools-version: 5.7 +import PackageDescription + +let package = Package( + name: "MapLibreDistribution", + products: [ + .library(name: "MapLibre", targets: ["MapLibre"]), + ], + targets: [ + .binaryTarget( + name: "MapLibre", + url: "", + checksum: "" + ), + ] +) diff --git a/release/maplibre-particle/ios/README.md b/release/maplibre-particle/ios/README.md new file mode 100644 index 000000000..b0b46a275 --- /dev/null +++ b/release/maplibre-particle/ios/README.md @@ -0,0 +1,14 @@ +# MapLibre Particle iOS Binary Distribution 基线 + +本目录只保存后续独立 distribution repository 所需的可审计模板;不发布二进制、不创建远端仓库,也不改变 MapLibre iOS 上游版本号。 + +模块和 product 始终为 `MapLibre`。MLRN 11.3.6 与后续 Particle Bridge 必须通过同一份自定义 MapLibre binary 消费,不能再链接第二个同功能但不同模块名的 framework。 + +发布时: + +1. 在 macOS 上从对应 Git commit 成功生成 `MapLibre.dynamic.xcframework`; +2. 压缩为 `MapLibre.dynamic.xcframework.zip`,对 zip 计算 SHA-256; +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。 diff --git a/release/maplibre-particle/ios/release-manifest.json b/release/maplibre-particle/ios/release-manifest.json new file mode 100644 index 000000000..c15506570 --- /dev/null +++ b/release/maplibre-particle/ios/release-manifest.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "status": "NOT_BUILT", + "distributionVersion": "6.28.0-particle.1", + "moduleName": "MapLibre", + "artifact": { + "fileName": "MapLibre.dynamic.xcframework.zip", + "url": "NOT_BUILT", + "sizeBytes": null, + "sha256": "NOT_BUILT" + }, + "sourceCommit": "3e03949d3411c830cfff4bf320cd350f5b6a7527", + "notes": "STEP-082 created the template only. Populate artifact fields only from a successful macOS XCFramework build." +} diff --git a/粒子开发记录.md b/粒子开发记录.md index de216f7d9..b76c0254a 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -827,3 +827,11 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2` - Wind QBIN Golden fixture 范围为 112.5E..157.5E / 0..55.776573N;iOS private smoke 相机调整到约 27.8883N / 135E、zoom 3 后可正常观察。所有 STEP081 临时 readback/固定线/Point fallback 均已清除;错误 Point fallback 的整屏绿色结果未进入正式代码。 - STEP-081 功能/真机验收提交 `f1c0f92b2b`(`fix: close STEP-081 iOS Metal particle rendering`)已成功 push 到 `origin/particle/main`。 + +### 2026-09-18 STEP-082 iOS Native FieldRuntime Facade 与 XCFramework 发行基线 + +- 新增 public `MLNParticleFieldRuntimeConfiguration`、`MLNParticleFieldRuntime` 与 `MLNMapView (ParticleFieldRuntime)`;MapView 通过 associated object 持有 runtime,runtime 仅弱引用 MapView,replace 先 dispose 旧实例,dispose 幂等。 +- facade 每次命令均重新查找 current style 的 `RasterArraySource`、`WeatherColorLayer` 与 `RasterParticleLayer` 并核验真实 Core 类型和 source 关联,不缓存跨 style 生命周期的 Core pointer;style reload、source/layer 缺失均以独立 NSError fail-closed。 +- V4 入口复用 Core contract parser、decoder/submit、binding、forecast 与 clear;ObjC 层只执行与 Android 一致的 transport guard 和稳定结果映射,未改 QBIN/renderer/Metal、Android、RN 或 NavSea,也未重做 STEP-081。 +- `MLNParticleFieldRuntime.h` 已加入 iOS Bazel SDK public headers 和 `Mapbox.h`;`MLNMapView_Private.h` 仍仅为 implementation private include。建立 `release/maplibre-particle/ios/` SwiftPM template/manifest,因 XCFramework zip 未生成而明确 `NOT_BUILT`。 +- macOS Bazel `//platform/ios:MapLibre.dynamic` 在 facade 编译前被既有 style-codegen 输出声明阻断:`particle_validation_layer.cpp` 被列为生成输出但 generator 不生成;未越权改 Core/codegen。iPhoneOS arm64 CMake `ios-sdk-static` 编译已启动,最终状态以 Step/完成报告记录为准;在 XCFramework 成功验证前,STEP-082 保持 `IMPLEMENTED / WAITING_MAC_XCFRAMEWORK_VALIDATION`。