diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml new file mode 100644 index 0000000..9bd1d56 --- /dev/null +++ b/.github/workflows/swiftlint.yml @@ -0,0 +1,21 @@ +name: SwiftLint + +on: + pull_request: + paths: + - '.github/workflows/swiftlint.yml' + - '.swiftlint.yml' + - '**/*.swift' + workflow_dispatch: + +jobs: + SwiftLint: + 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 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..dde5b9b --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,8 @@ +excluded: + - .build + - Tests/ForeFlightKMLTests/UserMapShapesSampleIndividualTests.swift + - Tests/ForeFlightKMLTests/UserMapShapesSampleFullTest.swift + - Example/ForeFlightKMLDemo/ +identifier_name: + min_length: 1 + max_length: 40 diff --git a/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift b/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift index 84e369d..9cba4d1 100644 --- a/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift +++ b/Example/ForeFlightKMLDemo/KML/KMLGenerator.swift @@ -6,13 +6,23 @@ import ForeFlightKML enum KMLGenerator { static func generateCircleKML(center: CLLocationCoordinate2D, radiusMeters: Double) -> String { let builder = ForeFlightKMLBuilder(documentName: "Foreflight KML Demo") - + let centerCoordinate = Coordinate(latitude: center.latitude, longitude: center.longitude) - - builder.addLineCircle(name: "Circle", center: centerCoordinate, radiusMeters: radiusMeters, numberOfPoints: 100, style: PathStyle(color: .black)) - builder.addPolygonCircle(name: "Filled Circle" ,center: centerCoordinate, radiusMeters: radiusMeters * 2, style: PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3))) - + builder.addLineCircle( + name: "Circle", + center: centerCoordinate, + radiusMeters: radiusMeters, + numberOfPoints: 100, + style: PathStyle(color: .black) + ) + + builder.addPolygonCircle( + name: "Filled Circle", + center: centerCoordinate, + radiusMeters: radiusMeters * 2, + style: PolygonStyle(outlineColor: .black, fillColor: .warning.withAlpha(0.3))) + return builder.build() } diff --git a/Example/ForeFlightKMLDemo/Map/MapViewRepresentable.swift b/Example/ForeFlightKMLDemo/Map/MapViewRepresentable.swift index f61dd35..d54fe99 100644 --- a/Example/ForeFlightKMLDemo/Map/MapViewRepresentable.swift +++ b/Example/ForeFlightKMLDemo/Map/MapViewRepresentable.swift @@ -13,7 +13,9 @@ struct MapViewRepresentable: UIViewRepresentable { map.delegate = context.coordinator map.showsUserLocation = true map.pointOfInterestFilter = .excludingAll - map.setRegion(MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.750188, longitude: -1.581566), latitudinalMeters: 2000, longitudinalMeters: 2000),animated: false) + map.setRegion(MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 51.750188, longitude: -1.581566), latitudinalMeters: 2000, longitudinalMeters: 2000), + animated: false) let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) tap.numberOfTapsRequired = 1 diff --git a/Example/ForeFlightKMLDemo/Views/ContentView.swift b/Example/ForeFlightKMLDemo/Views/ContentView.swift index 9b03679..a18b871 100644 --- a/Example/ForeFlightKMLDemo/Views/ContentView.swift +++ b/Example/ForeFlightKMLDemo/Views/ContentView.swift @@ -50,10 +50,10 @@ struct ContentView: View { lastTapCoordinate = coord let kml = KMLGenerator.generateCircleKML(center: coord, radiusMeters: defaultRadiusMeters) - + let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" - + let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(dateFormatter.string(from: Date())).kml") do { try kml.data(using: .utf8)?.write(to: tmpURL) diff --git a/Package.swift b/Package.swift index ca57dcf..14f7748 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,6 @@ let package = Package( .testTarget( name: "ForeFlightKMLTests", dependencies: ["ForeFlightKML"] - ), + ) ] ) diff --git a/Sources/ForeFlightKML/CoreElements/LinearRing.swift b/Sources/ForeFlightKML/CoreElements/LinearRing.swift index f200800..a799b4e 100644 --- a/Sources/ForeFlightKML/CoreElements/LinearRing.swift +++ b/Sources/ForeFlightKML/CoreElements/LinearRing.swift @@ -1,6 +1,8 @@ import GeodesySpherical -/// Defines a closed line string, typically the outer boundary of a Polygon. Optionally, a LinearRing can also be used as the inner boundary of a Polygon to create holes in the Polygon. A Polygon can contain multiple elements used as inner boundaries. +/// Defines a closed line string, typically the outer boundary of a Polygon. +/// Optionally, a LinearRing can also be used as the inner boundary of a Polygon to create holes in the Polygon. +/// A Polygon can contain multiple elements used as inner boundaries. /// Note: LinearRing does NOT support altitudeMode - that is specified at the Polygon level. /// However, individual coordinates can still have altitude values. public struct LinearRing: CoordinateContainer { @@ -9,7 +11,7 @@ public struct LinearRing: CoordinateContainer { public var altitude: Double? /// LinearRing doesn't define altitude mode (handled by parent Polygon) public var altitudeMode: AltitudeMode? { nil } - public var tessellate: Bool? = nil + public var tessellate: Bool? /// Create a new linear ring. /// - Parameters: @@ -21,8 +23,7 @@ public struct LinearRing: CoordinateContainer { // Auto-close the ring if needed if let first = coordinates.first, let last = coordinates.last, - first.latitude != last.latitude || first.longitude != last.longitude - { + first.latitude != last.latitude || first.longitude != last.longitude { self.coordinates = coordinates + [first] } else { self.coordinates = coordinates diff --git a/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift b/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift index ac76c87..715a8c6 100644 --- a/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift +++ b/Sources/ForeFlightKML/ForeFlightKML+Convenience.swift @@ -245,4 +245,50 @@ extension ForeFlightKMLBuilder { let placemark = Placemark(name: name, geometry: segment, style: style) return addPlacemark(placemark) } + + /// Add a filled annular (ring) segment polygon. + /// This creates a segment between two radii, excluding the inner circle area. + /// - Parameters: + /// - name: Display name in ForeFlight (optional) + /// - center: Center point of the segment + /// - innerRadius: Inner radius in meters (the "hole" size) + /// - outerRadius: Outer radius in meters + /// - startAngle: Starting angle in degrees (0° = North, clockwise) + /// - endAngle: Ending angle in degrees (0° = North, clockwise) + /// - numberOfPoints: Number of points for each arc (default: 64) + /// - altitude: Altitude in meters (optional) + /// - tessellate: Whether to follow ground contours (default: false) + /// - style: Polygon style defining outline and optional fill (optional) + /// - Returns: Self for method chaining + @discardableResult + public func addPolygonAnnularSegment( + name: String? = nil, + center: Coordinate, + innerRadius: Double, + outerRadius: Double, + startAngle: Double, + endAngle: Double, + numberOfPoints: Int = 64, + altitude: Double? = nil, + tessellate: Bool = false, + style: PolygonStyle? = nil + ) -> Self { + precondition(innerRadius > 0, "Inner radius must be positive") + 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( + center: center, + innerRadius: innerRadius, + outerRadius: outerRadius, + startAngle: startAngle, + endAngle: endAngle, + numberOfPoints: numberOfPoints, + altitude: altitude, + tessellate: tessellate + ) + + let placemark = Placemark(name: name, geometry: segment, style: style) + return addPlacemark(placemark) + } } diff --git a/Sources/ForeFlightKML/ForeFlightKML.swift b/Sources/ForeFlightKML/ForeFlightKML.swift index bb4800d..5df0777 100644 --- a/Sources/ForeFlightKML/ForeFlightKML.swift +++ b/Sources/ForeFlightKML/ForeFlightKML.swift @@ -16,7 +16,7 @@ public final class ForeFlightKMLBuilder { "xmlns": "http://www.opengis.net/kml/2.2", "xmlns:gx": "http://www.google.com/kml/ext/2.2", "xmlns:kml": "http://www.opengis.net/kml/2.2", - "xmlns:atom": "http://www.w3.org/2005/Atom", + "xmlns:atom": "http://www.w3.org/2005/Atom" ] /// Create a new builder. @@ -80,7 +80,7 @@ public final class ForeFlightKMLBuilder { } /// Produce the KML document as `Data` using the given text encoding. - /// - Parameter encoding: The `String.Encoding` to use when converting the KML string into data. Defaults to `.utf8`. + /// - Parameter encoding: The `String.Encoding` to use when converting the KML string into data. /// - Returns: `Data` containing the encoded KML, or an empty `Data` if encoding fails. public func kmlData(encoding: String.Encoding = .utf8) -> Data { return kmlString().data(using: encoding) ?? Data() diff --git a/Sources/ForeFlightKML/Geometry/PolygonAnnularSegment.swift b/Sources/ForeFlightKML/Geometry/PolygonAnnularSegment.swift new file mode 100644 index 0000000..8de364a --- /dev/null +++ b/Sources/ForeFlightKML/Geometry/PolygonAnnularSegment.swift @@ -0,0 +1,34 @@ +import GeodesySpherical + +public struct PolygonAnnularSegment: KMLElement, AltitudeSupport { + let polygon: Polygon + + public var altitudeMode: AltitudeMode? { polygon.altitudeMode } + public init( + center: Coordinate, + innerRadius: Double, + outerRadius: Double, + startAngle: Double, + endAngle: Double, + numberOfPoints: Int = 100, + altitude: Double? = nil, + altitudeMode: AltitudeMode? = nil, + tessellate: Bool? = nil + ) { + let coordinates = SegmentGeometry.generateAnnularSegmentPoints( + center: center, + innerRadius: innerRadius, + outerRadius: outerRadius, + startAngle: startAngle, + endAngle: endAngle, + numberOfPoints: numberOfPoints + ) + + let ring = LinearRing(coordinates: coordinates, altitude: altitude) + self.polygon = Polygon(outer: ring, altitudeMode: altitudeMode, tessellate: tessellate) + } + + public func kmlString() -> String { + return polygon.kmlString() + } +} diff --git a/Sources/ForeFlightKML/Geometry/Shared/CircleGeometry.swift b/Sources/ForeFlightKML/Geometry/Shared/CircleGeometry.swift index 02c7ed8..e923ec3 100644 --- a/Sources/ForeFlightKML/Geometry/Shared/CircleGeometry.swift +++ b/Sources/ForeFlightKML/Geometry/Shared/CircleGeometry.swift @@ -16,8 +16,7 @@ internal enum CircleGeometry { } if let first = circlePoints.first, let last = circlePoints.last, - first.longitude != last.longitude || first.latitude != last.latitude - { + first.longitude != last.longitude || first.latitude != last.latitude { circlePoints.append(first) } diff --git a/Sources/ForeFlightKML/Geometry/Shared/SegmentGeometry.swift b/Sources/ForeFlightKML/Geometry/Shared/SegmentGeometry.swift index 4e1da5b..9773149 100644 --- a/Sources/ForeFlightKML/Geometry/Shared/SegmentGeometry.swift +++ b/Sources/ForeFlightKML/Geometry/Shared/SegmentGeometry.swift @@ -31,4 +31,49 @@ internal enum SegmentGeometry { segmentPoints.append(center) return segmentPoints } + + // swiftlint:disable:next function_parameter_count + static func generateAnnularSegmentPoints( + center: Coordinate, + innerRadius: Double, + outerRadius: Double, + startAngle: Double, + endAngle: Double, + numberOfPoints: Int + ) -> [Coordinate] { + precondition(innerRadius > 0, "Inner radius must be positive") + precondition(outerRadius > innerRadius, "Outer radius must be greater than inner radius") + + var points: [Coordinate] = [] + + let start = startAngle.truncatingRemainder(dividingBy: 360) + let end = endAngle.truncatingRemainder(dividingBy: 360) + + let angleSpan: Double + if end >= start { + angleSpan = end - start + } else { + angleSpan = (360 - start) + end + } + + for i in 0...numberOfPoints { + let fraction = Double(i) / Double(numberOfPoints) + let currentAngle = start + fraction * angleSpan + let point = center.destination(with: outerRadius, bearing: currentAngle) + points.append(point) + } + + for i in 0...numberOfPoints { + let fraction = Double(i) / Double(numberOfPoints) + let currentAngle = end - fraction * angleSpan + let point = center.destination(with: innerRadius, bearing: currentAngle) + points.append(point) + } + + if let first = points.first { + points.append(first) + } + + return points + } } diff --git a/Sources/ForeFlightKML/Styles/Colors.swift b/Sources/ForeFlightKML/Styles/Colors.swift index 0a338ed..5ef3882 100644 --- a/Sources/ForeFlightKML/Styles/Colors.swift +++ b/Sources/ForeFlightKML/Styles/Colors.swift @@ -55,8 +55,7 @@ public struct KMLColor: Equatable, CustomStringConvertible { /// - alpha: Alpha component (0.0-1.0, will be clamped). Default is 1.0 (opaque) /// - Returns: A new KMLColor public static func fromRGB(red: Double, green: Double, blue: Double, alpha: Double = 1.0) - -> KMLColor - { + -> KMLColor { func clampAndScale(_ value: Double) -> Int { Int((max(0.0, min(1.0, value)) * 255.0).rounded()) } @@ -69,6 +68,7 @@ public struct KMLColor: Equatable, CustomStringConvertible { ) } + // swiftlint:disable function_body_length /// Create a color from a hex string /// - Parameter hex: Hex string in format "#RGB", "#RRGGBB", "#AARRGGBB", "RGB", "RRGGBB", or "AARRGGBB" /// - Returns: Result containing KMLColor @@ -147,6 +147,7 @@ public struct KMLColor: Equatable, CustomStringConvertible { return .black } } + // swiftlint:enable function_body_length /// Create a color from KML hex format (aabbggrr) /// - Parameter kmlHex: Hex string in KML format "aabbggrr" @@ -220,7 +221,7 @@ public struct KMLColor: Equatable, CustomStringConvertible { switch self { case .invalidHexFormat(let hex): return - "Invalid hex color format: '\(hex)'. Expected formats: RGB, RRGGBB, AARRGGBB (with optional # prefix)" + "Invalid hex color format: '\(hex)'. Expected formats: RGB, RRGGBB, AARRGGBB (optional # prefix)" case .invalidKMLHexFormat(let hex): return "Invalid KML hex color format: '\(hex)'. Expected format: aabbggrr (8 hex characters)" diff --git a/Sources/ForeFlightKML/Styles/IconStyle.swift b/Sources/ForeFlightKML/Styles/IconStyle.swift index 1818a4e..629b84f 100644 --- a/Sources/ForeFlightKML/Styles/IconStyle.swift +++ b/Sources/ForeFlightKML/Styles/IconStyle.swift @@ -51,8 +51,7 @@ public struct IconStyle: KMLSubStyle { /// - scale: Size multiplier for the icon (default: 1.3) /// - Returns: A new IconStyle configured for custom colored shapes public static func custom(type: CustomIconType, color: KMLColor? = nil, scale: Double? = 1.3) - -> IconStyle - { + -> IconStyle { let baseUrl = "http://maps.google.com/mapfiles/kml/" let href = "\(baseUrl)shapes/\(type.href).png" return IconStyle(href: href, color: color, scale: scale) @@ -87,9 +86,9 @@ public struct IconStyle: KMLSubStyle { /// These are the "paddle" style icons that only support fixed colors. public enum PredefinedIconType: String { case pushpin - case circle = "circle" - case square = "square" - case diamond = "diamond" + case circle + case square + case diamond } /// Icon shapes that support custom colors. diff --git a/Sources/ForeFlightKML/Styles/LabelStyle.swift b/Sources/ForeFlightKML/Styles/LabelStyle.swift index 5e4c21d..e8213da 100644 --- a/Sources/ForeFlightKML/Styles/LabelStyle.swift +++ b/Sources/ForeFlightKML/Styles/LabelStyle.swift @@ -18,7 +18,7 @@ public struct LabelStyle: KMLSubStyle { let lines = [ "", "\(color.kmlHexString)", - "", + "" ] return lines.joined(separator: "\n") } diff --git a/Sources/ForeFlightKML/Styles/LineStyle.swift b/Sources/ForeFlightKML/Styles/LineStyle.swift index e14b490..b9ca7c7 100644 --- a/Sources/ForeFlightKML/Styles/LineStyle.swift +++ b/Sources/ForeFlightKML/Styles/LineStyle.swift @@ -24,7 +24,7 @@ public struct LineStyle: KMLSubStyle { "", "\(color.kmlHexString)", width.map { "\($0)" }, - "", + "" ] return lines.compactMap { $0 }.joined(separator: "\n") } diff --git a/Sources/ForeFlightKML/Styles/PolyStyle.swift b/Sources/ForeFlightKML/Styles/PolyStyle.swift index 626cc52..e44f1e1 100644 --- a/Sources/ForeFlightKML/Styles/PolyStyle.swift +++ b/Sources/ForeFlightKML/Styles/PolyStyle.swift @@ -25,7 +25,7 @@ public struct PolyStyle: KMLSubStyle { "", "\(color.kmlHexString)", fill.map { "\($0 ? 1 : 0)" }, - "", + "" ] return lines.compactMap { $0 }.joined(separator: "\n") } diff --git a/Sources/ForeFlightKML/Utils/Geodesy+KML.swift b/Sources/ForeFlightKML/Utils/Geodesy+KML.swift index 054fe2a..de7d19e 100644 --- a/Sources/ForeFlightKML/Utils/Geodesy+KML.swift +++ b/Sources/ForeFlightKML/Utils/Geodesy+KML.swift @@ -1,6 +1,9 @@ import GeodesySpherical -// KML uses 3D geographic coordinates: longitude, latitude and altitude, in that order. The longitude and latitude components are in decimal degrees as defined by the World Geodetic System of 1984 (WGS-84). The vertical component (altitude) is measured in meters from the WGS84 EGM96 Geoid vertical datum. +// KML uses 3D geographic coordinates: longitude, latitude and altitude, in that order. +// The longitude and latitude components are in decimal degrees +// as defined by the World Geodetic System of 1984 (WGS-84). +// The vertical component (altitude) is measured in meters from the WGS84 EGM96 Geoid vertical datum. extension GeodesySpherical.Coordinate { public func kmlString() -> String { diff --git a/Tests/ForeFlightKMLTests/CoreElementTests/LineStringTests.swift b/Tests/ForeFlightKMLTests/CoreElementTests/LineStringTests.swift index e7a47a5..bc688b0 100644 --- a/Tests/ForeFlightKMLTests/CoreElementTests/LineStringTests.swift +++ b/Tests/ForeFlightKMLTests/CoreElementTests/LineStringTests.swift @@ -7,7 +7,7 @@ final class LineStringTests: XCTestCase { func testKmlWithAltitudeAndTessellate() { let coords = [ Coordinate(latitude: 2, longitude: 3), - Coordinate(latitude: 4, longitude: 5), + Coordinate(latitude: 4, longitude: 5) ] let line = LineString( coordinates: coords, altitude: 100.0, altitudeMode: .absolute, tessellate: true) diff --git a/Tests/ForeFlightKMLTests/CoreElementTests/LinearRingTests.swift b/Tests/ForeFlightKMLTests/CoreElementTests/LinearRingTests.swift index 6e5b868..4eba622 100644 --- a/Tests/ForeFlightKMLTests/CoreElementTests/LinearRingTests.swift +++ b/Tests/ForeFlightKMLTests/CoreElementTests/LinearRingTests.swift @@ -11,7 +11,7 @@ final class LinearRingTests: XCTestCase { [-98.96485321080908, 30.49650491542987], [-98.95965359046227, 30.92214152160733], [-99.09548335615463, 31.45369338953584], - [-100.1097399038377, 31.57870338920791], + [-100.1097399038377, 31.57870338920791] ] let coords = points.map { pair in diff --git a/Tests/ForeFlightKMLTests/CoreElementTests/PointTests.swift b/Tests/ForeFlightKMLTests/CoreElementTests/PointTests.swift index 1e0e484..6c727dc 100644 --- a/Tests/ForeFlightKMLTests/CoreElementTests/PointTests.swift +++ b/Tests/ForeFlightKMLTests/CoreElementTests/PointTests.swift @@ -24,7 +24,7 @@ final class PointTests: XCTestCase { try XMLTestHelper.validateStructure( xml, expectedElements: [ - "Point", "gx:drawOrder", "coordinates", + "Point", "gx:drawOrder", "coordinates" ]) let drawOrder = try XMLTestHelper.getTextContent(elementName: "gx:drawOrder", from: xml) @@ -59,7 +59,7 @@ final class PointTests: XCTestCase { try XMLTestHelper.validateStructure( xml, expectedElements: [ - "Point", "drawOrder", "altitudeMode", "coordinates", + "Point", "drawOrder", "altitudeMode", "coordinates" ]) let altitudeMode = try XMLTestHelper.getTextContent(elementName: "altitudeMode", from: xml) diff --git a/Tests/ForeFlightKMLTests/CoreElementTests/PolygonTests.swift b/Tests/ForeFlightKMLTests/CoreElementTests/PolygonTests.swift index 735188f..a9b0e83 100644 --- a/Tests/ForeFlightKMLTests/CoreElementTests/PolygonTests.swift +++ b/Tests/ForeFlightKMLTests/CoreElementTests/PolygonTests.swift @@ -11,7 +11,7 @@ final class PolygonTests: XCTestCase { func testKmlWithOuterOnly() { let outer = makeRing([ Coordinate(latitude: 10, longitude: 20), - Coordinate(latitude: 30, longitude: 40), + Coordinate(latitude: 30, longitude: 40) ]) let polygon = Polygon(outer: outer) let kml = polygon.kmlString() @@ -37,13 +37,13 @@ final class PolygonTests: XCTestCase { let outer = makeRing( [ Coordinate(latitude: 0, longitude: 0), - Coordinate(latitude: 1, longitude: 1), + Coordinate(latitude: 1, longitude: 1) ], altitude: 50.0) let inner = makeRing( [ Coordinate(latitude: 2, longitude: 2), - Coordinate(latitude: 3, longitude: 3), + Coordinate(latitude: 3, longitude: 3) ], altitude: 50.0) let polygon = Polygon( outer: outer, inner: [inner], altitudeMode: .absolute, tessellate: false) @@ -62,12 +62,12 @@ final class PolygonTests: XCTestCase { let outer = makeRing( [ Coordinate(latitude: 0, longitude: 0), - Coordinate(latitude: 1, longitude: 1), + Coordinate(latitude: 1, longitude: 1) ], altitude: nil) let inner = makeRing( [ Coordinate(latitude: 2, longitude: 2), - Coordinate(latitude: 3, longitude: 3), + Coordinate(latitude: 3, longitude: 3) ], altitude: 20.0) let polygon = Polygon( outer: outer, inner: [inner], altitudeMode: .absolute, tessellate: false) @@ -85,12 +85,12 @@ final class PolygonTests: XCTestCase { let outer = makeRing( [ Coordinate(latitude: 0, longitude: 0), - Coordinate(latitude: 1, longitude: 1), + Coordinate(latitude: 1, longitude: 1) ], altitude: nil) let inner = makeRing( [ Coordinate(latitude: 2, longitude: 2), - Coordinate(latitude: 3, longitude: 3), + Coordinate(latitude: 3, longitude: 3) ], altitude: nil) let polygon = Polygon(outer: outer, inner: [inner], tessellate: false) let kml = polygon.kmlString() diff --git a/Tests/ForeFlightKMLTests/ForeFlightKMLTests.swift b/Tests/ForeFlightKMLTests/ForeFlightKMLTests.swift index a7fbffc..0f49cef 100644 --- a/Tests/ForeFlightKMLTests/ForeFlightKMLTests.swift +++ b/Tests/ForeFlightKMLTests/ForeFlightKMLTests.swift @@ -23,7 +23,7 @@ final class ForeFlightKMLTests: XCTestCase { let builder = ForeFlightKMLBuilder(documentName: "My Test KML") let start = Coordinate(latitude: 33.29349602069717, longitude: -97.83722968666947) let end = Coordinate(latitude: 31.29050449094128, longitude: -97.828182763451) - + builder.addLine(name: "Test Line", coordinates: [start, end], style: .init(color: .black)) let kml = builder.kmlString() @@ -53,7 +53,7 @@ final class ForeFlightKMLTests: XCTestCase { func testBuildBasicCircle() throws { let builder = ForeFlightKMLBuilder(documentName: "My Test KML") let center = Coordinate(latitude: 38.8700980, longitude: -77.055967) - + builder.addLineCircle(name: "500m circle", center: center, radiusMeters: 500) let kml = builder.kmlString() diff --git a/Tests/ForeFlightKMLTests/GeometryTests/PolygonAnnularSegmentTests.swift b/Tests/ForeFlightKMLTests/GeometryTests/PolygonAnnularSegmentTests.swift new file mode 100644 index 0000000..c7ea25d --- /dev/null +++ b/Tests/ForeFlightKMLTests/GeometryTests/PolygonAnnularSegmentTests.swift @@ -0,0 +1,179 @@ +import GeodesySpherical +import XCTest + +@testable import ForeFlightKML + +final class PolygonAnnularSegmentTests: XCTestCase { + + struct Quadrant { + let name: String + let start: Double + let end: Double + let color: KMLColor + } + + func testBasicAnnularSegment() throws { + let builder = ForeFlightKMLBuilder(documentName: "Annular Test") + let center = Coordinate(latitude: 38.8700980, longitude: -77.055967) + + let segment = PolygonAnnularSegment( + center: center, + innerRadius: 1000, // 1km inner radius + outerRadius: 2000, // 2km outer radius + startAngle: 0, // North + endAngle: 90 // East + ) + + let placemark = Placemark(name: "Northeast Quadrant", geometry: segment) + builder.addPlacemark(placemark) + let kml = builder.kmlString() + + XCTAssertTrue(kml.contains("")) + XCTAssertTrue(kml.contains("Northeast Quadrant")) + XCTAssertTrue(kml.contains("")) + XCTAssertTrue(kml.contains("")) + } + + func testFourQuadrantAnnularSegments() throws { + let builder = ForeFlightKMLBuilder(documentName: "Ring Quadrants") + let center = Coordinate(latitude: 51.750188, longitude: -1.581566) + + let innerRadius: Double = 1000 // 1km + let outerRadius: Double = 2000 // 2km + + let quadrants: [Quadrant] = [ + Quadrant(name: "North", start: 0, end: 90, color: .black), + Quadrant(name: "East", start: 90, end: 180, color: .black), + Quadrant(name: "South", start: 180, end: 270, color: .black), + Quadrant(name: "West", start: 270, end: 360, color: .black) + ] + + for quadrant in quadrants { + builder.addPolygonAnnularSegment( + name: quadrant.name, + center: center, + innerRadius: innerRadius, + outerRadius: outerRadius, + startAngle: quadrant.start, + endAngle: quadrant.end, + style: PolygonStyle( + outlineColor: .black, + fillColor: .warning.withAlpha(0.3) + ) + ) + } + + let kml = builder.kmlString() + + XCTAssertTrue(kml.contains("North")) + XCTAssertTrue(kml.contains("East")) + XCTAssertTrue(kml.contains("South")) + XCTAssertTrue(kml.contains("West")) + + let placemarkCount = kml.components(separatedBy: "").count - 1 + XCTAssertEqual(placemarkCount, 4) + } + + func testAnnularSegmentWithStyle() throws { + let builder = ForeFlightKMLBuilder(documentName: "Styled Ring") + let center = Coordinate(latitude: 38.8700980, longitude: -77.055967) + + builder.addPolygonAnnularSegment( + name: "Warning Sector", + center: center, + innerRadius: 1500, + outerRadius: 3000, + startAngle: 45, + endAngle: 135, + style: PolygonStyle( + outlineColor: .warning, + outlineWidth: 2.0, + fillColor: .warning.withAlpha(0.4) + ) + ) + + let kml = builder.kmlString() + + XCTAssertTrue(kml.contains("Warning Sector")) + XCTAssertTrue(kml.contains("")) + XCTAssertTrue(kml.contains("")) + } + + func testAnnularSegmentCrossingNorth() throws { + // Test a segment that crosses 0° (wraps around North) + let builder = ForeFlightKMLBuilder(documentName: "Crossing North") + let center = Coordinate(latitude: 38.8700980, longitude: -77.055967) + + let segment = PolygonAnnularSegment( + center: center, + innerRadius: 1000, + outerRadius: 2000, + startAngle: 330, // 30° before North + endAngle: 30 // 30° after North + ) + + builder.addPlacemark(Placemark(name: "North Crossing", geometry: segment)) + 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") + let center = Coordinate(latitude: 38.8700980, longitude: -77.055967) + + let segment = PolygonAnnularSegment( + center: center, + innerRadius: 1000, + outerRadius: 2000, + startAngle: 45, + endAngle: 50, + numberOfPoints: 16 + ) + + builder.addPlacemark(Placemark(name: "Narrow", geometry: segment)) + let kml = builder.kmlString() + + XCTAssertTrue(kml.contains("Narrow")) + } + + func testGenerateCompleteDemoKML() throws { + let builder = ForeFlightKMLBuilder(documentName: "Annular Segments Demo") + let center = Coordinate(latitude: 38.8700980, longitude: -77.055967) + + let innerRadius: Double = 1000 + let outerRadius: Double = 2000 + + let quadrants: [Quadrant] = [ + Quadrant(name: "North Quadrant", start: 0, end: 90, color: .warning), + Quadrant(name: "East Quadrant", start: 90, end: 180, color: .caution), + Quadrant(name: "South Quadrant", start: 180, end: 270, color: .advisory), + Quadrant(name: "West Quadrant", start: 270, end: 360, color: .fromHex("0000FF")) + ] + + for quadrant in quadrants { + builder.addPolygonAnnularSegment( + name: quadrant.name, + center: center, + innerRadius: innerRadius, + outerRadius: outerRadius, + startAngle: quadrant.start, + endAngle: quadrant.end, + numberOfPoints: 64, + style: PolygonStyle( + outlineColor: .black, + outlineWidth: 2.0, + fillColor: quadrant.color.withAlpha(0.4) + ) + ) + } + + let kml = builder.build() + + XCTAssertTrue(kml.contains("")) + let placemarkCount = kml.components(separatedBy: "").count - 1 + XCTAssertEqual(placemarkCount, 4) + } +} diff --git a/Tests/ForeFlightKMLTests/ModelTests/CoordinateContainerTests.swift b/Tests/ForeFlightKMLTests/ModelTests/CoordinateContainerTests.swift index c8926db..58ce72c 100644 --- a/Tests/ForeFlightKMLTests/ModelTests/CoordinateContainerTests.swift +++ b/Tests/ForeFlightKMLTests/ModelTests/CoordinateContainerTests.swift @@ -32,7 +32,7 @@ final class CoordinateContainerTests: XCTestCase { func testKmlWithTessellateAndAltitude() { let coords = [ Coordinate(latitude: 1, longitude: 2), - Coordinate(latitude: 3, longitude: 4), + Coordinate(latitude: 3, longitude: 4) ] let container = TestContainerWithTessellate( coordinates: coords, diff --git a/Tests/ForeFlightKMLTests/StyleTests/Geometry/GeometryStyleTests.swift b/Tests/ForeFlightKMLTests/StyleTests/Geometry/GeometryStyleTests.swift index d36b572..44dfe59 100644 --- a/Tests/ForeFlightKMLTests/StyleTests/Geometry/GeometryStyleTests.swift +++ b/Tests/ForeFlightKMLTests/StyleTests/Geometry/GeometryStyleTests.swift @@ -47,7 +47,7 @@ final class GeometryStylesTests: XCTestCase { Coordinate(latitude: 51.5, longitude: -0.1), Coordinate(latitude: 51.6, longitude: -0.1), Coordinate(latitude: 51.6, longitude: -0.2), - Coordinate(latitude: 51.5, longitude: -0.2), + Coordinate(latitude: 51.5, longitude: -0.2) ] builder.addPolygon( diff --git a/Tests/ForeFlightKMLTests/TestHelpers.swift b/Tests/ForeFlightKMLTests/TestHelpers.swift index 09ef956..550670b 100644 --- a/Tests/ForeFlightKMLTests/TestHelpers.swift +++ b/Tests/ForeFlightKMLTests/TestHelpers.swift @@ -16,11 +16,11 @@ class XMLTestHelper { """ - + let data = Data(completeKML.utf8) return try XMLDocument(data: data, options: []) } - + /// Extract all elements of a given name from XML, handling namespaces static func extractElements(named elementName: String, from xml: XMLDocument) throws -> [XMLElement] { // Try both with and without namespace prefix @@ -29,9 +29,9 @@ class XMLTestHelper { "//kml:\(elementName)", "//gx:\(elementName)" ] - + var allElements: [XMLElement] = [] - + for query in xpathQueries { do { let nodes = try xml.nodes(forXPath: query) @@ -40,10 +40,10 @@ class XMLTestHelper { continue } } - + return allElements } - + /// Get text content from first element with given name, handling namespaces static func getTextContent(elementName: String, from xml: XMLDocument) throws -> String? { let elements = try extractElements(named: elementName, from: xml) @@ -57,23 +57,36 @@ class XMLTestHelper { XCTAssertFalse(elements.isEmpty, "Missing required element: \(elementName)") } } - + /// Parse coordinates string into array of coordinate components - static func parseCoordinates(_ coordinateString: String) -> [(longitude: Double, latitude: Double, altitude: Double?)] { + static func parseCoordinates(_ coordinateString: String) -> [CoordinateComponent] { let lines = coordinateString.components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } - + return lines.compactMap { line in let parts = line.components(separatedBy: ",") - guard parts.count >= 2, - let lon = Double(parts[0]), - let lat = Double(parts[1]) else { - return nil - } - - let alt = parts.count >= 3 ? Double(parts[2]) : nil - return (longitude: lon, latitude: lat, altitude: alt) + return CoordinateComponent(from: parts) + } + } +} + +struct CoordinateComponent { + let longitude: Double + let latitude: Double + let altitude: Double? +} + +extension CoordinateComponent { + init?(from parts: [String]) { + guard parts.count >= 2, + let lon = Double(parts[0]), + let lat = Double(parts[1]) else { + return nil } + + self.longitude = lon + self.latitude = lat + self.altitude = parts.count >= 3 ? Double(parts[2]) : nil } } diff --git a/Tests/ForeFlightKMLTests/UserMapShapesSampleFullTest.swift b/Tests/ForeFlightKMLTests/UserMapShapesSampleFullTest.swift index b601244..5026eaf 100644 --- a/Tests/ForeFlightKMLTests/UserMapShapesSampleFullTest.swift +++ b/Tests/ForeFlightKMLTests/UserMapShapesSampleFullTest.swift @@ -20,7 +20,7 @@ final class UserMapShapesRecreationTests: XCTestCase { ("ypin", .yellow, -102.6009416726494), ("bpin", .blue, -102.3009416726494), ("gpin", .green, -102.0009416726494), ("ltbpin", .lightblue, -101.7009416726494), ("pkpin", .pink, -101.4009416726494), ("ppin", .purple, -101.1009416726494), - ("wpin", .white, -100.8009416726494), + ("wpin", .white, -100.8009416726494) ] for pushpin in pushpins { @@ -287,7 +287,7 @@ final class UserMapShapesRecreationTests: XCTestCase { Coordinate(latitude: 31.61232452550777, longitude: -98.5428285617465), Coordinate(latitude: 31.7047397632289, longitude: -100.3170555137615), Coordinate(latitude: 29.97078684118979, longitude: -100.3694632201026), - Coordinate(latitude: 29.98839907481225, longitude: -97.94364952606627), + Coordinate(latitude: 29.98839907481225, longitude: -97.94364952606627) ] builder.addLine( @@ -304,7 +304,7 @@ final class UserMapShapesRecreationTests: XCTestCase { Coordinate(latitude: 30.49650491542987, longitude: -98.96485321080908), Coordinate(latitude: 30.92214152160733, longitude: -98.95965359046227), Coordinate(latitude: 31.45369338953584, longitude: -99.09548335615463), - Coordinate(latitude: 31.57870338920791, longitude: -100.1097399038377), + Coordinate(latitude: 31.57870338920791, longitude: -100.1097399038377) ] builder.addPolygon( @@ -327,7 +327,7 @@ final class UserMapShapesRecreationTests: XCTestCase { Coordinate(latitude: 30.4076916783399, longitude: -98.9725084035981), Coordinate(latitude: 30.23278723698756, longitude: -100.0984043086122), Coordinate(latitude: 30.02246950700159, longitude: -100.10906670613), - Coordinate(latitude: 30.03052269669343, longitude: -99.82245544757197), + Coordinate(latitude: 30.03052269669343, longitude: -99.82245544757197) ] builder.addPolygon( diff --git a/Tests/ForeFlightKMLTests/UserMapShapesSampleIndividualTests.swift b/Tests/ForeFlightKMLTests/UserMapShapesSampleIndividualTests.swift index c6fa6aa..e3f6e96 100644 --- a/Tests/ForeFlightKMLTests/UserMapShapesSampleIndividualTests.swift +++ b/Tests/ForeFlightKMLTests/UserMapShapesSampleIndividualTests.swift @@ -55,7 +55,7 @@ final class ExampleKMLRecreationTests: XCTestCase { ("ltbpin", .lightblue, -101.7009416726494), ("pkpin", .pink, -101.4009416726494), ("ppin", .purple, -101.1009416726494), - ("wpin", .white, -100.8009416726494), + ("wpin", .white, -100.8009416726494) ] for pushpin in pushpins { @@ -115,13 +115,13 @@ final class ExampleKMLRecreationTests: XCTestCase { (.yellow, 32.00980174601483), (.white, 31.60980174601483), (.red, 31.60980174601483), - (.purple, 31.10980174601483), + (.purple, 31.10980174601483) ] let types: [(type: PredefinedIconType, lonOffset: Double, suffix: String)] = [ (.diamond, 0.0, "diamond"), (.circle, 0.3, "circle"), - (.square, 0.6, "square"), + (.square, 0.6, "square") ] for (index, shape) in shapes.enumerated() { @@ -255,7 +255,7 @@ final class ExampleKMLRecreationTests: XCTestCase { (-100.6451262280923, 32.90340732536495, 662.9205172240112), (-100.6415253336444, 32.99137087501826, 656.9518519692371), (-100.6287822500505, 33.07873557406999, 651.7252962731609), - (-100.6451262280923, 32.90340732536495, 662.9205172240112), // Closing coordinate + (-100.6451262280923, 32.90340732536495, 662.9205172240112) // Closing coordinate ] let coords = circleCoords.map { Coordinate(latitude: $0.lat, longitude: $0.lon) } @@ -287,7 +287,7 @@ final class ExampleKMLRecreationTests: XCTestCase { Coordinate(latitude: 31.61232452550777, longitude: -98.5428285617465), Coordinate(latitude: 31.7047397632289, longitude: -100.3170555137615), Coordinate(latitude: 29.97078684118979, longitude: -100.3694632201026), - Coordinate(latitude: 29.98839907481225, longitude: -97.94364952606627), + Coordinate(latitude: 29.98839907481225, longitude: -97.94364952606627) ] let style = PathStyle( @@ -318,7 +318,7 @@ final class ExampleKMLRecreationTests: XCTestCase { Coordinate(latitude: 30.49650491542987, longitude: -98.96485321080908), Coordinate(latitude: 30.92214152160733, longitude: -98.95965359046227), Coordinate(latitude: 31.45369338953584, longitude: -99.09548335615463), - Coordinate(latitude: 31.57870338920791, longitude: -100.1097399038377), + Coordinate(latitude: 31.57870338920791, longitude: -100.1097399038377) ] let style = PolygonStyle( @@ -354,7 +354,7 @@ final class ExampleKMLRecreationTests: XCTestCase { Coordinate(latitude: 30.4076916783399, longitude: -98.9725084035981), Coordinate(latitude: 30.23278723698756, longitude: -100.0984043086122), Coordinate(latitude: 30.02246950700159, longitude: -100.10906670613), - Coordinate(latitude: 30.03052269669343, longitude: -99.82245544757197), + Coordinate(latitude: 30.03052269669343, longitude: -99.82245544757197) ] let style = PolygonStyle( @@ -401,7 +401,7 @@ final class ExampleKMLRecreationTests: XCTestCase { let pathCoords = [ Coordinate(latitude: 33.29349602069717, longitude: -97.83722968666947), - Coordinate(latitude: 31.29050449094128, longitude: -97.828182763451), + Coordinate(latitude: 31.29050449094128, longitude: -97.828182763451) ] builder.addLine( @@ -415,7 +415,7 @@ final class ExampleKMLRecreationTests: XCTestCase { let polygonCoords = [ Coordinate(latitude: 31.57870338920791, longitude: -100.1097399038377), Coordinate(latitude: 30.28600960074139, longitude: -100.1165273813259), - Coordinate(latitude: 30.49650491542987, longitude: -98.96485321080908), + Coordinate(latitude: 30.49650491542987, longitude: -98.96485321080908) ] builder.addPolygon(