diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml
index 9bd1d56..187f4e7 100644
--- a/.github/workflows/swiftlint.yml
+++ b/.github/workflows/swiftlint.yml
@@ -13,9 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- - name: GitHub Action for SwiftLint
- uses: norio-nomura/action-swiftlint@3.2.1
- name: GitHub Action for SwiftLint with --strict
uses: norio-nomura/action-swiftlint@3.2.1
with:
- args: --strict
\ No newline at end of file
+ args: --fix --quiet
diff --git a/Example/ForeFlightKMLDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/ForeFlightKMLDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 92b3d2a..1bd35e1 100644
--- a/Example/ForeFlightKMLDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Example/ForeFlightKMLDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Haelix-Code/ForeFlightKML/",
"state" : {
- "revision" : "573e10270008ce4ebbaaac14465afb2acd2beaf7",
- "version" : "0.2.1"
+ "revision" : "25a2e22beae7c4a3ee0c0082e8da42c8f1e87eda",
+ "version" : "0.3.0"
}
},
{
diff --git a/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift b/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift
index 9cba4d1..a12f5eb 100644
--- a/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift
+++ b/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift
@@ -4,7 +4,7 @@ import CoreLocation
import ForeFlightKML
enum KMLGenerator {
- static func generateCircleKML(center: CLLocationCoordinate2D, radiusMeters: Double) -> String {
+ static func generateCircleKML(center: CLLocationCoordinate2D, radiusMeters: Double) throws -> Data? {
let builder = ForeFlightKMLBuilder(documentName: "Foreflight KML Demo")
let centerCoordinate = Coordinate(latitude: center.latitude, longitude: center.longitude)
@@ -23,7 +23,8 @@ enum KMLGenerator {
radiusMeters: radiusMeters * 2,
style: PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3)))
- return builder.build()
+ return try builder.buildKMZ()
+
}
static func polygonCoordinatesForMap(center: CLLocationCoordinate2D, radiusMeters: Double) -> [CLLocationCoordinate2D] {
diff --git a/Example/ForeFlightKMLDemo/Views/ContentView.swift b/Example/ForeFlightKMLDemo/Views/ContentView.swift
index a18b871..80f2ab4 100644
--- a/Example/ForeFlightKMLDemo/Views/ContentView.swift
+++ b/Example/ForeFlightKMLDemo/Views/ContentView.swift
@@ -48,19 +48,19 @@ struct ContentView: View {
private func handleMapTap(_ coord: CLLocationCoordinate2D) {
lastTapCoordinate = coord
+ do {
+ let kmz = try KMLGenerator.generateCircleKML(center: coord, radiusMeters: defaultRadiusMeters)
- let kml = KMLGenerator.generateCircleKML(center: coord, radiusMeters: defaultRadiusMeters)
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
+ let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(dateFormatter.string(from: Date())).kmz")
- let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(dateFormatter.string(from: Date())).kml")
- do {
- try kml.data(using: .utf8)?.write(to: tmpURL)
+ try kmz?.write(to: tmpURL)
kmlToShareURL = tmpURL
showingShare = true
} catch {
- print("Failed to write KML: \(error)")
+ print("Failed to write KMZ: \(error)")
kmlToShareURL = nil
}
}
diff --git a/Package.resolved b/Package.resolved
index b312d27..a95a72e 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -8,6 +8,15 @@
"revision" : "c72d7ea459c6eee4d041272c61f84df61d850091",
"version" : "0.2.2"
}
+ },
+ {
+ "identity" : "zipfoundation",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/weichsel/ZIPFoundation.git",
+ "state" : {
+ "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
+ "version" : "0.9.20"
+ }
}
],
"version" : 2
diff --git a/Package.swift b/Package.swift
index 14f7748..71dbf62 100644
--- a/Package.swift
+++ b/Package.swift
@@ -11,13 +11,18 @@ let package = Package(
)
],
dependencies: [
- .package(url: "https://github.com/florianreinhart/Geodesy", .upToNextMajor(from: "0.2.2"))
+ .package(url: "https://github.com/florianreinhart/Geodesy", .upToNextMajor(from: "0.2.2")),
+ .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.0")
],
targets: [
.target(
name: "ForeFlightKML",
dependencies: [
- .product(name: "Geodesy", package: "Geodesy")
+ .product(name: "Geodesy", package: "Geodesy"),
+ .product(name: "ZIPFoundation", package: "ZIPFoundation")
+ ],
+ resources: [
+ .process("Resources")
]
),
.testTarget(
diff --git a/README.md b/README.md
index 099d969..01277ac 100644
--- a/README.md
+++ b/README.md
@@ -2,26 +2,21 @@
> Swift framework to build KML files in Jeppesen ForeFlight friendly format.
-This package provides a small, focused API for composing KML documents suitable for importing into ForeFlight as **User Map Shapes (KML)**. It intentionally avoids UI concerns — it gives you `String` KML output (or bytes) which your app can write to disk and share using the standard iOS share sheet.
+This package provides a small, focused API for composing KML/KMZ documents suitable for importing into ForeFlight as **User Map Shapes (KML)**. It intentionally avoids UI concerns — it gives you `String` KML output (or bytes) which your app can write to disk and share using the standard iOS share sheet.
----
## Quick highlights
-- Compose `Placemark`s with `Point`, `LineString`, `Polygon` and derived geometry helpers (circles, arc segments, etc.).
+- Compose `Placemark`s with `Point`, `LineString`, `Polygon` and derived geometry helpers (circles, arc sectors, etc.).
- Create reusable styles (`Style`, `LineStyle`, `PolyStyle`, `IconStyle`, `LabelStyle`) and assign them to placemarks.
-- `ForeFlightKMLBuilder` collects placemarks and styles, emits a complete `kml` document string.
+- `ForeFlightKMLBuilder` collects placemarks and styles, emits a complete `kml` or `kmz` document .
- Lightweight — no UI code.
----
-
## Install
-
1. In Xcode: **File › Add Packages...**
2. Enter the repository URL.
3. Choose the `ForeFlightKML` package product and add it to your app target.
----
## Example Output
Using the example given on the [ForeFlight website](https://foreflight.com/support/user-map-shapes/) the below is generated using this Framework.
@@ -30,27 +25,23 @@ See `/Tests/ForeFlightKMLTests/UserMapShapesSampleFullTest.swift`
-## Minimal Quick Start
+## Quick Start
```swift
import ForeFlightKML
-import Geodesy (we use this for Coordinate and relative positioniong)
+import Geodesy (used for coordinates)
-// Example: Airport Traffic Pattern
let builder = ForeFlightKMLBuilder(documentName: "Airport with ATZ")
-
-// Runway centerline
builder.addLine(
name: "Runway 15-33",
coordinates: [Coordinate(latitude:, longitude:),Coordinate(latitude:, longitude:)],
style: LineStyle(color: .black)
)
-// Traffic Warning Area
builder.addLineCircle(
name: "Airport ATZ",
- center: airportCenter,
- radiusMeters: 4630, // 2.5 nautical mile ATZ
+ center: Coordinate(latitude:, longitude:),
+ radiusMeters: 4630,
PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3))
)
@@ -60,19 +51,40 @@ presentShareSheet(with: url)
```
> **Note**: ForeFlight supports importing KML/KMZ files via the iOS share sheet. See ForeFlight's docs for exact import behavior.
----
-## API quick reference (important types)
-- `ForeFlightKMLBuilder` — builder for the KML document. Methods: `addPlacemark(_:)`, `kmlString()`.
+## API Reference
+
+### KMLBuidler
+ `ForeFlightKMLBuilder` is the builder for the KML/KMZ document.
+ - Document name can be set on `init` or with `setDocumentName()`
+ - Elements can be manually added using `addPlacemark(_:)`
+ - The output is accessed by: for KML `try builder.build()` or for KMZ: `try builder.buildKMZ()`
+
+### KMLBuilder Convenience Elements
+ - `addPoint` Add a point with style.
+ - `addLine` Add a line connecting multiple coordinates.
+ - `addLineCircle` Add a circular line (approximated by line segments).
+ - `addLineSector` Add an arc sector line geometry.
+ - `addPolygon` Add a polygon with outer boundary and optional holes.
+ - `addPolygonCircle` Add a polygon with outer boundary and optional holes.
+ - `addPolygonSector` Add a filled sector polygon (pie slice).
+ - `addPolygonAnnularSector` Add a filled annular (ring) sector polygon.
+ - `addLabel` Add a text-only label placemark at a coordinate.
+
+### ForeflightKMLBuilder Export formats
+- `kml String` via `builder.build()`
+- `kml Data` via `builder.kmlData()`
+- `kmz Data` via `builder.buildKMZ()`
+- KMZ (zipped KML) is required when using custom icons or using labelBadge (which uses a transparent .png under the hood).
+
+### Underlying elements
- `Placemark` — a Feature containing a geometry (must implement `KMLElement`). Optionally attach a `KMLStyle`.
-- Geometry types: `Point`, `Line`, `LineCircle`, `LineSegment` (segment of a Circle), `Polygon`, `PolygonCircle` (filled circle), `PolygonSegment` (filled segment) `LinearRing`.
+- Geometry types: `Point`, `Line`, `LineCircle`, `LineSector` (sector of a Circle), `Polygon`, `PolygonCircle` (filled circle), `PolygonSector` (filled sector) `LinearRing`.
- `Style` and substyles: `LineStyle`, `PolyStyle`, `IconStyle`, `LabelStyle`.
- `KMLColor` — helper to create the aabbggrr color values used by KML.
-Full public API surface is visible in the package sources; the README examples show common usage patterns.
-
----
+Full public API surface is visible in the package sources.
## Notes, conventions and gotchas
@@ -81,13 +93,10 @@ Full public API surface is visible in the package sources; the README examples s
- **Angles/bearings**: bearings (for arc & circle generation) are interpreted in degrees (0..360). The bearing convention is clockwise from north.
- **Altitude**: When you provide altitudes, the `AltitudeMode` is emitted (defaults to `.absolute` in most geometries).
- **Styles**: `Style` generates a stable `id` when provided; otherwise a UUID-based id is generated. `ForeFlightKMLBuilder` will automatically register styles added via `Placemark`.
----
## Demo & tests
-The repo contains an `Example` app that demonstrates building shapes and the `Tests` folder with unit tests. Before publishing, ensure the example builds and the tests run in CI.
-
----
+The repo contains an `Example` app that demonstrates building shapes and the `Tests` folder with unit tests.
## Contributing
diff --git a/Sources/ForeFlightKML/Errors/KMZExportError.swift b/Sources/ForeFlightKML/Errors/KMZExportError.swift
new file mode 100644
index 0000000..ba3e86e
--- /dev/null
+++ b/Sources/ForeFlightKML/Errors/KMZExportError.swift
@@ -0,0 +1,5 @@
+public enum KMZExportError: Error {
+ case kmzRequired
+ case missingLocalResource(String)
+ case archiveCreationFailed
+}
diff --git a/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift b/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift
index 715a8c6..23effa2 100644
--- a/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift
+++ b/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift
@@ -97,7 +97,7 @@ extension ForeFlightKMLBuilder {
return addPlacemark(placemark)
}
- /// Add an arc segment line geometry.
+ /// Add an arc sector line geometry.
/// - Parameters:
/// - name: Display name in ForeFlight (optional)
/// - center: Center point of the arc
@@ -110,7 +110,7 @@ extension ForeFlightKMLBuilder {
/// - style: Path style defining line appearance (optional)
/// - Returns: Self for method chaining
@discardableResult
- public func addLineSegment(
+ public func addLineSector(
name: String? = nil,
center: Coordinate,
radiusMeters: Double,
@@ -124,7 +124,7 @@ extension ForeFlightKMLBuilder {
precondition(radiusMeters > 0, "Radius must be positive")
precondition(numberOfPoints >= 3, "Need at least 3 segments for an arc")
- let segment = LineSegment(
+ let sector = LineSector(
center: center,
radius: radiusMeters,
startAngle: startAngle,
@@ -134,7 +134,7 @@ extension ForeFlightKMLBuilder {
tessellate: tessellate
)
- let placemark = Placemark(name: name, geometry: segment, style: style)
+ let placemark = Placemark(name: name, geometry: sector, style: style)
return addPlacemark(placemark)
}
@@ -205,7 +205,7 @@ extension ForeFlightKMLBuilder {
return addPlacemark(placemark)
}
- /// Add a filled segment polygon (pie slice).
+ /// Add a filled sector polygon (pie slice).
/// - Parameters:
/// - name: Display name in ForeFlight (optional)
/// - center: Center point of the arc
@@ -218,7 +218,7 @@ extension ForeFlightKMLBuilder {
/// - style: Polygon style defining outline and optional fill (optional)
/// - Returns: Self for method chaining
@discardableResult
- public func addPolygonSegment(
+ public func addPolygonSector(
name: String? = nil,
center: Coordinate,
radiusMeters: Double,
@@ -232,7 +232,7 @@ extension ForeFlightKMLBuilder {
precondition(radiusMeters > 0, "Radius must be positive")
precondition(numberOfPoints >= 3, "Need at least 3 segments for a segment")
- let segment = PolygonSegment(
+ let sector = PolygonSector(
center: center,
radius: radiusMeters,
startAngle: startAngle,
@@ -242,15 +242,15 @@ extension ForeFlightKMLBuilder {
tessellate: tessellate
)
- let placemark = Placemark(name: name, geometry: segment, style: style)
+ let placemark = Placemark(name: name, geometry: sector, style: style)
return addPlacemark(placemark)
}
- /// Add a filled annular (ring) segment polygon.
- /// This creates a segment between two radii, excluding the inner circle area.
+ /// Add a filled annular (ring) sector polygon.
+ /// This creates a sector between two radii, excluding the inner circle area.
/// - Parameters:
/// - name: Display name in ForeFlight (optional)
- /// - center: Center point of the segment
+ /// - center: Center point of the sector
/// - innerRadius: Inner radius in meters (the "hole" size)
/// - outerRadius: Outer radius in meters
/// - startAngle: Starting angle in degrees (0° = North, clockwise)
@@ -261,7 +261,7 @@ extension ForeFlightKMLBuilder {
/// - style: Polygon style defining outline and optional fill (optional)
/// - Returns: Self for method chaining
@discardableResult
- public func addPolygonAnnularSegment(
+ public func addPolygonAnnularSector(
name: String? = nil,
center: Coordinate,
innerRadius: Double,
@@ -277,7 +277,7 @@ extension ForeFlightKMLBuilder {
precondition(outerRadius > innerRadius, "Outer radius must be greater than inner radius")
precondition(numberOfPoints >= 3, "Need at least 3 segments for an annular segment")
- let segment = PolygonAnnularSegment(
+ let sector = PolygonAnnularSector(
center: center,
innerRadius: innerRadius,
outerRadius: outerRadius,
@@ -288,7 +288,32 @@ extension ForeFlightKMLBuilder {
tessellate: tessellate
)
- let placemark = Placemark(name: name, geometry: segment, style: style)
+ let placemark = Placemark(name: name, geometry: sector, style: style)
return addPlacemark(placemark)
}
+
+ /// Add a text-only label placemark at a coordinate.
+ /// This uses a transparent 1×1 icon to enable ForeFlight’s “badge” rendering, with the badge color driven by `IconStyle.color`.
+ /// - Important: Because this relies on a local icon asset, the output **must be exported as KMZ** (not plain KML) for the label to render correctly.
+ /// - Parameters:
+ /// - text: Label text displayed in ForeFlight
+ /// - coordinate: Geographic coordinate
+ /// - altitude: Altitude in meters (optional)
+ /// - color: Badge/background color for the label (default: white)
+ /// - Returns: Self for method chaining
+ @discardableResult
+ public func addLabel(
+ _ text: String,
+ coordinate: Coordinate,
+ altitude: Double? = nil,
+ color: KMLColor = .white
+ ) -> Self {
+ addPoint(
+ name: text,
+ coordinate: coordinate,
+ altitude: altitude,
+ style: .labelBadge(color: color)
+ )
+ }
+
}
diff --git a/Sources/ForeFlightKML/ForeFlightKML.swift b/Sources/ForeFlightKML/ForeFlightKML.swift
index 5df0777..445d6b2 100644
--- a/Sources/ForeFlightKML/ForeFlightKML.swift
+++ b/Sources/ForeFlightKML/ForeFlightKML.swift
@@ -47,12 +47,21 @@ public final class ForeFlightKMLBuilder {
return self
}
+ /// True if this document must be exported as KMZ to render correctly.
+ public var requiresKMZ: Bool {
+ styleManager.requiresKMZ
+ }
+
// MARK: - Build Methods
/// Generate the complete KML string for this document.
/// This method can be called multiple times - it doesn't modify the builder state.
/// - Returns: A complete KML document as a UTF-8 string ready for export to ForeFlight
- public func build() -> String {
+ public func build() throws -> String {
+ guard !requiresKMZ else {
+ throw KMZExportError.kmzRequired
+ }
+
return kmlString()
}
diff --git a/Sources/ForeFlightKML/ForeflightKML+KMZ.swift b/Sources/ForeFlightKML/ForeflightKML+KMZ.swift
new file mode 100644
index 0000000..300dac6
--- /dev/null
+++ b/Sources/ForeFlightKML/ForeflightKML+KMZ.swift
@@ -0,0 +1,50 @@
+import Foundation
+import ZIPFoundation
+
+public extension ForeFlightKMLBuilder {
+
+ /// Build a KMZ (ZIP) containing doc.kml and any required local assets.
+ func buildKMZ() throws -> Data? {
+
+ let kmlData = kmlData()
+
+ let archive = try Archive(accessMode: .create)
+
+ try archive.addEntry(
+ with: "doc.kml",
+ type: .file,
+ uncompressedSize: Int64(kmlData.count),
+ compressionMethod: .deflate,
+ provider: { position, size in
+ let start = Int(position)
+ guard start < kmlData.count else { return Data() }
+ let end = min(start + size, kmlData.count)
+ return kmlData.subdata(in: start.. [Coordinate] {
- var segmentPoints: [Coordinate] = []
- segmentPoints.append(center)
+ var sectorPoints: [Coordinate] = []
+ sectorPoints.append(center)
let start = startAngle.truncatingRemainder(dividingBy: 360)
let end = endAngle.truncatingRemainder(dividingBy: 360)
@@ -25,15 +25,15 @@ internal enum SegmentGeometry {
let endPoint = center.destination(with: radius, bearing: currentAngle)
- segmentPoints.append(endPoint)
+ sectorPoints.append(endPoint)
}
- segmentPoints.append(center)
- return segmentPoints
+ sectorPoints.append(center)
+ return sectorPoints
}
// swiftlint:disable:next function_parameter_count
- static func generateAnnularSegmentPoints(
+ static func generateAnnularSectorPoints(
center: Coordinate,
innerRadius: Double,
outerRadius: Double,
diff --git a/Sources/ForeFlightKML/Models/StyleManager.swift b/Sources/ForeFlightKML/Models/StyleManager.swift
index 74eaf89..fb3935a 100644
--- a/Sources/ForeFlightKML/Models/StyleManager.swift
+++ b/Sources/ForeFlightKML/Models/StyleManager.swift
@@ -47,6 +47,13 @@ internal class StyleManager {
}
}
+ /// True if any referenced style requires KMZ packaging.
+ var requiresKMZ: Bool {
+ referencedStyleIds.contains { id in
+ styles[id]?.requiresKMZ == true
+ }
+ }
+
// MARK: - Style Management
/// Get a style by its ID.
diff --git a/Sources/ForeFlightKML/Resources/1x1.png b/Sources/ForeFlightKML/Resources/1x1.png
new file mode 100644
index 0000000..1914264
Binary files /dev/null and b/Sources/ForeFlightKML/Resources/1x1.png differ
diff --git a/Sources/ForeFlightKML/Styles/Geometry/PathStyle.swift b/Sources/ForeFlightKML/Styles/Geometry/PathStyle.swift
index e937970..3251dc8 100644
--- a/Sources/ForeFlightKML/Styles/Geometry/PathStyle.swift
+++ b/Sources/ForeFlightKML/Styles/Geometry/PathStyle.swift
@@ -4,7 +4,7 @@ import Foundation
///
/// Path styles define how lines are drawn. They apply to:
/// - Line and LineString geometries
-/// - LineCircle and LineSegment geometries
+/// - LineCircle and LineSector geometries
///
public struct PathStyle: KMLStyle {
public let stroke: LineStyle
diff --git a/Sources/ForeFlightKML/Styles/Geometry/PointStyle.swift b/Sources/ForeFlightKML/Styles/Geometry/PointStyle.swift
index eb969e2..21b79b0 100644
--- a/Sources/ForeFlightKML/Styles/Geometry/PointStyle.swift
+++ b/Sources/ForeFlightKML/Styles/Geometry/PointStyle.swift
@@ -25,6 +25,10 @@ public struct PointStyle: KMLStyle {
return styleId
}
+ public var requiresKMZ: Bool {
+ icon.requiresKMZ
+ }
+
public func kmlString() -> String {
var components: [String] = []
components.append(")
func id() -> String
func kmlString() -> String
+ // Whether this style requires KMZ packaging (e.g. local icon assets).
+ var requiresKMZ: Bool { get }
+}
+
+public extension KMLStyle {
+ var requiresKMZ: Bool { false }
}
/// Represents a style *sub-element* like `` or ``.
diff --git a/Tests/ForeFlightKMLTests/ForeFlightKML+KMZTests.swift b/Tests/ForeFlightKMLTests/ForeFlightKML+KMZTests.swift
new file mode 100644
index 0000000..15ace04
--- /dev/null
+++ b/Tests/ForeFlightKMLTests/ForeFlightKML+KMZTests.swift
@@ -0,0 +1,78 @@
+import XCTest
+@testable import ForeFlightKML
+import GeodesySpherical
+import ZIPFoundation
+
+final class KMZPackagingTests: XCTestCase {
+
+ func test_buildKMZ_alwaysContainsDocKML() throws {
+ let builder = ForeFlightKMLBuilder()
+ builder.addPoint(
+ name: "Normal",
+ coordinate: Coordinate(latitude: 51.0, longitude: -1.0),
+ altitude: 0,
+ style: PointStyle(icon: .custom(type: .square, color: .white, scale: 1.0))
+ )
+
+ let kmz = try builder.buildKMZ()
+ let archive = try makeArchive(from: kmz!)
+
+ XCTAssertNotNil(archive["doc.kml"], "KMZ must contain doc.kml at root")
+ }
+
+ func test_buildKMZ_doesNotInclude1x1png_whenNotRequired() throws {
+ let builder = ForeFlightKMLBuilder()
+ builder.addPoint(
+ name: "Normal",
+ coordinate: Coordinate(latitude: 51.0, longitude: -1.0),
+ altitude: 0,
+ style: PointStyle(icon: .custom(type: .square, color: .white, scale: 1.0))
+ )
+
+ XCTAssertFalse(builder.requiresKMZ)
+
+ let kmz = try builder.buildKMZ()
+ let archive = try makeArchive(from: kmz!)
+
+ XCTAssertNil(archive["1x1.png"], "KMZ should not include 1x1.png unless required")
+ }
+
+ func test_buildKMZ_includes1x1png_whenRequired() throws {
+ let builder = ForeFlightKMLBuilder()
+ builder.addLabel("Badge", coordinate: Coordinate(latitude: 51.0, longitude: -1.0), color: .warning)
+ XCTAssertTrue(builder.requiresKMZ)
+
+ let kmz = try builder.buildKMZ()
+ let archive = try makeArchive(from: kmz!)
+
+ XCTAssertNotNil(archive["1x1.png"], "KMZ must include 1x1.png when label badges are used")
+ }
+
+ // MARK: - Helpers
+
+ private func makeArchive(from kmzData: Data) throws -> Archive {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathExtension("kmz")
+
+ try kmzData.write(to: url)
+ return try Archive(url: url, accessMode: .read)
+ }
+
+ func test_builderAddLabel() {
+ let builder = ForeFlightKMLBuilder()
+
+ builder.addLabel("Label Warning", coordinate: .init(latitude: 51.2345, longitude: -1.2345), color: .warning)
+ do {
+ let kmz = try builder.buildKMZ()
+
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("test.kmz")
+
+ try kmz!.write(to: url)
+ print("KMZ written to:", url)
+ } catch {
+ XCTFail("Unable to build KMZ: \(error)")
+ }
+ }
+}
diff --git a/Tests/ForeFlightKMLTests/GeometryTests/LineSegmentTests.swift b/Tests/ForeFlightKMLTests/GeometryTests/LineSectorTests.swift
similarity index 73%
rename from Tests/ForeFlightKMLTests/GeometryTests/LineSegmentTests.swift
rename to Tests/ForeFlightKMLTests/GeometryTests/LineSectorTests.swift
index 45d8239..407b5af 100644
--- a/Tests/ForeFlightKMLTests/GeometryTests/LineSegmentTests.swift
+++ b/Tests/ForeFlightKMLTests/GeometryTests/LineSectorTests.swift
@@ -3,34 +3,34 @@ import XCTest
@testable import ForeFlightKML
-final class LineSegmentTests: XCTestCase {
- func testBuildSegment() throws {
+final class LineSectorTests: XCTestCase {
+ func testBuildSector() throws {
let builder = ForeFlightKMLBuilder(documentName: "My Test KML")
let center = Coordinate(latitude: 38.8700980, longitude: -77.055967)
- let segmentElement = LineSegment.init(
+ let sectorElement = LineSector.init(
center: center, radius: 200, startAngle: 120.0, endAngle: 150.0)
- builder.addPlacemark(Placemark(name: "Pizza Wedge", geometry: segmentElement))
+ builder.addPlacemark(Placemark(name: "Pizza Wedge", geometry: sectorElement))
let kml = builder.kmlString()
XCTAssertTrue(kml.contains(""), "Expected KML to contain the LineSTring name")
}
- func testBuildSegmentAltitude() throws {
+ func testBuildSectorAltitude() throws {
let builder = ForeFlightKMLBuilder(documentName: "My Test KML")
let center = Coordinate(latitude: 38.8700980, longitude: -77.055967)
- let segmentElementRed = LineSegment.init(
+ let sectorElementRed = LineSector.init(
center: center, radius: 200, startAngle: 90.0, endAngle: 150.0, altitude: 2500)
- let segmentElementBlue = LineSegment.init(
+ let sectorElementBlue = LineSector.init(
center: center, radius: 200, startAngle: 330.0, endAngle: 30.0, altitude: 3000)
builder.addPlacemark(
- Placemark(name: "Red Pizza Wedge", geometry: segmentElementRed))
+ Placemark(name: "Red Pizza Wedge", geometry: sectorElementRed))
builder.addPlacemark(
Placemark(
- name: "Blue Pizza Wedge", geometry: segmentElementBlue))
+ name: "Blue Pizza Wedge", geometry: sectorElementBlue))
let kml = builder.kmlString()
diff --git a/Tests/ForeFlightKMLTests/GeometryTests/PolygonAnnularSegmentTests.swift b/Tests/ForeFlightKMLTests/GeometryTests/PolygonAnnularSectorTests.swift
similarity index 87%
rename from Tests/ForeFlightKMLTests/GeometryTests/PolygonAnnularSegmentTests.swift
rename to Tests/ForeFlightKMLTests/GeometryTests/PolygonAnnularSectorTests.swift
index c7ea25d..6564783 100644
--- a/Tests/ForeFlightKMLTests/GeometryTests/PolygonAnnularSegmentTests.swift
+++ b/Tests/ForeFlightKMLTests/GeometryTests/PolygonAnnularSectorTests.swift
@@ -3,7 +3,7 @@ import XCTest
@testable import ForeFlightKML
-final class PolygonAnnularSegmentTests: XCTestCase {
+final class PolygonAnnularSectorTests: XCTestCase {
struct Quadrant {
let name: String
@@ -12,11 +12,11 @@ final class PolygonAnnularSegmentTests: XCTestCase {
let color: KMLColor
}
- func testBasicAnnularSegment() throws {
+ func testBasicAnnularSector() throws {
let builder = ForeFlightKMLBuilder(documentName: "Annular Test")
let center = Coordinate(latitude: 38.8700980, longitude: -77.055967)
- let segment = PolygonAnnularSegment(
+ let sector = PolygonAnnularSector(
center: center,
innerRadius: 1000, // 1km inner radius
outerRadius: 2000, // 2km outer radius
@@ -24,7 +24,7 @@ final class PolygonAnnularSegmentTests: XCTestCase {
endAngle: 90 // East
)
- let placemark = Placemark(name: "Northeast Quadrant", geometry: segment)
+ let placemark = Placemark(name: "Northeast Quadrant", geometry: sector)
builder.addPlacemark(placemark)
let kml = builder.kmlString()
@@ -34,7 +34,7 @@ final class PolygonAnnularSegmentTests: XCTestCase {
XCTAssertTrue(kml.contains(""))
}
- func testFourQuadrantAnnularSegments() throws {
+ func testFourQuadrantAnnularSectors() throws {
let builder = ForeFlightKMLBuilder(documentName: "Ring Quadrants")
let center = Coordinate(latitude: 51.750188, longitude: -1.581566)
@@ -49,7 +49,7 @@ final class PolygonAnnularSegmentTests: XCTestCase {
]
for quadrant in quadrants {
- builder.addPolygonAnnularSegment(
+ builder.addPolygonAnnularSector(
name: quadrant.name,
center: center,
innerRadius: innerRadius,
@@ -74,11 +74,11 @@ final class PolygonAnnularSegmentTests: XCTestCase {
XCTAssertEqual(placemarkCount, 4)
}
- func testAnnularSegmentWithStyle() throws {
+ func testAnnularSectorWithStyle() throws {
let builder = ForeFlightKMLBuilder(documentName: "Styled Ring")
let center = Coordinate(latitude: 38.8700980, longitude: -77.055967)
- builder.addPolygonAnnularSegment(
+ builder.addPolygonAnnularSector(
name: "Warning Sector",
center: center,
innerRadius: 1500,
@@ -99,12 +99,12 @@ final class PolygonAnnularSegmentTests: XCTestCase {
XCTAssertTrue(kml.contains(""))
}
- func testAnnularSegmentCrossingNorth() throws {
- // Test a segment that crosses 0° (wraps around North)
+ func testAnnularSectorCrossingNorth() throws {
+ // Test a sector that crosses 0° (wraps around North)
let builder = ForeFlightKMLBuilder(documentName: "Crossing North")
let center = Coordinate(latitude: 38.8700980, longitude: -77.055967)
- let segment = PolygonAnnularSegment(
+ let sector = PolygonAnnularSector(
center: center,
innerRadius: 1000,
outerRadius: 2000,
@@ -112,19 +112,19 @@ final class PolygonAnnularSegmentTests: XCTestCase {
endAngle: 30 // 30° after North
)
- builder.addPlacemark(Placemark(name: "North Crossing", geometry: segment))
+ builder.addPlacemark(Placemark(name: "North Crossing", geometry: sector))
let kml = builder.kmlString()
XCTAssertTrue(kml.contains("North Crossing"))
XCTAssertTrue(kml.contains(""))
}
- func testNarrowAnnularSegment() throws {
- // Test a thin ring segment (5° arc)
- let builder = ForeFlightKMLBuilder(documentName: "Narrow Segment")
+ func testNarrowAnnularSector() throws {
+ // Test a thin ring sector (5° arc)
+ let builder = ForeFlightKMLBuilder(documentName: "Narrow Sector")
let center = Coordinate(latitude: 38.8700980, longitude: -77.055967)
- let segment = PolygonAnnularSegment(
+ let sector = PolygonAnnularSector(
center: center,
innerRadius: 1000,
outerRadius: 2000,
@@ -133,14 +133,14 @@ final class PolygonAnnularSegmentTests: XCTestCase {
numberOfPoints: 16
)
- builder.addPlacemark(Placemark(name: "Narrow", geometry: segment))
+ builder.addPlacemark(Placemark(name: "Narrow", geometry: sector))
let kml = builder.kmlString()
XCTAssertTrue(kml.contains("Narrow"))
}
func testGenerateCompleteDemoKML() throws {
- let builder = ForeFlightKMLBuilder(documentName: "Annular Segments Demo")
+ let builder = ForeFlightKMLBuilder(documentName: "Annular Sector Demo")
let center = Coordinate(latitude: 38.8700980, longitude: -77.055967)
let innerRadius: Double = 1000
@@ -154,7 +154,7 @@ final class PolygonAnnularSegmentTests: XCTestCase {
]
for quadrant in quadrants {
- builder.addPolygonAnnularSegment(
+ builder.addPolygonAnnularSector(
name: quadrant.name,
center: center,
innerRadius: innerRadius,
@@ -170,7 +170,7 @@ final class PolygonAnnularSegmentTests: XCTestCase {
)
}
- let kml = builder.build()
+ let kml = builder.kmlString()
XCTAssertTrue(kml.contains(""))
let placemarkCount = kml.components(separatedBy: "").count - 1
diff --git a/Tests/ForeFlightKMLTests/GeometryTests/PolygonSegmentTests.swift b/Tests/ForeFlightKMLTests/GeometryTests/PolygonSegmentTests.swift
index 07504f4..e525f88 100644
--- a/Tests/ForeFlightKMLTests/GeometryTests/PolygonSegmentTests.swift
+++ b/Tests/ForeFlightKMLTests/GeometryTests/PolygonSegmentTests.swift
@@ -3,14 +3,14 @@ import XCTest
@testable import ForeFlightKML
-final class PolygonSegmentsTests: XCTestCase {
- func testBuildBasicSegments() throws {
+final class PolygonSectorTests: XCTestCase {
+ func testBuildBasicSectors() throws {
let builder = ForeFlightKMLBuilder(documentName: "My Test KML")
let center = Coordinate(latitude: 38.8700980, longitude: -77.055967)
- let circle = PolygonSegment(center: center, radius: 500, startAngle: 45, endAngle: 135)
- let pm = Placemark(name: "Nice Segment", geometry: circle)
+ let circle = PolygonSector(center: center, radius: 500, startAngle: 45, endAngle: 135)
+ let pm = Placemark(name: "Nice Sector", geometry: circle)
builder.addPlacemark(pm)
let kml = builder.kmlString()
diff --git a/Tests/ForeFlightKMLTests/ModelTests/AltitudeTests.swift b/Tests/ForeFlightKMLTests/ModelTests/AltitudeTests.swift
index 077d225..845a476 100644
--- a/Tests/ForeFlightKMLTests/ModelTests/AltitudeTests.swift
+++ b/Tests/ForeFlightKMLTests/ModelTests/AltitudeTests.swift
@@ -22,9 +22,7 @@ final class AltitudeSupportTests: XCTestCase {
func testPointWithAltitudeNoMode() {
let point = Point(Coordinate(latitude: 1, longitude: -1), altitude: 0)
-
let kml = point.kmlString()
- print(kml)
XCTAssertTrue(kml.contains("-1.0,1.0,0.0"))
XCTAssertFalse(kml.contains(""))
}
diff --git a/Tests/ForeFlightKMLTests/StyleTests/Geometry/PointStyleTests.swift b/Tests/ForeFlightKMLTests/StyleTests/Geometry/PointStyleTests.swift
index cff7162..7683582 100644
--- a/Tests/ForeFlightKMLTests/StyleTests/Geometry/PointStyleTests.swift
+++ b/Tests/ForeFlightKMLTests/StyleTests/Geometry/PointStyleTests.swift
@@ -1,5 +1,5 @@
import XCTest
-
+import GeodesySpherical
@testable import ForeFlightKML
final class PointStylesTests: XCTestCase {
@@ -46,4 +46,42 @@ final class PointStylesTests: XCTestCase {
XCTAssertNotEqual(style1.id(), style2.id())
}
+
+ func test_pointStyle_requiresKMZ_followsIconStyle() {
+ let s1 = PointStyle(icon: .transparentLocalPng(tint: .white))
+ XCTAssertTrue(s1.requiresKMZ)
+
+ let s2 = PointStyle(icon: .custom(type: .square, color: .white))
+ XCTAssertFalse(s2.requiresKMZ)
+ }
+
+ func test_labelBadge_requiresKMZ_true() {
+ let s = PointStyle.labelBadge(color: .warning)
+ XCTAssertTrue(s.requiresKMZ)
+ }
+
+ func test_addLabel_emitsTransparentHref_andIconColor() throws {
+ let builder = ForeFlightKMLBuilder(documentName: "Test")
+ builder.addLabel("Badge", coordinate: Coordinate(latitude: 51.0, longitude: -1.0), color: .warning)
+ let kml = builder.kmlString()
+
+ XCTAssertTrue(kml.contains("1x1.png"))
+ XCTAssertTrue(kml.contains(""))
+ XCTAssertTrue(kml.contains(""), "Label badge must emit IconStyle color (drives ForeFlight badge)")
+
+ do {
+ _ = try builder.buildKMZ()
+ } catch {
+ XCTFail("buildKMZ() threw: \(error)")
+ return
+ }
+
+ }
+
+ func test_labelBadge_doesNotEmitLabelStyle() {
+ let style = PointStyle.labelBadge(color: .warning, id: "fixed")
+ let xml = style.kmlString()
+
+ XCTAssertFalse(xml.contains(""), "labelBadge should omit LabelStyle (ForeFlight ignores it)")
+ }
}
diff --git a/Tests/ForeFlightKMLTests/StyleTests/IconStyle+HiddenTests.swift b/Tests/ForeFlightKMLTests/StyleTests/IconStyle+HiddenTests.swift
new file mode 100644
index 0000000..0d8e27c
--- /dev/null
+++ b/Tests/ForeFlightKMLTests/StyleTests/IconStyle+HiddenTests.swift
@@ -0,0 +1,44 @@
+import XCTest
+@testable import ForeFlightKML
+import GeodesySpherical
+
+final class LabelOnlyTests: XCTestCase {
+
+ func test_iconStyleTransparentLocalPng_emitsLocalHref() {
+ let kml = IconStyle.transparentLocalPng().kmlString()
+
+ XCTAssertTrue(kml.contains(""))
+ XCTAssertTrue(kml.contains(""))
+ XCTAssertTrue(
+ kml.contains("1x1.png"),
+ "Label-only icon must reference bundled transparent PNG"
+ )
+ XCTAssertTrue(kml.contains(""))
+ }
+
+ func test_pointStyleLabelOnly_emitsStyle() {
+ let style = PointStyle.labelBadge(color: .white)
+ let kml = style.kmlString()
+
+ XCTAssertTrue(kml.contains(""))
+ }
+
+ func test_builderAddLabel_emitsPlacemarkName() throws {
+ let builder = ForeFlightKMLBuilder()
+
+ builder.addLabel("Label Warning", coordinate: .init(latitude: 51.2345, longitude: -1.2345), color: .warning)
+ let kml = builder.kmlString()
+
+ XCTAssertTrue(kml.contains(""))
+ XCTAssertTrue(kml.contains("