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` Image -## 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("