diff --git a/Package.swift b/Package.swift index 9f6c4b7..4545316 100644 --- a/Package.swift +++ b/Package.swift @@ -5,6 +5,7 @@ import PackageDescription let swiftSettings: [SwiftSetting] = [ .enableExperimentalFeature("StrictConcurrency"), // 5.10 .enableUpcomingFeature("StrictConcurrency"), // 6.0 + .enableUpcomingFeature("InferSendableFromCaptures"), // Silences a Sendable warning ] let package = Package( diff --git a/Sources/GISTools/Algorithms/BooleanPointInPolygon.swift b/Sources/GISTools/Algorithms/BooleanPointInPolygon.swift index c7f3068..deb79ec 100644 --- a/Sources/GISTools/Algorithms/BooleanPointInPolygon.swift +++ b/Sources/GISTools/Algorithms/BooleanPointInPolygon.swift @@ -88,15 +88,15 @@ extension Polygon { ignoreBoundary: Bool = false) -> Bool { - if let boundingBox = boundingBox, !boundingBox.contains(coordinate) { + if let boundingBox, !boundingBox.contains(coordinate) { return false } - guard let outerRing = outerRing, outerRing.contains(coordinate, ignoreBoundary: ignoreBoundary) else { - return false - } + guard let outerRing = outerRing, + outerRing.contains(coordinate, ignoreBoundary: ignoreBoundary) + else { return false } - if let innerRings = innerRings { + if let innerRings { for ring in innerRings { if ring.contains(coordinate, ignoreBoundary: ignoreBoundary) { return false @@ -140,6 +140,10 @@ extension MultiPolygon { ignoreBoundary: Bool = false) -> Bool { + if let boundingBox, !boundingBox.contains(coordinate) { + return false + } + for polygon in polygons { if polygon.contains(coordinate, ignoreBoundary: ignoreBoundary) { return true diff --git a/Sources/GISTools/Algorithms/BoundingBoxPosition.swift b/Sources/GISTools/Algorithms/BoundingBoxPosition.swift new file mode 100644 index 0000000..5ffdc30 --- /dev/null +++ b/Sources/GISTools/Algorithms/BoundingBoxPosition.swift @@ -0,0 +1,83 @@ +#if !os(Linux) +import CoreLocation +#endif +import Foundation + +extension BoundingBox { + + /// Position of a coordinate in relation to a bounding box. + public struct CoordinatePosition: OptionSet, Sendable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let center = CoordinatePosition(rawValue: 1 << 0) + public static let top = CoordinatePosition(rawValue: 1 << 1) + public static let right = CoordinatePosition(rawValue: 1 << 2) + public static let bottom = CoordinatePosition(rawValue: 1 << 3) + public static let left = CoordinatePosition(rawValue: 1 << 4) + public static let outside = CoordinatePosition(rawValue: 1 << 5) + } + + /// Returns the relative position of a coordinate with regards to the bounding box. + public func position(of coordinate: Coordinate3D) -> CoordinatePosition { + var position: CoordinatePosition = [] + + if !contains(coordinate) { + position.insert(.outside) + } + + let latitudeSpan = northEast.latitude - southWest.latitude + let longitudeSpan = northEast.longitude - southWest.longitude + + let topCutoff = southWest.latitude + (latitudeSpan * 0.65) + if coordinate.latitude > topCutoff { + position.insert(.top) + } + + let rightCutoff = southWest.longitude + (longitudeSpan * 0.65) + if coordinate.longitude > rightCutoff { + position.insert(.right) + } + + let bottomCutoff = southWest.latitude + (latitudeSpan * 0.35) + if coordinate.latitude < bottomCutoff { + position.insert(.bottom) + } + + let leftCutoff = southWest.longitude + (longitudeSpan * 0.35) + if coordinate.longitude < leftCutoff { + position.insert(.left) + } + + if position.isEmpty { + position.insert(.center) + } + + return position + } + + /// Returns the relative position of a point with regards to the bounding box. + public func postion(of point: Point) -> CoordinatePosition { + position(of: point.coordinate) + } + + // MARK: - CoreLocation compatibility + +#if !os(Linux) + + /// Returns the relative position of a coordinate with regards to the bounding box. + public func postion(of coordinate: CLLocationCoordinate2D) -> CoordinatePosition { + position(of: Coordinate3D(coordinate)) + } + + /// Returns the relative position of a location with regards to the bounding box. + public func postion(of coordinate: CLLocation) -> CoordinatePosition { + position(of: Coordinate3D(coordinate)) + } + +#endif + +} diff --git a/Sources/GISTools/Algorithms/Center.swift b/Sources/GISTools/Algorithms/Center.swift index 2af6eaf..5ea62fa 100644 --- a/Sources/GISTools/Algorithms/Center.swift +++ b/Sources/GISTools/Algorithms/Center.swift @@ -24,7 +24,11 @@ extension GeoJson { public var centroid: Point? { let allCoordinates = self.allCoordinates - guard !allCoordinates.isEmpty else { return nil } + guard allCoordinates.isNotEmpty else { return nil } + + if allCoordinates.count == 1 { + return Point(allCoordinates[0]) + } var sumLongitude: Double = 0.0 var sumLatitude: Double = 0.0 diff --git a/Sources/GISTools/Algorithms/Conversions.swift b/Sources/GISTools/Algorithms/Conversions.swift index 10c75b6..77e1a88 100644 --- a/Sources/GISTools/Algorithms/Conversions.swift +++ b/Sources/GISTools/Algorithms/Conversions.swift @@ -10,7 +10,7 @@ import Foundation extension GISTool { /// Unit of measurement. - public enum Unit { + public enum Unit: Sendable { case acres case centimeters case centimetres @@ -43,7 +43,7 @@ extension GISTool { case .millimeters, .millimetres: return earthRadius * 1000.0 case .nauticalmiles: return earthRadius / 1852.0 case .radians: return 1.0 - case .yards: return earthRadius / 1.0936 + case .yards: return earthRadius / (1.0 / 1.0936) default: return nil } } @@ -70,13 +70,13 @@ extension GISTool { public static func areaFactor(for unit: Unit) -> Double? { switch unit { case .acres: return 0.000247105 - case .centimeters, .centimetres: return 10000.0 + case .centimeters, .centimetres: return 10_000.0 case .feet: return 10.763910417 case .inches: return 1550.003100006 case .kilometers, .kilometres: return 0.000001 case .meters, .metres: return 1.0 case .miles: return 3.86e-7 - case .millimeters, .millimetres: return 1_000_000 + case .millimeters, .millimetres: return 1_000_000.0 case .yards: return 1.195990046 default: return nil } @@ -84,14 +84,22 @@ extension GISTool { /// Converts a length to the requested unit. /// Valid units: miles, nauticalmiles, inches, yards, meters, metres, kilometers, centimeters, feet - public static func convert(length: Double, from originalUnit: Unit, to finalUnit: Unit) -> Double? { + public static func convert( + length: Double, + from originalUnit: Unit, + to finalUnit: Unit + ) -> Double? { guard length >= 0 else { return nil } return length.lengthToRadians(unit: originalUnit)?.radiansToLength(unit: finalUnit) } /// Converts a area to the requested unit. /// Valid units: kilometers, kilometres, meters, metres, centimetres, millimeters, acres, miles, yards, feet, inches - public static func convert(area: Double, from originalUnit: Unit, to finalUnit: Unit) -> Double? { + public static func convert( + area: Double, + from originalUnit: Unit, + to finalUnit: Unit + ) -> Double? { guard area >= 0, let startFactor = areaFactor(for: originalUnit), let finalFactor = areaFactor(for: finalUnit) @@ -106,15 +114,29 @@ extension GISTool { extension GISTool { - /// Converts pixel coordinates in a given zoom level to a coordinate. + @available(*, deprecated, renamed: "coordinate(fromPixelX:pixelY:zoom:tileSideLength:projection:)") public static func convertToCoordinate( fromPixelX pixelX: Double, pixelY: Double, atZoom zoom: Int, tileSideLength: Double = GISTool.tileSideLength, - projection: Projection = .epsg4326) - -> Coordinate3D - { + projection: Projection = .epsg4326 + ) -> Coordinate3D { + coordinate(fromPixelX: pixelX, + pixelY: pixelY, + zoom: zoom, + tileSideLength: tileSideLength, + projection: projection) + } + + /// Converts pixel coordinates in a given zoom level to a coordinate. + public static func coordinate( + fromPixelX pixelX: Double, + pixelY: Double, + zoom: Int, + tileSideLength: Double = GISTool.tileSideLength, + projection: Projection = .epsg4326 + ) -> Coordinate3D { let resolution = metersPerPixel(atZoom: zoom, tileSideLength: tileSideLength) let coordinateXY = Coordinate3D( @@ -144,11 +166,18 @@ extension GISTool { extension GISTool { + @available(*, deprecated, renamed: "degrees(fromMeters:atLatitude:)") public static func convertToDegrees( fromMeters meters: Double, - atLatitude latitude: CLLocationDegrees) - -> (latitudeDegrees: CLLocationDegrees, longitudeDegrees: CLLocationDegrees) - { + atLatitude latitude: CLLocationDegrees + ) -> (latitudeDegrees: CLLocationDegrees, longitudeDegrees: CLLocationDegrees) { + degrees(fromMeters: meters, atLatitude: latitude) + } + + public static func degrees( + fromMeters meters: CLLocationDistance, + atLatitude latitude: CLLocationDegrees + ) -> (latitudeDegrees: CLLocationDegrees, longitudeDegrees: CLLocationDegrees) { // Length of one minute at this latitude let oneDegreeLatitudeDistance: CLLocationDistance = GISTool.earthCircumference / 360.0 // ~111 km let oneDegreeLongitudeDistance: CLLocationDistance = cos(latitude * Double.pi / 180.0) * oneDegreeLatitudeDistance @@ -160,3 +189,13 @@ extension GISTool { } } + +extension Coordinate3D { + + public func degrees( + fromMeters meters: CLLocationDistance + ) -> (latitudeDegrees: CLLocationDegrees, longitudeDegrees: CLLocationDegrees) { + GISTool.degrees(fromMeters: meters, atLatitude: latitude) + } + +} diff --git a/Sources/GISTools/Algorithms/FrechetDistance.swift b/Sources/GISTools/Algorithms/FrechetDistance.swift index 26db8ac..42ab7e7 100644 --- a/Sources/GISTools/Algorithms/FrechetDistance.swift +++ b/Sources/GISTools/Algorithms/FrechetDistance.swift @@ -37,7 +37,7 @@ extension LineString { /// - Parameters: /// - from: The other geometry of equal type. /// - distanceFunction: The algorithm to use for distance calculations. - /// - segmentLength: Adds coordinates to the lines for improved matching (in meters). + /// - segmentLength: This value adds intermediate points to the geometry for improved matching, in meters. /// /// - Returns: The frechet distance between the two geometries. public func frechetDistance( diff --git a/Sources/GISTools/Algorithms/LineChunk.swift b/Sources/GISTools/Algorithms/LineChunk.swift index 6fb4d36..2ac9969 100644 --- a/Sources/GISTools/Algorithms/LineChunk.swift +++ b/Sources/GISTools/Algorithms/LineChunk.swift @@ -10,8 +10,13 @@ extension LineString { /// Divides a *LineString* into chunks of a specified length. /// If the line is shorter than the segment length then the original line is returned. /// - /// - Parameter segmentLength: How long to make each segment, in meters - public func chunked(segmentLength: CLLocationDistance) -> MultiLineString { + /// - Parameters: + /// - segmentLength: How long to make each segment, in meters + /// - dropIntermediateCoordinates: Simplify the result so that each chunk has exactly two coordinates + public func chunked( + segmentLength: CLLocationDistance, + dropIntermediateCoordinates: Bool = false + ) -> MultiLineString { guard self.isValid, segmentLength > 0.0 else { return MultiLineString() } @@ -26,7 +31,15 @@ extension LineString { let numberOfSegments = Int(ceil(lineLength / segmentLength)) for index in 0 ..< numberOfSegments { guard let chunk = sliceAlong(startDistance: segmentLength * Double(index), stopDistance: segmentLength * Double(index + 1)) else { break } - lineStrings.append(chunk) + + if dropIntermediateCoordinates, + let newChunk = LineString([chunk.firstCoordinate, chunk.lastCoordinate].compactMap({ $0 })) + { + lineStrings.append(newChunk) + } + else { + lineStrings.append(chunk) + } } return MultiLineString(lineStrings) ?? MultiLineString() @@ -35,13 +48,18 @@ extension LineString { /// Divides a *LineString* into evenly spaced segments of a specified length. /// If the line is shorter than the segment length then the original line is returned. /// - /// - Parameter segmentLength: How long to make each segment, in meters - public func evenlyDivided(segmentLength: CLLocationDistance) -> LineString { + /// - Parameters: + /// - segmentLength: How long to make each segment, in meters + /// - dropIntermediateCoordinates: Simplify the result so that each chunk has exactly two coordinates + public func evenlyDivided( + segmentLength: CLLocationDistance, + dropIntermediateCoordinates: Bool = false + ) -> LineString { guard self.isValid, segmentLength > 0.0 else { return LineString() } - return LineString(chunked(segmentLength: segmentLength).lineSegments) ?? self + return LineString(chunked(segmentLength: segmentLength, dropIntermediateCoordinates: dropIntermediateCoordinates).lineSegments) ?? self } } @@ -51,18 +69,30 @@ extension MultiLineString { /// Divides a *MultiLineString* into chunks of a specified length. /// If any line is shorter than the segment length then the original line is returned. /// - /// - Parameter segmentLength: How long to make each segment, in meters - public func chunked(segmentLength: CLLocationDistance) -> MultiLineString { - MultiLineString(lineStrings.flatMap({ $0.chunked(segmentLength: segmentLength).lineStrings })) ?? self + /// - Parameters: + /// - segmentLength: How long to make each segment, in meters + /// - dropIntermediateCoordinates: Simplify the result so that each chunk has exactly two coordinates + public func chunked( + segmentLength: CLLocationDistance, + dropIntermediateCoordinates: Bool = false + ) -> MultiLineString { + MultiLineString(lineStrings.flatMap({ + $0.chunked(segmentLength: segmentLength, dropIntermediateCoordinates: dropIntermediateCoordinates).lineStrings + })) ?? self } /// Divides a *MultiLineString* into evenly spaced segments of a specified length. /// If the line is shorter than the segment length then the original line is returned. /// - /// - Parameter segmentLength: How long to make each segment, in meters - public func evenlyDivided(segmentLength: CLLocationDistance) -> MultiLineString { + /// - Parameters: + /// - segmentLength: How long to make each segment, in meters + /// - dropIntermediateCoordinates: Simplify the result so that each chunk has exactly two coordinates + public func evenlyDivided( + segmentLength: CLLocationDistance, + dropIntermediateCoordinates: Bool = false + ) -> MultiLineString { MultiLineString(lineStrings.map({ - LineString($0.chunked(segmentLength: segmentLength).lineSegments) ?? $0 + LineString($0.chunked(segmentLength: segmentLength, dropIntermediateCoordinates: dropIntermediateCoordinates).lineSegments) ?? $0 })) ?? self } @@ -74,16 +104,21 @@ extension Feature { /// into a *FeatureCollection* of chunks of a specified length. /// If any line is shorter than the segment length then the original line is returned. /// - /// - Parameter segmentLength: How long to make each segment, in meters - public func chunked(segmentLength: CLLocationDistance) -> FeatureCollection { + /// - Parameters: + /// - segmentLength: How long to make each segment, in meters + /// - dropIntermediateCoordinates: Simplify the result so that each chunk has exactly two coordinates + public func chunked( + segmentLength: CLLocationDistance, + dropIntermediateCoordinates: Bool = false + ) -> FeatureCollection { var features: [Feature] switch self.geometry { case let lineString as LineString: - features = lineString.chunked(segmentLength: segmentLength).lineStrings.map({ Feature($0, id: id, properties: properties) }) + features = lineString.chunked(segmentLength: segmentLength, dropIntermediateCoordinates: dropIntermediateCoordinates).lineStrings.map({ Feature($0, id: id, properties: properties) }) case let multiLineString as MultiLineString: - features = multiLineString.chunked(segmentLength: segmentLength).lineStrings.map({ Feature($0, id: id, properties: properties) }) + features = multiLineString.chunked(segmentLength: segmentLength, dropIntermediateCoordinates: dropIntermediateCoordinates).lineStrings.map({ Feature($0, id: id, properties: properties) }) default: features = [self] @@ -98,12 +133,17 @@ extension FeatureCollection { /// Returns a *FeatureCollection* containing a *LineString* or *MultiLineString* chunked into smaller parts. /// - /// - Parameter segmentLength: How long to make each segment, in meters - public func chunked(segmentLength: CLLocationDistance) -> FeatureCollection { + /// - Parameters: + /// - segmentLength: How long to make each segment, in meters + /// - dropIntermediateCoordinates: Simplify the result so that each chunk has exactly two coordinates + public func chunked( + segmentLength: CLLocationDistance, + dropIntermediateCoordinates: Bool = false + ) -> FeatureCollection { var newFeatures: [Feature] = [] for feature in features { - newFeatures.append(contentsOf: feature.chunked(segmentLength: segmentLength).features) + newFeatures.append(contentsOf: feature.chunked(segmentLength: segmentLength, dropIntermediateCoordinates: dropIntermediateCoordinates).features) } return FeatureCollection(newFeatures) diff --git a/Sources/GISTools/Algorithms/LineIntersect.swift b/Sources/GISTools/Algorithms/LineIntersect.swift index d49d0fb..f5ebb6c 100644 --- a/Sources/GISTools/Algorithms/LineIntersect.swift +++ b/Sources/GISTools/Algorithms/LineIntersect.swift @@ -8,7 +8,7 @@ import Foundation extension LineSegment { - private enum Orientation { + private enum Orientation: Sendable { case colinear case clockwise case counterClockwise diff --git a/Sources/GISTools/Algorithms/LineOverlap.swift b/Sources/GISTools/Algorithms/LineOverlap.swift index 9e85664..97f178c 100644 --- a/Sources/GISTools/Algorithms/LineOverlap.swift +++ b/Sources/GISTools/Algorithms/LineOverlap.swift @@ -8,7 +8,7 @@ import Foundation extension LineSegment { /// Indicates how one segment compares to another segment. - public enum LineSegmentComparisonResult { + public enum LineSegmentComparisonResult: Sendable { case equal case notEqual case thisOnOther @@ -101,4 +101,157 @@ extension GeoJson { return result } + /// Returns the overlapping segments with the receiver itself. + /// + /// This implementation has been optimized for finding self-overlaps. + /// + /// - Note: Altitude values will be ignored. + /// + /// - Parameters: + /// - tolerance: The tolerance, in meters. Using `0.0` will only return segments that *exactly* overlap. + /// - segmentLength: This value adds intermediate points to the geometry for improved matching, in meters. Choosing this too small might lead to memory explosion. + /// + /// - Returns: All segments that at least overlap with one other segment. Each segment will + /// appear in the result only once. + public func overlappingSegments( + tolerance: CLLocationDistance, + segmentLength: Double? = nil + ) -> MultiLineString? { + let tolerance = abs(tolerance) + let distanceFunction = FrechetDistanceFunction.haversine + + guard let line = if let segmentLength, segmentLength > 0.0 { + LineString(lineSegments)?.evenlyDivided(segmentLength: segmentLength) + } + else { + LineString(lineSegments) + } + else { + return nil + } + + let p = line.allCoordinates + var ca: [OrderedIndexPair: Double] = [:] + + func index(_ pI: Int, _ qI: Int) -> OrderedIndexPair { + .init(pI, qI) + } + + // Distances between each coordinate pair + for i in 0 ..< p.count { + for j in i + 1 ..< p.count { + let distance = distanceFunction.distance(between: p[i], and: p[j]) + if distance > tolerance { continue } + ca[index(i, j)] = distance + } + } + + // Find coordinate pairs within the tolerance + var pairs: Set = [] + + var i = 0 + outer: while i < p.count - 1 { + defer { i += 1 } + + var j = i + 2 + while ca[index(i, j), default: Double.greatestFiniteMagnitude] <= tolerance { + j += 1 + if j == p.count { break outer } + } + + while j < p.count { + defer { j += 1 } + + if ca[index(i, j), default: Double.greatestFiniteMagnitude] <= tolerance { + pairs.insert(index(i, j)) + } + } + } + + // Find overlapping segments + var scratchList = pairs.sorted() + var result: Set = [] + while scratchList.isNotEmpty { + let candidate = scratchList.removeFirst() + + if candidate.first > 0, + candidate.second > 0, + pairs.contains(index(candidate.first - 1, candidate.second - 1)) + { + result.insert(index(candidate.first, candidate.first - 1)) + result.insert(index(candidate.second, candidate.second - 1)) + continue + } + + if candidate.first > 0, + candidate.second < p.count - 1, + pairs.contains(index(candidate.first - 1, candidate.second + 1)) + { + result.insert(index(candidate.first, candidate.first - 1)) + result.insert(index(candidate.second, candidate.second + 1)) + continue + } + + if candidate.first < p.count - 1, + candidate.second > 0, + pairs.contains(index(candidate.first + 1, candidate.second - 1)) + { + result.insert(index(candidate.first, candidate.first + 1)) + result.insert(index(candidate.second, candidate.second - 1)) + continue + } + + if candidate.first < p.count - 1, + candidate.second < p.count - 1, + pairs.contains(index(candidate.first + 1, candidate.second + 1)) + { + result.insert(index(candidate.first, candidate.first + 1)) + result.insert(index(candidate.second, candidate.second + 1)) + continue + } + } + + return MultiLineString(result.map({ LineString(unchecked: [p[$0.first], p[$0.second]]) })) + } + + /// An estimate of how much the receiver overlaps with itself. + /// + /// - Parameters: + /// - tolerance: The tolerance, in meters. Using `0.0` will only count segments that *exactly* overlap. + /// - segmentLength: This value adds intermediate points to the geometry for improved matching, in meters. Choosing this too small might lead to memory explosion. + /// + /// - Returns: The length of all segments that overlap within `tolerance`. + public func estimatedOverlap( + tolerance: CLLocationDistance, + segmentLength: Double? = nil + ) -> Double { + guard let result = overlappingSegments(tolerance: tolerance, segmentLength: segmentLength) else { return 0.0 } + + return result.length + } + +} + +// MARK: - Private + +private struct OrderedIndexPair: Hashable, Comparable, CustomStringConvertible { + + let first: Int + let second: Int + + init(_ first: Int, _ second: Int) { + self.first = min(first, second) + self.second = max(first, second) + } + + var description: String { + "(\(first)-\(second))" + } + + static func < (lhs: OrderedIndexPair, rhs: OrderedIndexPair) -> Bool { + if lhs.first < rhs.first { return true } + if lhs.first > rhs.first { return false } + return lhs.second < rhs.second + } + } diff --git a/Sources/GISTools/Algorithms/LineSegments.swift b/Sources/GISTools/Algorithms/LineSegments.swift index 08eeb89..7185a89 100644 --- a/Sources/GISTools/Algorithms/LineSegments.swift +++ b/Sources/GISTools/Algorithms/LineSegments.swift @@ -8,10 +8,10 @@ extension BoundingBox { /// Returns the receiver as *LineSegment*s. public var lineSegments: [LineSegment] { [ - LineSegment(first: northWest, second: northEast), - LineSegment(first: northEast, second: southEast), - LineSegment(first: southEast, second: southWest), - LineSegment(first: southWest, second: northWest), + LineSegment(first: northWest, second: northEast, index: 0), + LineSegment(first: northEast, second: southEast, index: 1), + LineSegment(first: southEast, second: southWest, index: 2), + LineSegment(first: southWest, second: northWest, index: 3), ] } @@ -34,9 +34,9 @@ extension GeoJson { return [] case let lineString as LineString: - return lineString.coordinates.overlappingPairs().compactMap { (first, second) in + return lineString.coordinates.overlappingPairs().compactMap { (first, second, index) in guard let second else { return nil } - return LineSegment(first: first, second: second) + return LineSegment(first: first, second: second, index: index) } case let multiLineString as MultiLineString: @@ -44,9 +44,9 @@ extension GeoJson { case let polygon as Polygon: return polygon.rings.flatMap({ (ring) in - ring.coordinates.overlappingPairs().compactMap { (first, second) in + ring.coordinates.overlappingPairs().compactMap { (first, second, index) in guard let second else { return nil } - return LineSegment(first: first, second: second) + return LineSegment(first: first, second: second, index: index) } }) diff --git a/Sources/GISTools/Algorithms/Reverse.swift b/Sources/GISTools/Algorithms/Reverse.swift index bf147a2..6bc513a 100644 --- a/Sources/GISTools/Algorithms/Reverse.swift +++ b/Sources/GISTools/Algorithms/Reverse.swift @@ -68,7 +68,10 @@ extension LineSegment { /// Returns the receiver with all coordinates reversed. public var reversed: LineSegment { - var result = LineSegment(first: second, second: first) + var result = LineSegment( + first: second, + second: first, + index: index) result.boundingBox = boundingBox return result } diff --git a/Sources/GISTools/Algorithms/Rewind.swift b/Sources/GISTools/Algorithms/Rewind.swift new file mode 100644 index 0000000..498c75b --- /dev/null +++ b/Sources/GISTools/Algorithms/Rewind.swift @@ -0,0 +1,110 @@ +#if !os(Linux) +import CoreLocation +#endif +import Foundation + +/// The winding order for polygons. +public enum PolygonWindingOrder: Sendable { + case clockwise + case counterClockwise +} + +extension GeoJson { + + /// Returns the receiver with the specified winding order. + /// `Point` and `MultiPoint` will be returned as-is. + public func withWindingOrder(_ order: PolygonWindingOrder) -> Self { + switch self { + case let lineString as LineString: + guard let ring = Ring(lineString.coordinates) else { return self } + + if (order == .counterClockwise && ring.isClockwise) + || (order == .clockwise && ring.isCounterClockwise) + { + return self + } + + return lineString.reversed as! Self + + case let multiLineString as MultiLineString: + var newMultiLineString = MultiLineString( + unchecked: multiLineString.lineStrings.map({ $0.withWindingOrder(order) }), + calculateBoundingBox: (multiLineString.boundingBox != nil)) + newMultiLineString.foreignMembers = multiLineString.foreignMembers + return newMultiLineString as! Self + + case let polygon as Polygon: + guard var outer = polygon.outerRing else { return self } + + if order == .counterClockwise { + if outer.isClockwise { outer.reverse() } + let inner: [Ring] = polygon.innerRings?.map({ $0.isClockwise ? $0 : $0.reversed }) ?? [] + var newPolygon = Polygon( + unchecked: [outer] + inner, + calculateBoundingBox: (polygon.boundingBox != nil)) + newPolygon.foreignMembers = polygon.foreignMembers + return newPolygon as! Self + } + else { + if outer.isCounterClockwise { outer.reverse() } + let inner: [Ring] = polygon.innerRings?.map({ $0.isCounterClockwise ? $0 : $0.reversed }) ?? [] + var newPolygon = Polygon( + unchecked: [outer] + inner, + calculateBoundingBox: (polygon.boundingBox != nil)) + newPolygon.foreignMembers = polygon.foreignMembers + return newPolygon as! Self + } + + case let multiPolygon as MultiPolygon: + var newMultiPolygon = MultiPolygon( + unchecked: multiPolygon.polygons.map({ $0.withWindingOrder(order) }), + calculateBoundingBox: (multiPolygon.boundingBox != nil)) + newMultiPolygon.foreignMembers = multiPolygon.foreignMembers + return newMultiPolygon as! Self + + case let geometryCollection as GeometryCollection: + var newGeometryCollection = GeometryCollection( + geometryCollection.geometries.map({ $0.withWindingOrder(order) }), + calculateBoundingBox: (geometryCollection.boundingBox != nil)) + newGeometryCollection.foreignMembers = geometryCollection.foreignMembers + return newGeometryCollection as! Self + + case let feature as Feature: + var newFeature = Feature( + feature.geometry.withWindingOrder(order), + id: feature.id, + properties: feature.properties, + calculateBoundingBox: (feature.boundingBox != nil)) + newFeature.foreignMembers = feature.foreignMembers + return newFeature as! Self + + case let featureCollection as FeatureCollection: + var newFeatureCollection = FeatureCollection( + featureCollection.features.map({ $0.withWindingOrder(order) }), + calculateBoundingBox: (featureCollection.boundingBox != nil)) + newFeatureCollection.foreignMembers = featureCollection.foreignMembers + return newFeatureCollection as! Self + + default: + return self + } + } + + /// Forces the receiver to be in the specified winding order. + /// `Point` and `MultiPoint` will be returned as-is. + public mutating func forceWindingOrder(_ order: PolygonWindingOrder) { + self = withWindingOrder(order) + } + + /// Returns the receiver with the outer ring counterclockwise and inner rings clockwise. + /// `Point` and `MultiPoint` will be returned as-is. + public var rewinded: Self { + withWindingOrder(.counterClockwise) + } + + /// Rewinds the receiver with the outer ring counterclockwise and inner rings clockwise. + public mutating func rewind() { + self = rewinded + } + +} diff --git a/Sources/GISTools/Algorithms/TransformScale.swift b/Sources/GISTools/Algorithms/TransformScale.swift index 372abd9..e85f0d3 100644 --- a/Sources/GISTools/Algorithms/TransformScale.swift +++ b/Sources/GISTools/Algorithms/TransformScale.swift @@ -8,7 +8,7 @@ import Foundation // Ported from https://github.com/Turfjs/turf/blob/master/packages/turf-transform-scale /// The anchor from where a scale operation takes place. -public enum ScaleAnchor { +public enum ScaleAnchor: Sendable { case southWest case southEast case northWest diff --git a/Sources/GISTools/Extensions/ArrayExtensions.swift b/Sources/GISTools/Extensions/ArrayExtensions.swift index 6a0253a..e1de077 100644 --- a/Sources/GISTools/Extensions/ArrayExtensions.swift +++ b/Sources/GISTools/Extensions/ArrayExtensions.swift @@ -145,15 +145,22 @@ extension Array { /// let a = [1, 2, 3, 4, 5] /// a.overlappingPairs() -> [(1, 2), (2, 3), (3, 4), (4, 5)] /// ``` - func overlappingPairs() -> [(first: Element, second: Element?)] { + func overlappingPairs() -> [(first: Element, second: Element?, index: Int)] { guard !isEmpty else { return [] } if count == 1 { - return [(first: self[0], second: nil)] + return [(first: self[0], second: nil, index: 0)] } return (0 ..< (self.count - 1)).map { (index) in - return (first: self[index], second: self[index + 1]) + return (first: self[index], second: self[index + 1], index: index) + } + } + + /// Split the array into equal sized chunks. + func chunked(into chunkSize: Int) -> [[Element]] { + stride(from: 0, to: count, by: chunkSize).map { chunk in + Array(self[chunk ..< Swift.min(chunk + chunkSize, count)]) } } diff --git a/Sources/GISTools/Extensions/DoubleExtensions.swift b/Sources/GISTools/Extensions/DoubleExtensions.swift index ea1d3bd..eac668f 100644 --- a/Sources/GISTools/Extensions/DoubleExtensions.swift +++ b/Sources/GISTools/Extensions/DoubleExtensions.swift @@ -57,6 +57,55 @@ extension Double { } +extension Double { + + /// Convert millimeters to meters. + public var millimeters: Double { + self / 1000.0 + } + + /// Convert centimeters to meters. + public var centimeters: Double { + self / 100.0 + } + + /// Convert meters to meters (i.e. returns self). + public var meters: Double { + self + } + + /// Convert kilometers to meters. + public var kilometers: Double { + self * 1000.0 + } + + /// Convert inches to meters. + public var inches: Double { + self / 39.370 + } + + /// Convert feet to meters. + public var feet: Double { + self / 3.28084 + } + + /// Convert yards to meters. + public var yards: Double { + self / 1.0936 + } + + /// Convert miles to meters. + public var miles: Double { + self * 1609.344 + } + + /// Convert nautical miles to meters. + public var nauticalMiles: Double { + self * 1852.0 + } + +} + // MARK: - Private extension Double { diff --git a/Sources/GISTools/Extensions/EquatableExtensions.swift b/Sources/GISTools/Extensions/EquatableExtensions.swift new file mode 100644 index 0000000..ba2998a --- /dev/null +++ b/Sources/GISTools/Extensions/EquatableExtensions.swift @@ -0,0 +1,19 @@ +// +// Created by Thomas Rasch on 21.04.23. +// + +import Foundation + +// MARK: Private + +extension Equatable { + + func isIn(_ c: [Self]) -> Bool { + return c.contains(self) + } + + func isNotIn(_ c: [Self]) -> Bool { + return !c.contains(self) + } + +} diff --git a/Sources/GISTools/Extensions/IntExtensions.swift b/Sources/GISTools/Extensions/IntExtensions.swift new file mode 100644 index 0000000..d70c237 --- /dev/null +++ b/Sources/GISTools/Extensions/IntExtensions.swift @@ -0,0 +1,52 @@ +import Foundation + +// MARK: Public + +extension Int { + + /// Convert millimeters to meters. + public var millimeters: Double { + Double(self) / 1000.0 + } + + /// Convert centimeters to meters. + public var centimeters: Double { + Double(self) / 100.0 + } + + /// Convert meters to meters (i.e. returns self as Double). + public var meters: Double { + Double(self) + } + + /// Convert kilometers to meters. + public var kilometers: Double { + Double(self) * 1000.0 + } + + /// Convert inches to meters. + public var inches: Double { + Double(self) / 39.370 + } + + /// Convert feet to meters. + public var feet: Double { + Double(self) / 3.28084 + } + + /// Convert yards to meters. + public var yards: Double { + Double(self) / 1.0936 + } + + /// Convert miles to meters. + public var miles: Double { + Double(self) * 1609.344 + } + + /// Convert nautical miles to meters. + public var nauticalMiles: Double { + Double(self) * 1852.0 + } + +} diff --git a/Sources/GISTools/Extensions/SetExtensions.swift b/Sources/GISTools/Extensions/SetExtensions.swift index ea5dc0c..d4bb258 100644 --- a/Sources/GISTools/Extensions/SetExtensions.swift +++ b/Sources/GISTools/Extensions/SetExtensions.swift @@ -9,4 +9,9 @@ extension Set { Array(self) } + /// A Boolean value indicating whether the collection is not empty. + var isNotEmpty: Bool { + !isEmpty + } + } diff --git a/Sources/GISTools/GeoJson/BoundingBox.swift b/Sources/GISTools/GeoJson/BoundingBox.swift index c9ff962..fe1d33c 100644 --- a/Sources/GISTools/GeoJson/BoundingBox.swift +++ b/Sources/GISTools/GeoJson/BoundingBox.swift @@ -77,7 +77,7 @@ public struct BoundingBox: northEast.longitude += padding case .epsg4326: - let latLongDegrees = GISTool.convertToDegrees(fromMeters: padding, atLatitude: southWest.latitude) + let latLongDegrees = GISTool.degrees(fromMeters: padding, atLatitude: southWest.latitude) southWest.latitude -= latLongDegrees.latitudeDegrees northEast.latitude += latLongDegrees.latitudeDegrees diff --git a/Sources/GISTools/GeoJson/BoundingBoxRepresentable.swift b/Sources/GISTools/GeoJson/BoundingBoxRepresentable.swift index 8eb0de9..9fa2bfc 100644 --- a/Sources/GISTools/GeoJson/BoundingBoxRepresentable.swift +++ b/Sources/GISTools/GeoJson/BoundingBoxRepresentable.swift @@ -31,7 +31,7 @@ extension BoundingBoxRepresentable { @discardableResult public mutating func updateBoundingBox(onlyIfNecessary ifNecessary: Bool = true) -> BoundingBox? { - if boundingBox != nil && ifNecessary { return nil } + if boundingBox != nil && ifNecessary { return boundingBox } boundingBox = calculateBoundingBox() return boundingBox } diff --git a/Sources/GISTools/GeoJson/Coordinate3D.swift b/Sources/GISTools/GeoJson/Coordinate3D.swift index dfeadcf..cbd14dc 100644 --- a/Sources/GISTools/GeoJson/Coordinate3D.swift +++ b/Sources/GISTools/GeoJson/Coordinate3D.swift @@ -346,27 +346,45 @@ extension Coordinate3D: Projectable { extension Coordinate3D: GeoJsonReadable { - /// Create a coordinate from a JSON object. + /// Create a coordinate from a JSON object, which can either be + /// - GeoJSON, + /// - or a dictionary with `x`, `y` and `z` values. /// /// - Note: The [GeoJSON spec](https://datatracker.ietf.org/doc/html/rfc7946) /// uses CRS:84 that specifies coordinates in longitude/latitude order. - /// - Important: The third value will always be ``altitude``, the fourth value + /// - Important: The third value in GeoJSON coordinates will always be ``altitude``, the fourth value /// will be ``m`` if it exists. ``altitude`` can be a JSON `null` value. /// - important: The source is expected to be in EPSG:4326. public init?(json: Any?) { - guard let pointArray = json as? [Double?], - pointArray.count >= 2, - let pLongitude = pointArray[0], - let pLatitude = pointArray[1] - else { return nil } + var pLongitude: Double? + var pLatitude: Double? + var pAltitude: CLLocationDistance? + var pM: Double? + + if let pointArray = json as? [Double?], + pointArray.count >= 2 + { + pLongitude = pointArray[0] + pLatitude = pointArray[1] + pAltitude = if pointArray.count >= 3 { pointArray[2] } else { nil } + pM = if pointArray.count >= 4 { pointArray[3] } else { nil } + } + else if let pointDictionary = json as? [String: Any] { + pLongitude = pointDictionary["x"] as? Double + pLatitude = pointDictionary["y"] as? Double + pAltitude = pointDictionary["z"] as? CLLocationDistance + pM = pointDictionary["m"] as? Double + } + else { + return nil + } - let pAltitude: CLLocationDistance? = if pointArray.count >= 3 { pointArray[2] } else { nil } - let pM: Double? = if pointArray.count >= 4 { pointArray[3] } else { nil } + guard let pLongitude, let pLatitude else { return nil } self.init(latitude: pLatitude, longitude: pLongitude, altitude: pAltitude, m: pM) } - /// Dump the coordinate as a JSON object. + /// Dump the coordinate as a GeoJSON coordinate. /// /// - Important: The result JSON object will have a `null` value for the altitude /// if the ``altitude`` is `nil` and ``m`` exists. @@ -458,19 +476,55 @@ extension Sequence { extension Coordinate3D: Equatable { - /// Coordinates are regarded as equal when they are within a few μm from each other. - /// See ``GISTool.equalityDelta``. + /// Coordinates are regarded as equal when they are within a few μm from each other + /// (mainly to counter small rounding errors). + /// See also `GISTool.equalityDelta`. + /// + /// - note: `GISTool.equalityDelta` works only for coordinates in EPSG:4326 projection. + /// Use `equals(other:includingAltitude:equalityDelta:altitudeDelta:)` + /// for other projections or if you need more control. + /// + /// - note: This also compares the altitudes of coordinates. public static func == ( lhs: Coordinate3D, rhs: Coordinate3D) -> Bool { lhs.projection == rhs.projection - && abs(lhs.latitude - rhs.latitude) < GISTool.equalityDelta - && abs(lhs.longitude - rhs.longitude) < GISTool.equalityDelta + && abs(lhs.latitude - rhs.latitude) <= GISTool.equalityDelta + && abs(lhs.longitude - rhs.longitude) <= GISTool.equalityDelta && lhs.altitude == rhs.altitude } + /// Compares two coordinates with the given deltas. + /// + /// - note: The `other` coordinate will be projected to the projection of the reveiver. + public func equals( + other: Coordinate3D, + includingAltitude: Bool = true, + equalityDelta: Double = GISTool.equalityDelta, + altitudeDelta: Double = 0.0) + -> Bool + { + let other = other.projected(to: projection) + + if abs(latitude - other.latitude) > equalityDelta + || abs(longitude - other.longitude) > equalityDelta + { + return false + } + + if includingAltitude { + if let altitude, let otherAltitude = other.altitude { + return abs(altitude - otherAltitude) <= altitudeDelta + } + + return altitude == other.altitude + } + + return true + } + } // MARK: - Hashable diff --git a/Sources/GISTools/GeoJson/Feature.swift b/Sources/GISTools/GeoJson/Feature.swift index 0411435..a50ed6c 100644 --- a/Sources/GISTools/GeoJson/Feature.swift +++ b/Sources/GISTools/GeoJson/Feature.swift @@ -59,7 +59,7 @@ public struct Feature: } } - public var asJson: Any { + public var asJson: Sendable { switch self { case .double(let double): double case .int(let int): int @@ -79,7 +79,7 @@ public struct Feature: } public var type: GeoJsonType { - return .feature + .feature } public var projection: Projection { @@ -90,7 +90,7 @@ public struct Feature: public var id: Identifier? /// The `Feature`s geometry object. - public let geometry: GeoJsonGeometry + public private(set) var geometry: GeoJsonGeometry public var allCoordinates: [Coordinate3D] { geometry.allCoordinates @@ -115,7 +115,7 @@ public struct Feature: self.properties = properties if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -134,8 +134,8 @@ public struct Feature: self.properties = (geoJson["properties"] as? [String: Sendable]) ?? [:] self.boundingBox = Feature.tryCreate(json: geoJson["bbox"]) - if calculateBoundingBox, self.boundingBox == nil { - self.boundingBox = self.calculateBoundingBox() + if calculateBoundingBox { + self.updateBoundingBox() } if geoJson.count > 3 { @@ -148,13 +148,13 @@ public struct Feature: } } - public var asJson: [String: Any] { - var result: [String: Any] = [ + public var asJson: [String: Sendable] { + var result: [String: Sendable] = [ "type": GeoJsonType.feature.rawValue, "properties": properties, "geometry": geometry.asJson ] - if let id = id { + if let id { result["id"] = id.asJson } if let boundingBox = boundingBox { @@ -170,8 +170,18 @@ public struct Feature: extension Feature { + @discardableResult + public mutating func updateBoundingBox(onlyIfNecessary ifNecessary: Bool = true) -> BoundingBox? { + geometry.updateBoundingBox(onlyIfNecessary: ifNecessary) + + if boundingBox != nil && ifNecessary { return boundingBox } + + boundingBox = calculateBoundingBox() + return boundingBox + } + public func calculateBoundingBox() -> BoundingBox? { - return geometry.boundingBox ?? geometry.calculateBoundingBox() + geometry.boundingBox ?? geometry.calculateBoundingBox() } public func intersects(_ otherBoundingBox: BoundingBox) -> Bool { @@ -227,7 +237,7 @@ extension Feature { /// Returns a property by key. public func property(for key: String) -> T? { - return properties[key] as? T + properties[key] as? T } /// Set a property key/value pair. diff --git a/Sources/GISTools/GeoJson/FeatureCollection.swift b/Sources/GISTools/GeoJson/FeatureCollection.swift index 898d3e1..9a9b082 100644 --- a/Sources/GISTools/GeoJson/FeatureCollection.swift +++ b/Sources/GISTools/GeoJson/FeatureCollection.swift @@ -7,7 +7,7 @@ public struct FeatureCollection: { public var type: GeoJsonType { - return .featureCollection + .featureCollection } public var projection: Projection { @@ -39,7 +39,7 @@ public struct FeatureCollection: self.features = features if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -48,7 +48,7 @@ public struct FeatureCollection: self.features = geometries.compactMap { Feature($0) } if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -74,8 +74,8 @@ public struct FeatureCollection: return nil } - if calculateBoundingBox, self.boundingBox == nil { - self.boundingBox = self.calculateBoundingBox() + if calculateBoundingBox { + self.updateBoundingBox() } } @@ -102,8 +102,8 @@ public struct FeatureCollection: self.features = features self.boundingBox = FeatureCollection.tryCreate(json: geoJson["bbox"]) - if calculateBoundingBox, self.boundingBox == nil { - self.boundingBox = self.calculateBoundingBox() + if calculateBoundingBox { + self.updateBoundingBox() } if geoJson.count > 2 { @@ -115,8 +115,8 @@ public struct FeatureCollection: } } - public var asJson: [String: Any] { - var result: [String: Any] = [ + public var asJson: [String: Sendable] { + var result: [String: Sendable] = [ "type": GeoJsonType.featureCollection.rawValue, "features": features.map { $0.asJson } ] @@ -133,6 +133,20 @@ public struct FeatureCollection: extension FeatureCollection { + @discardableResult + public mutating func updateBoundingBox(onlyIfNecessary ifNecessary: Bool = true) -> BoundingBox? { + mapFeatures { feature in + var feature = feature + feature.updateBoundingBox(onlyIfNecessary: ifNecessary) + return feature + } + + if boundingBox != nil && ifNecessary { return boundingBox } + + boundingBox = calculateBoundingBox() + return boundingBox + } + public func calculateBoundingBox() -> BoundingBox? { let featureBoundingBoxes: [BoundingBox] = features.compactMap({ $0.boundingBox ?? $0.calculateBoundingBox() }) guard !featureBoundingBoxes.isEmpty else { return nil} @@ -201,7 +215,7 @@ extension FeatureCollection { } if boundingBox != nil { - boundingBox = calculateBoundingBox() + updateBoundingBox(onlyIfNecessary: false) } } @@ -214,17 +228,19 @@ extension FeatureCollection { features.append(feature) if boundingBox != nil { - boundingBox = calculateBoundingBox() + updateBoundingBox(onlyIfNecessary: false) } } /// Remove a Feature from the receiver. @discardableResult - public mutating func removeFeature(at index: Int) -> Feature { + public mutating func removeFeature(at index: Int) -> Feature? { + guard index >= 0, index < features.count else { return nil } + let removedFeature = features.remove(at: index) if boundingBox != nil { - boundingBox = calculateBoundingBox() + updateBoundingBox(onlyIfNecessary: false) } return removedFeature diff --git a/Sources/GISTools/GeoJson/GeoJson.swift b/Sources/GISTools/GeoJson/GeoJson.swift index ecb441a..bafca31 100644 --- a/Sources/GISTools/GeoJson/GeoJson.swift +++ b/Sources/GISTools/GeoJson/GeoJson.swift @@ -1,7 +1,7 @@ import Foundation /// All permitted GeoJSON types. -public enum GeoJsonType: String { +public enum GeoJsonType: String, Sendable { /// Marks an invalid object case invalid /// A GeoJSON Point object @@ -92,7 +92,7 @@ extension GeoJson { /// Any foreign member by key. public func foreignMember(for key: String) -> T? { - return foreignMembers[key] as? T + foreignMembers[key] as? T } /// Set a foreign member key/value pair. diff --git a/Sources/GISTools/GeoJson/GeoJsonConvertible.swift b/Sources/GISTools/GeoJson/GeoJsonConvertible.swift index 5599d70..4763e4e 100644 --- a/Sources/GISTools/GeoJson/GeoJsonConvertible.swift +++ b/Sources/GISTools/GeoJson/GeoJsonConvertible.swift @@ -54,7 +54,7 @@ public protocol GeoJsonWritable { /// Return the GeoJson object as a Swift dictionary. /// /// - important: Always projected to EPSG:4326, unless the receiver has no SRID. - var asJson: [String: Any] { get } + var asJson: [String: Sendable] { get } } @@ -99,8 +99,8 @@ extension Sequence where Self.Iterator.Element: GeoJsonWritable { /// Returns all elements as an array of JSON objects /// /// - important: Always projected to EPSG:4326, unless the coordinate has no SRID. - public var asJson: [[String: Any]] { - return self.map({ $0.asJson }) + public var asJson: [[String: Sendable]] { + self.map({ $0.asJson }) } } diff --git a/Sources/GISTools/GeoJson/GeometryCollection.swift b/Sources/GISTools/GeoJson/GeometryCollection.swift index d5f3556..a7f5e11 100644 --- a/Sources/GISTools/GeoJson/GeometryCollection.swift +++ b/Sources/GISTools/GeoJson/GeometryCollection.swift @@ -4,7 +4,7 @@ import Foundation public struct GeometryCollection: GeoJsonGeometry { public var type: GeoJsonType { - return .geometryCollection + .geometryCollection } public var projection: Projection { @@ -12,7 +12,7 @@ public struct GeometryCollection: GeoJsonGeometry { } /// The GeometryCollection's geometry objects. - public let geometries: [GeoJsonGeometry] + public private(set) var geometries: [GeoJsonGeometry] public var allCoordinates: [Coordinate3D] { geometries.flatMap(\.allCoordinates) @@ -32,7 +32,7 @@ public struct GeometryCollection: GeoJsonGeometry { self.geometries = geometries if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -49,8 +49,8 @@ public struct GeometryCollection: GeoJsonGeometry { self.geometries = geometries self.boundingBox = GeometryCollection.tryCreate(json: geoJson["bbox"]) - if calculateBoundingBox, self.boundingBox == nil { - self.boundingBox = self.calculateBoundingBox() + if calculateBoundingBox { + self.updateBoundingBox() } if geoJson.count > 2 { @@ -62,8 +62,8 @@ public struct GeometryCollection: GeoJsonGeometry { } } - public var asJson: [String: Any] { - var result: [String: Any] = [ + public var asJson: [String: Sendable] { + var result: [String: Sendable] = [ "type": GeoJsonType.geometryCollection.rawValue, "geometries": geometries.map { $0.asJson } ] @@ -80,6 +80,20 @@ public struct GeometryCollection: GeoJsonGeometry { extension GeometryCollection { + @discardableResult + public mutating func updateBoundingBox(onlyIfNecessary ifNecessary: Bool = true) -> BoundingBox? { + mapGeometries { geometry in + var geometry = geometry + geometry.updateBoundingBox(onlyIfNecessary: ifNecessary) + return geometry + } + + if boundingBox != nil && ifNecessary { return boundingBox } + + boundingBox = calculateBoundingBox() + return boundingBox + } + public func calculateBoundingBox() -> BoundingBox? { let geometryBoundingBoxes: [BoundingBox] = geometries.compactMap({ $0.boundingBox ?? $0.calculateBoundingBox() }) guard !geometryBoundingBoxes.isEmpty else { return nil } @@ -130,3 +144,70 @@ extension GeometryCollection { } } + +// MARK: - Geometries + +extension GeometryCollection { + + /// Insert a GeoJsonGeometry into the receiver. + /// + /// - note: `geometry` must be in the same projection as the receiver. + public mutating func insertGeometry(_ geometry: GeoJsonGeometry, atIndex index: Int) { + guard geometries.count == 0 || projection == geometry.projection else { return } + + if index < geometries.count { + geometries.insert(geometry, at: index) + } + else { + geometries.append(geometry) + } + + if boundingBox != nil { + updateBoundingBox(onlyIfNecessary: false) + } + } + + /// Append a GeoJsonGeometry to the receiver. + /// + /// - note: `geometry` must be in the same projection as the receiver. + public mutating func appendGeometry(_ geometry: GeoJsonGeometry) { + guard geometries.count == 0 || projection == geometry.projection else { return } + + geometries.append(geometry) + + if boundingBox != nil { + updateBoundingBox(onlyIfNecessary: false) + } + } + + /// Remove a GeoJsonGeometry from the receiver. + @discardableResult + public mutating func removeGeometry(at index: Int) -> GeoJsonGeometry? { + guard index >= 0, index < geometries.count else { return nil } + + let removedGeometry = geometries.remove(at: index) + + if boundingBox != nil { + updateBoundingBox(onlyIfNecessary: false) + } + + return removedGeometry + } + + /// Map Geometries in-place. + public mutating func mapGeometries(_ transform: (GeoJsonGeometry) -> GeoJsonGeometry) { + geometries = geometries.map(transform) + } + + /// Map Geometries in-place, removing *nil* values. + public mutating func compactMapGeometries(_ transform: (GeoJsonGeometry) -> GeoJsonGeometry?) { + geometries = geometries.compactMap(transform) + } + + /// Filter Geometries in-place. + public mutating func filterGeometries(_ isIncluded: (GeoJsonGeometry) -> Bool) { + geometries = geometries.filter(isIncluded) + } + + +} diff --git a/Sources/GISTools/GeoJson/LineSegment.swift b/Sources/GISTools/GeoJson/LineSegment.swift index 74fcb06..5074b8b 100644 --- a/Sources/GISTools/GeoJson/LineSegment.swift +++ b/Sources/GISTools/GeoJson/LineSegment.swift @@ -1,5 +1,5 @@ #if !os(Linux) -import CoreLocation + import CoreLocation #endif import Foundation @@ -17,16 +17,21 @@ public struct LineSegment: Sendable { /// The segment's second coordinate. public let second: Coordinate3D + /// The index within a `LineString`, if applicable. + public let index: Int? + /// Initialize a LineSegment with two coordinates. public init( first: Coordinate3D, second: Coordinate3D, + index: Int? = nil, calculateBoundingBox: Bool = false) { assert(first.projection == second.projection, "Can't have different projections") self.first = first self.second = second + self.index = index if calculateBoundingBox { self.boundingBox = self.calculateBoundingBox() @@ -39,7 +44,7 @@ extension LineSegment { /// The receiver's two coordinates. public var coordinates: [Coordinate3D] { - return [first, second] + [first, second] } } @@ -54,6 +59,7 @@ extension LineSegment: Projectable { return LineSegment( first: first.projected(to: newProjection), second: second.projected(to: newProjection), + index: index, calculateBoundingBox: (boundingBox != nil)) } @@ -68,18 +74,26 @@ extension LineSegment { public init( first: CLLocationCoordinate2D, second: CLLocationCoordinate2D, + index: Int? = nil, calculateBoundingBox: Bool = false) { - self.init(first: Coordinate3D(first), second: Coordinate3D(second), calculateBoundingBox: calculateBoundingBox) + self.init(first: Coordinate3D(first), + second: Coordinate3D(second), + index: index, + calculateBoundingBox: calculateBoundingBox) } /// Initialize a LineSegment with two locations. public init( first: CLLocation, second: CLLocation, + index: Int? = nil, calculateBoundingBox: Bool = false) { - self.init(first: Coordinate3D(first), second: Coordinate3D(second), calculateBoundingBox: calculateBoundingBox) + self.init(first: Coordinate3D(first), + second: Coordinate3D(second), + index: index, + calculateBoundingBox: calculateBoundingBox) } } @@ -90,7 +104,7 @@ extension LineSegment { extension LineSegment: BoundingBoxRepresentable { public func calculateBoundingBox() -> BoundingBox? { - return BoundingBox(coordinates: coordinates) + BoundingBox(coordinates: coordinates) } public func intersects(_ otherBoundingBox: BoundingBox) -> Bool { @@ -131,12 +145,12 @@ extension LineSegment: BoundingBoxRepresentable { extension LineSegment: Equatable { - public static func ==( + public static func == ( lhs: LineSegment, rhs: LineSegment) -> Bool { - return lhs.first == rhs.first + lhs.first == rhs.first && lhs.second == rhs.second } diff --git a/Sources/GISTools/GeoJson/LineString.swift b/Sources/GISTools/GeoJson/LineString.swift index 7704b45..638528f 100644 --- a/Sources/GISTools/GeoJson/LineString.swift +++ b/Sources/GISTools/GeoJson/LineString.swift @@ -10,7 +10,7 @@ public struct LineString: { public var type: GeoJsonType { - return .lineString + .lineString } public var projection: Projection { @@ -29,7 +29,7 @@ public struct LineString: public var foreignMembers: [String: Sendable] = [:] public var lineStrings: [LineString] { - return [self] + [self] } public init() { @@ -48,7 +48,7 @@ public struct LineString: self.coordinates = coordinates if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -57,7 +57,7 @@ public struct LineString: self.coordinates = lineSegment.coordinates if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -66,7 +66,7 @@ public struct LineString: guard !lineSegments.isEmpty else { return nil } var coordinates: [Coordinate3D] = [] - for (previous, current) in lineSegments.overlappingPairs() { + for (previous, current, _) in lineSegments.overlappingPairs() { if coordinates.isEmpty { coordinates.append(previous.first) if previous.second != previous.first { @@ -102,8 +102,8 @@ public struct LineString: self.coordinates = coordinates self.boundingBox = LineString.tryCreate(json: geoJson["bbox"]) - if calculateBoundingBox, self.boundingBox == nil { - self.boundingBox = self.calculateBoundingBox() + if calculateBoundingBox { + self.updateBoundingBox() } if geoJson.count > 2 { @@ -115,8 +115,8 @@ public struct LineString: } } - public var asJson: [String: Any] { - var result: [String: Any] = [ + public var asJson: [String: Sendable] { + var result: [String: Sendable] = [ "type": GeoJsonType.lineString.rawValue, "coordinates": coordinates.map { $0.asJson } ] @@ -135,12 +135,12 @@ extension LineString { /// The receiver's first coordinate. public var firstCoordinate: Coordinate3D? { - return coordinates.first + coordinates.first } /// The receiver's last coordinate. public var lastCoordinate: Coordinate3D? { - return coordinates.last + coordinates.last } } @@ -184,7 +184,7 @@ extension LineString { extension LineString { public func calculateBoundingBox() -> BoundingBox? { - return BoundingBox(coordinates: coordinates) + BoundingBox(coordinates: coordinates) } public func intersects(_ otherBoundingBox: BoundingBox) -> Bool { @@ -202,7 +202,9 @@ extension LineString { let maxLatitude = otherBoundingBox.northEast.latitude for index in 0 ..< coordinates.count - 1 { - let segment = LineSegment(first: coordinates[index], second: coordinates[index + 1]) + let segment = LineSegment( + first: coordinates[index], + second: coordinates[index + 1]) // The bbox contains one of the end points if otherBoundingBox.contains(segment.first) diff --git a/Sources/GISTools/GeoJson/MultiLineString.swift b/Sources/GISTools/GeoJson/MultiLineString.swift index 527e743..70b38f0 100644 --- a/Sources/GISTools/GeoJson/MultiLineString.swift +++ b/Sources/GISTools/GeoJson/MultiLineString.swift @@ -10,7 +10,7 @@ public struct MultiLineString: { public var type: GeoJsonType { - return .multiLineString + .multiLineString } public var projection: Projection { @@ -18,7 +18,14 @@ public struct MultiLineString: } /// The MultiLineString's coordinates. - public let coordinates: [[Coordinate3D]] + public private(set) var coordinates: [[Coordinate3D]] { + get { + lineStrings.map { $0.coordinates } + } + set { + lineStrings = newValue.compactMap({ LineString($0) }) + } + } public var allCoordinates: [Coordinate3D] { coordinates.flatMap({ $0 }) @@ -28,12 +35,10 @@ public struct MultiLineString: public var foreignMembers: [String: Sendable] = [:] - public var lineStrings: [LineString] { - return coordinates.compactMap { LineString($0) } - } + public private(set) var lineStrings: [LineString] = [] public init() { - self.coordinates = [] + self.lineStrings = [] } /// Try to initialize a MultiLineString with some coordinates. @@ -50,7 +55,7 @@ public struct MultiLineString: self.coordinates = coordinates if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -63,10 +68,10 @@ public struct MultiLineString: /// Try to initialize a MultiLineString with some LineStrings, don't check the coordinates for validity. public init(unchecked lineStrings: [LineString], calculateBoundingBox: Bool = false) { - self.coordinates = lineStrings.map { $0.coordinates } + self.lineStrings = lineStrings if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -82,7 +87,7 @@ public struct MultiLineString: self.coordinates = lineSegments.map({ $0.coordinates }) if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -99,8 +104,8 @@ public struct MultiLineString: self.coordinates = coordinates self.boundingBox = MultiLineString.tryCreate(json: geoJson["bbox"]) - if calculateBoundingBox, self.boundingBox == nil { - self.boundingBox = self.calculateBoundingBox() + if calculateBoundingBox { + self.updateBoundingBox() } if geoJson.count > 2 { @@ -112,8 +117,8 @@ public struct MultiLineString: } } - public var asJson: [String: Any] { - var result: [String: Any] = [ + public var asJson: [String: Sendable] { + var result: [String: Sendable] = [ "type": GeoJsonType.multiLineString.rawValue, "coordinates": coordinates.map { $0.map { $0.asJson } } ] @@ -132,12 +137,12 @@ extension MultiLineString { /// The receiver's first coordinate. public var firstCoordinate: Coordinate3D? { - return coordinates.first?.first + coordinates.first?.first } /// The receiver's last coordinate. public var lastCoordinate: Coordinate3D? { - return coordinates.last?.last + coordinates.last?.last } } @@ -180,6 +185,20 @@ extension MultiLineString { extension MultiLineString { + @discardableResult + public mutating func updateBoundingBox(onlyIfNecessary ifNecessary: Bool = true) -> BoundingBox? { + mapLinestrings { linestring in + var linestring = linestring + linestring.updateBoundingBox(onlyIfNecessary: ifNecessary) + return linestring + } + + if boundingBox != nil && ifNecessary { return boundingBox } + + boundingBox = calculateBoundingBox() + return boundingBox + } + public func calculateBoundingBox() -> BoundingBox? { let flattened: [Coordinate3D] = Array(coordinates.joined()) return BoundingBox(coordinates: flattened) @@ -209,3 +228,69 @@ extension MultiLineString: Equatable { } } + +// MARK: - LineStrings + +extension MultiLineString { + + /// Insert a LineString into the receiver. + /// + /// - note: `linestring` must be in the same projection as the receiver. + public mutating func insertLineString(_ lineString: LineString, atIndex index: Int) { + guard lineStrings.count == 0 || projection == lineString.projection else { return } + + if index < lineStrings.count { + lineStrings.insert(lineString, at: index) + } + else { + lineStrings.append(lineString) + } + + if boundingBox != nil { + updateBoundingBox(onlyIfNecessary: false) + } + } + + /// Append a LineString to the receiver. + /// + /// - note: `linestring` must be in the same projection as the receiver. + public mutating func appendLineString(_ lineString: LineString) { + guard lineStrings.count == 0 || projection == lineString.projection else { return } + + lineStrings.append(lineString) + + if boundingBox != nil { + updateBoundingBox(onlyIfNecessary: false) + } + } + + /// Remove a LineString from the receiver. + @discardableResult + public mutating func removeLineString(at index: Int) -> LineString? { + guard index >= 0, index < lineStrings.count else { return nil } + + let removedGeometry = lineStrings.remove(at: index) + + if boundingBox != nil { + updateBoundingBox(onlyIfNecessary: false) + } + + return removedGeometry + } + + /// Map Linestrings in-place. + public mutating func mapLinestrings(_ transform: (LineString) -> LineString) { + lineStrings = lineStrings.map(transform) + } + + /// Map Linestrings in-place, removing *nil* values. + public mutating func compactMapLinestrings(_ transform: (LineString) -> LineString?) { + lineStrings = lineStrings.compactMap(transform) + } + + /// Filter Linestrings in-place. + public mutating func filterLinestrings(_ isIncluded: (LineString) -> Bool) { + lineStrings = lineStrings.filter(isIncluded) + } + +} diff --git a/Sources/GISTools/GeoJson/MultiPoint.swift b/Sources/GISTools/GeoJson/MultiPoint.swift index 78a1aaa..16011bb 100644 --- a/Sources/GISTools/GeoJson/MultiPoint.swift +++ b/Sources/GISTools/GeoJson/MultiPoint.swift @@ -10,7 +10,7 @@ public struct MultiPoint: { public var type: GeoJsonType { - return .multiPoint + .multiPoint } public var projection: Projection { @@ -18,7 +18,14 @@ public struct MultiPoint: } /// The receiver's coordinates. - public let coordinates: [Coordinate3D] + public private(set) var coordinates: [Coordinate3D] { + get { + points.map { $0.coordinate } + } + set { + points = newValue.compactMap({ Point($0) }) + } + } public var allCoordinates: [Coordinate3D] { coordinates @@ -28,12 +35,10 @@ public struct MultiPoint: public var foreignMembers: [String: Sendable] = [:] - public var points: [Point] { - return coordinates.map { Point($0) } - } + public private(set) var points: [Point] = [] public init() { - self.coordinates = [] + self.points = [] } /// Try to initialize a MultiPoint with some coordinates. @@ -48,7 +53,7 @@ public struct MultiPoint: self.coordinates = coordinates if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -61,10 +66,10 @@ public struct MultiPoint: /// Try to initialize a MultiPoint with some Points, don't check the coordinates for validity. public init(unchecked points: [Point], calculateBoundingBox: Bool = false) { - self.coordinates = points.map { $0.coordinate } + self.points = points if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -81,8 +86,8 @@ public struct MultiPoint: self.coordinates = coordinates self.boundingBox = MultiPoint.tryCreate(json: geoJson["bbox"]) - if calculateBoundingBox, self.boundingBox == nil { - self.boundingBox = self.calculateBoundingBox() + if calculateBoundingBox { + self.updateBoundingBox() } if geoJson.count > 2 { @@ -94,8 +99,8 @@ public struct MultiPoint: } } - public var asJson: [String: Any] { - var result: [String: Any] = [ + public var asJson: [String: Sendable] { + var result: [String: Sendable] = [ "type": GeoJsonType.multiPoint.rawValue, "coordinates": coordinates.map { $0.asJson } ] @@ -148,8 +153,22 @@ extension MultiPoint { extension MultiPoint { + @discardableResult + public mutating func updateBoundingBox(onlyIfNecessary ifNecessary: Bool = true) -> BoundingBox? { + mapPoints { point in + var point = point + point.updateBoundingBox(onlyIfNecessary: ifNecessary) + return point + } + + if boundingBox != nil && ifNecessary { return boundingBox } + + boundingBox = calculateBoundingBox() + return boundingBox + } + public func calculateBoundingBox() -> BoundingBox? { - return BoundingBox(coordinates: coordinates) + BoundingBox(coordinates: coordinates) } public func intersects(_ otherBoundingBox: BoundingBox) -> Bool { @@ -176,3 +195,69 @@ extension MultiPoint: Equatable { } } + +// MARK: - Points + +extension MultiPoint { + + /// Insert a Point into the receiver. + /// + /// - note: `point` must be in the same projection as the receiver. + public mutating func insertPoint(_ point: Point, atIndex index: Int) { + guard points.count == 0 || projection == point.projection else { return } + + if index < points.count { + points.insert(point, at: index) + } + else { + points.append(point) + } + + if boundingBox != nil { + updateBoundingBox(onlyIfNecessary: false) + } + } + + /// Append a Point to the receiver. + /// + /// - note: `point` must be in the same projection as the receiver. + public mutating func appendPoint(_ point: Point) { + guard points.count == 0 || projection == point.projection else { return } + + points.append(point) + + if boundingBox != nil { + updateBoundingBox(onlyIfNecessary: false) + } + } + + /// Remove a Point from the receiver. + @discardableResult + public mutating func removePoint(at index: Int) -> Point? { + guard index >= 0, index < points.count else { return nil } + + let removedGeometry = points.remove(at: index) + + if boundingBox != nil { + updateBoundingBox(onlyIfNecessary: false) + } + + return removedGeometry + } + + /// Map Points in-place. + public mutating func mapPoints(_ transform: (Point) -> Point) { + points = points.map(transform) + } + + /// Map Points in-place, removing *nil* values. + public mutating func compactMapPoints(_ transform: (Point) -> Point?) { + points = points.compactMap(transform) + } + + /// Filter Points in-place. + public mutating func filterPoints(_ isIncluded: (Point) -> Bool) { + points = points.filter(isIncluded) + } + +} diff --git a/Sources/GISTools/GeoJson/MultiPolygon.swift b/Sources/GISTools/GeoJson/MultiPolygon.swift index 6d63d68..ea6625e 100644 --- a/Sources/GISTools/GeoJson/MultiPolygon.swift +++ b/Sources/GISTools/GeoJson/MultiPolygon.swift @@ -10,7 +10,7 @@ public struct MultiPolygon: { public var type: GeoJsonType { - return .multiPolygon + .multiPolygon } public var projection: Projection { @@ -18,7 +18,14 @@ public struct MultiPolygon: } /// The receiver's coordinates. - public let coordinates: [[[Coordinate3D]]] + public private(set) var coordinates: [[[Coordinate3D]]] { + get { + polygons.map { $0.coordinates } + } + set { + polygons = newValue.compactMap({ Polygon($0) }) + } + } public var allCoordinates: [Coordinate3D] { coordinates.flatMap({ $0 }).flatMap({ $0 }) @@ -28,12 +35,10 @@ public struct MultiPolygon: public var foreignMembers: [String: Sendable] = [:] - public var polygons: [Polygon] { - return coordinates.compactMap { Polygon($0) } - } + public private(set) var polygons: [Polygon] = [] public init() { - self.coordinates = [] + self.polygons = [] } /// Try to initialize a MultiPolygon with some coordinates. @@ -51,7 +56,7 @@ public struct MultiPolygon: self.coordinates = coordinates if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -64,10 +69,10 @@ public struct MultiPolygon: /// Try to initialize a MultiPolygon with some Polygons, don't check the coordinates for validity. public init(unchecked polygons: [Polygon], calculateBoundingBox: Bool = false) { - self.coordinates = polygons.map { $0.coordinates } + self.polygons = polygons if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -84,8 +89,8 @@ public struct MultiPolygon: self.coordinates = coordinates self.boundingBox = MultiPolygon.tryCreate(json: geoJson["bbox"]) - if calculateBoundingBox, self.boundingBox == nil { - self.boundingBox = self.calculateBoundingBox() + if calculateBoundingBox { + self.updateBoundingBox() } if geoJson.count > 2 { @@ -97,8 +102,8 @@ public struct MultiPolygon: } } - public var asJson: [String: Any] { - var result: [String: Any] = [ + public var asJson: [String: Sendable] { + var result: [String: Sendable] = [ "type": GeoJsonType.multiPolygon.rawValue, "coordinates": coordinates.map { $0.map { $0.map { $0.asJson } } } ] @@ -151,8 +156,22 @@ extension MultiPolygon { extension MultiPolygon { + @discardableResult + public mutating func updateBoundingBox(onlyIfNecessary ifNecessary: Bool = true) -> BoundingBox? { + mapPolygons { polygon in + var polygon = polygon + polygon.updateBoundingBox(onlyIfNecessary: ifNecessary) + return polygon + } + + if boundingBox != nil && ifNecessary { return boundingBox } + + boundingBox = calculateBoundingBox() + return boundingBox + } + public func calculateBoundingBox() -> BoundingBox? { - return BoundingBox(coordinates: Array(coordinates.map({ $0.first ?? [] }).joined())) + BoundingBox(coordinates: Array(coordinates.map({ $0.first ?? [] }).joined())) } public func intersects(_ otherBoundingBox: BoundingBox) -> Bool { @@ -180,3 +199,69 @@ extension MultiPolygon: Equatable { } } + +// MARK: - Polygons + +extension MultiPolygon { + + /// Insert a Polygon into the receiver. + /// + /// - note: `polygon` must be in the same projection as the receiver. + public mutating func insertPolygon(_ polygon: Polygon, atIndex index: Int) { + guard polygons.count == 0 || projection == polygon.projection else { return } + + if index < polygons.count { + polygons.insert(polygon, at: index) + } + else { + polygons.append(polygon) + } + + if boundingBox != nil { + updateBoundingBox(onlyIfNecessary: false) + } + } + + /// Append a Polygon to the receiver. + /// + /// - note: `polygon` must be in the same projection as the receiver. + public mutating func appendPolygon(_ polygon: Polygon) { + guard polygons.count == 0 || projection == polygon.projection else { return } + + polygons.append(polygon) + + if boundingBox != nil { + updateBoundingBox(onlyIfNecessary: false) + } + } + + /// Remove a Polygon from the receiver. + @discardableResult + public mutating func removePolygon(at index: Int) -> Polygon? { + guard index >= 0, index < polygons.count else { return nil } + + let removedGeometry = polygons.remove(at: index) + + if boundingBox != nil { + updateBoundingBox(onlyIfNecessary: false) + } + + return removedGeometry + } + + /// Map Polygons in-place. + public mutating func mapPolygons(_ transform: (Polygon) -> Polygon) { + polygons = polygons.map(transform) + } + + /// Map Polygons in-place, removing *nil* values. + public mutating func compactMapPolygons(_ transform: (Polygon) -> Polygon?) { + polygons = polygons.compactMap(transform) + } + + /// Filter Polygons in-place. + public mutating func filterPolygons(_ isIncluded: (Polygon) -> Bool) { + polygons = polygons.filter(isIncluded) + } + +} diff --git a/Sources/GISTools/GeoJson/Point.swift b/Sources/GISTools/GeoJson/Point.swift index 7b17d30..744545b 100644 --- a/Sources/GISTools/GeoJson/Point.swift +++ b/Sources/GISTools/GeoJson/Point.swift @@ -7,7 +7,7 @@ import Foundation public struct Point: PointGeometry { public var type: GeoJsonType { - return .point + .point } public var projection: Projection { @@ -26,7 +26,7 @@ public struct Point: PointGeometry { public var foreignMembers: [String: Sendable] = [:] public var points: [Point] { - return [self] + [self] } /// Initialize a Point with a coordinate. @@ -34,7 +34,7 @@ public struct Point: PointGeometry { self.coordinate = coordinate if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -51,8 +51,8 @@ public struct Point: PointGeometry { self.coordinate = coordinate self.boundingBox = Point.tryCreate(json: geoJson["bbox"]) - if calculateBoundingBox, self.boundingBox == nil { - self.boundingBox = self.calculateBoundingBox() + if calculateBoundingBox { + self.updateBoundingBox() } if geoJson.count > 2 { @@ -64,8 +64,8 @@ public struct Point: PointGeometry { } } - public var asJson: [String: Any] { - var result: [String: Any] = [ + public var asJson: [String: Sendable] { + var result: [String: Sendable] = [ "type": GeoJsonType.point.rawValue, "coordinates": coordinate.asJson ] @@ -119,11 +119,11 @@ extension Point { extension Point { public func calculateBoundingBox() -> BoundingBox? { - return BoundingBox(coordinates: [coordinate]) + BoundingBox(coordinates: [coordinate]) } public func intersects(_ otherBoundingBox: BoundingBox) -> Bool { - return otherBoundingBox.contains(coordinate) + otherBoundingBox.contains(coordinate) } } diff --git a/Sources/GISTools/GeoJson/Polygon.swift b/Sources/GISTools/GeoJson/Polygon.swift index 5032cc0..8164be3 100644 --- a/Sources/GISTools/GeoJson/Polygon.swift +++ b/Sources/GISTools/GeoJson/Polygon.swift @@ -10,7 +10,7 @@ public struct Polygon: { public var type: GeoJsonType { - return .polygon + .polygon } public var projection: Projection { @@ -29,7 +29,7 @@ public struct Polygon: public var foreignMembers: [String: Sendable] = [:] public var polygons: [Polygon] { - return [self] + [self] } /// The receiver's outer ring. @@ -46,7 +46,7 @@ public struct Polygon: /// All of the receiver's rings (outer + inner). public var rings: [Ring] { - return coordinates.compactMap { Ring($0) } + coordinates.compactMap { Ring($0) } } public init() { @@ -67,7 +67,7 @@ public struct Polygon: self.coordinates = coordinates if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -83,7 +83,7 @@ public struct Polygon: self.coordinates = rings.map { $0.coordinates } if calculateBoundingBox { - self.boundingBox = self.calculateBoundingBox() + self.updateBoundingBox() } } @@ -100,8 +100,8 @@ public struct Polygon: self.coordinates = coordinates self.boundingBox = Polygon.tryCreate(json: geoJson["bbox"]) - if calculateBoundingBox && self.boundingBox == nil { - self.boundingBox = self.calculateBoundingBox() + if calculateBoundingBox { + self.updateBoundingBox() } if geoJson.count > 2 { @@ -113,8 +113,8 @@ public struct Polygon: } } - public var asJson: [String: Any] { - var result: [String: Any] = [ + public var asJson: [String: Sendable] { + var result: [String: Sendable] = [ "type": GeoJsonType.polygon.rawValue, "coordinates": coordinates.map { $0.map { $0.asJson } } ] diff --git a/Sources/GISTools/Other/MapTile.swift b/Sources/GISTools/Other/MapTile.swift index 41ad3ce..e009be4 100644 --- a/Sources/GISTools/Other/MapTile.swift +++ b/Sources/GISTools/Other/MapTile.swift @@ -118,10 +118,10 @@ public struct MapTile: CustomStringConvertible, Sendable { let pixelX: Double = (Double(x) + 0.5) * GISTool.tileSideLength let pixelY: Double = (Double(y) + 0.5) * GISTool.tileSideLength - return GISTool.convertToCoordinate( + return GISTool.coordinate( fromPixelX: pixelX, pixelY: pixelY, - atZoom: z, + zoom: z, tileSideLength: GISTool.tileSideLength, projection: projection) } @@ -137,16 +137,16 @@ public struct MapTile: CustomStringConvertible, Sendable { // Flip y let y = (1 << z) - 1 - y - let southWest = GISTool.convertToCoordinate( + let southWest = GISTool.coordinate( fromPixelX: Double(x) * GISTool.tileSideLength, pixelY: Double(y) * GISTool.tileSideLength, - atZoom: z, + zoom: z, tileSideLength: GISTool.tileSideLength, projection: projection) - let northEast = GISTool.convertToCoordinate( + let northEast = GISTool.coordinate( fromPixelX: Double(x + 1) * GISTool.tileSideLength, pixelY: Double(y + 1) * GISTool.tileSideLength, - atZoom: z, + zoom: z, tileSideLength: GISTool.tileSideLength, projection: projection) @@ -210,7 +210,7 @@ public struct MapTile: CustomStringConvertible, Sendable { // MARK: - Conversion pixel to meters /// Converts pixel coordinates in a given zoom level to a coordinate. - @available(*, deprecated, renamed: "GISTool.convertToCoordinate", message: "This method has been moved to the GISTool namespace") + @available(*, deprecated, renamed: "GISTool.coordinate(fromPixelX:pixelY:zoom:tileSideLength:projection:)", message: "This method has been moved to the GISTool namespace") public static func pixelCoordinate( pixelX: Double, pixelY: Double, @@ -219,10 +219,10 @@ public struct MapTile: CustomStringConvertible, Sendable { projection: Projection = .epsg4326) -> Coordinate3D { - GISTool.convertToCoordinate( + GISTool.coordinate( fromPixelX: pixelX, pixelY: pixelY, - atZoom: zoom, + zoom: zoom, tileSideLength: tileSideLength, projection: projection) } @@ -247,18 +247,20 @@ public struct MapTile: CustomStringConvertible, Sendable { // MARK: - Private - private static func normalizeCoordinate(_ coordinate: Coordinate3D) -> Coordinate3D { - var coordinate = coordinate + static func normalizeCoordinate(_ coordinate: Coordinate3D) -> Coordinate3D { + var (latitude, longitude) = (coordinate.latitude, coordinate.longitude) - if coordinate.longitude > 180.0 { - coordinate.longitude -= 360.0 + if longitude > 180.0 { + longitude -= 360.0 } - coordinate.longitude /= 360.0 - coordinate.longitude += 0.5 - coordinate.latitude = 0.5 - ((log(tan((Double.pi / 4) + ((0.5 * Double.pi * coordinate.latitude) / 180.0))) / Double.pi) / 2.0) + latitude = min(85.05112877980659, max(-85.05112877980659, latitude)) - return coordinate + longitude /= 360.0 + longitude += 0.5 + latitude = 0.5 - ((log(tan((Double.pi / 4) + ((0.5 * Double.pi * latitude) / 180.0))) / Double.pi) / 2.0) + + return Coordinate3D(latitude: latitude, longitude: longitude) } } @@ -266,3 +268,34 @@ public struct MapTile: CustomStringConvertible, Sendable { // MARK: - Equatable, Hashable extension MapTile: Equatable, Hashable {} + +// MARK: - Coordinate shortcuts + +extension Coordinate3D { + + /// The receiver as a ``MapTile``. + public func mapTile(atZoom zoom: Int) -> MapTile { + MapTile(coordinate: self, atZoom: zoom) + } + +} + +#if !os(Linux) +extension CLLocation { + + /// The receiver as a ``MapTile``. + public func mapTile(atZoom zoom: Int) -> MapTile { + MapTile(coordinate: Coordinate3D(self), atZoom: zoom) + } + +} + +extension CLLocationCoordinate2D { + + /// The receiver as a ``MapTile``. + public func mapTile(atZoom zoom: Int) -> MapTile { + MapTile(coordinate: Coordinate3D(self), atZoom: zoom) + } + +} +#endif diff --git a/Tests/GISToolsTests/Algorithms/ConversionTests.swift b/Tests/GISToolsTests/Algorithms/ConversionTests.swift index 9ce12f8..0d606da 100644 --- a/Tests/GISToolsTests/Algorithms/ConversionTests.swift +++ b/Tests/GISToolsTests/Algorithms/ConversionTests.swift @@ -32,7 +32,7 @@ final class ConversionTests: XCTestCase { func testMetersAtLatitude() throws { let meters = 10000.0 let degreesLatitude1 = try XCTUnwrap(GISTool.convert(length: meters, from: .meters, to: .degrees)) - let degreesLatitude2 = GISTool.convertToDegrees(fromMeters: meters, atLatitude: 0.0).latitudeDegrees + let degreesLatitude2 = GISTool.degrees(fromMeters: meters, atLatitude: 0.0).latitudeDegrees XCTAssertEqual(degreesLatitude1, degreesLatitude2, accuracy: 0.00000001) } diff --git a/Tests/GISToolsTests/Algorithms/LengthTests.swift b/Tests/GISToolsTests/Algorithms/LengthTests.swift index 5002b8e..ffd4eef 100644 --- a/Tests/GISToolsTests/Algorithms/LengthTests.swift +++ b/Tests/GISToolsTests/Algorithms/LengthTests.swift @@ -11,9 +11,13 @@ final class LengthTests: XCTestCase { let coordinate2 = Coordinate3D(latitude: 39.123, longitude: -75.534) let expectedLength: CLLocationDistance = 97129.22118967835 - let lineSegment = LineSegment(first: coordinate1, second: coordinate2) + let lineSegment = LineSegment( + first: coordinate1, + second: coordinate2, + index: 0) XCTAssertEqual(lineSegment.length, expectedLength, accuracy: 0.000001) + XCTAssertEqual(lineSegment.index, 0) } } diff --git a/Tests/GISToolsTests/Algorithms/LineChunkTests.swift b/Tests/GISToolsTests/Algorithms/LineChunkTests.swift index ee335ac..85294a7 100644 --- a/Tests/GISToolsTests/Algorithms/LineChunkTests.swift +++ b/Tests/GISToolsTests/Algorithms/LineChunkTests.swift @@ -14,9 +14,7 @@ final class LineChunkTests: XCTestCase { ])! func testLineChunkShort() { - let segmentLength: CLLocationDistance = GISTool.convert(length: 5.0, from: .miles, to: .meters)! - - let chunks = lineString.chunked(segmentLength: segmentLength).lineStrings + let chunks = lineString.chunked(segmentLength: 5.miles).lineStrings XCTAssertEqual(chunks.count, 7) let some = chunks[3] @@ -32,13 +30,23 @@ final class LineChunkTests: XCTestCase { } func testLineChunkLong() { - let segmentLength: CLLocationDistance = GISTool.convert(length: 50.0, from: .miles, to: .meters)! - - let chunks = lineString.chunked(segmentLength: segmentLength).lineStrings + let chunks = lineString.chunked(segmentLength: 50.miles).lineStrings XCTAssertEqual(chunks.count, 1) XCTAssertEqual(chunks[0], lineString) } + func testLineChunkDropIntermediates() { + let chunks = lineString.chunked(segmentLength: lineString.length / 2).lineStrings + XCTAssertEqual(chunks.count, 2) + XCTAssertEqual(chunks[0].coordinates.count, 3) + XCTAssertEqual(chunks[1].coordinates.count, 3) + + let chunksSimplified = lineString.chunked(segmentLength: lineString.length / 2, dropIntermediateCoordinates: true).lineStrings + XCTAssertEqual(chunksSimplified.count, 2) + XCTAssertEqual(chunksSimplified[0].coordinates.count, 2) + XCTAssertEqual(chunksSimplified[1].coordinates.count, 2) + } + func testEvenlyDivided() { let a = Coordinate3D.zero let b = a.destination(distance: 100.0, bearing: 90.0) @@ -48,7 +56,7 @@ final class LineChunkTests: XCTestCase { XCTAssertEqual(line.allCoordinates.count, 2) XCTAssertEqual(dividedLine.allCoordinates.count, 101) - for (first, second) in dividedLine.allCoordinates.overlappingPairs() { + for (first, second, _) in dividedLine.allCoordinates.overlappingPairs() { guard let second else { break } XCTAssertEqual(first.distance(from: second), 1.0, accuracy: 0.0001) } diff --git a/Tests/GISToolsTests/Algorithms/LineIntersectionTests.swift b/Tests/GISToolsTests/Algorithms/LineIntersectionTests.swift index 8747a24..2d989cb 100644 --- a/Tests/GISToolsTests/Algorithms/LineIntersectionTests.swift +++ b/Tests/GISToolsTests/Algorithms/LineIntersectionTests.swift @@ -144,16 +144,22 @@ final class LineIntersectionTests: XCTestCase { } func testBooleanIntersection() { - let segment1 = LineSegment(first: Coordinate3D(latitude: 1.0, longitude: 1.0), second: Coordinate3D(latitude: 1.0, longitude: 10.0)) - let segment2 = LineSegment(first: Coordinate3D(latitude: 2.0, longitude: 1.0), second: Coordinate3D(latitude: 2.0, longitude: 10.0)) + let segment1 = LineSegment(first: Coordinate3D(latitude: 1.0, longitude: 1.0), + second: Coordinate3D(latitude: 1.0, longitude: 10.0)) + let segment2 = LineSegment(first: Coordinate3D(latitude: 2.0, longitude: 1.0), + second: Coordinate3D(latitude: 2.0, longitude: 10.0)) XCTAssertFalse(segment1.intersects(segment2)) - let segment3 = LineSegment(first: Coordinate3D(latitude: 0.0, longitude: 10.0), second: Coordinate3D(latitude: 10.0, longitude: 0.0)) - let segment4 = LineSegment(first: Coordinate3D(latitude: 0.0, longitude: 0.0), second: Coordinate3D(latitude: 10.0, longitude: 10.0)) + let segment3 = LineSegment(first: Coordinate3D(latitude: 0.0, longitude: 10.0), + second: Coordinate3D(latitude: 10.0, longitude: 0.0)) + let segment4 = LineSegment(first: Coordinate3D(latitude: 0.0, longitude: 0.0), + second: Coordinate3D(latitude: 10.0, longitude: 10.0)) XCTAssertTrue(segment3.intersects(segment4)) - let segment5 = LineSegment(first: Coordinate3D(latitude: -5.0, longitude: -5.0), second: Coordinate3D(latitude: 0.0, longitude: 0.0)) - let segment6 = LineSegment(first: Coordinate3D(latitude: 1.0, longitude: 1.0), second: Coordinate3D(latitude: 10.0, longitude: 10.0)) + let segment5 = LineSegment(first: Coordinate3D(latitude: -5.0, longitude: -5.0), + second: Coordinate3D(latitude: 0.0, longitude: 0.0)) + let segment6 = LineSegment(first: Coordinate3D(latitude: 1.0, longitude: 1.0), + second: Coordinate3D(latitude: 10.0, longitude: 10.0)) XCTAssertFalse(segment5.intersects(segment6)) } diff --git a/Tests/GISToolsTests/Algorithms/LineSegmentsTests.swift b/Tests/GISToolsTests/Algorithms/LineSegmentsTests.swift index 4d9844e..0ae113a 100644 --- a/Tests/GISToolsTests/Algorithms/LineSegmentsTests.swift +++ b/Tests/GISToolsTests/Algorithms/LineSegmentsTests.swift @@ -12,14 +12,19 @@ final class LineSegmentsTests: XCTestCase { Coordinate3D(latitude: 0.0, longitude: 0.0), ] let lineSegments = [ - LineSegment(first: Coordinate3D(latitude: 0.0, longitude: 0.0), second: Coordinate3D(latitude: 10.0, longitude: 0.0)), - LineSegment(first: Coordinate3D(latitude: 10.0, longitude: 0.0), second: Coordinate3D(latitude: 10.0, longitude: 10.0)), - LineSegment(first: Coordinate3D(latitude: 10.0, longitude: 10.0), second: Coordinate3D(latitude: 0.0, longitude: 10.0)), - LineSegment(first: Coordinate3D(latitude: 0.0, longitude: 10.0), second: Coordinate3D(latitude: 0.0, longitude: 0.0)), + LineSegment(first: Coordinate3D(latitude: 0.0, longitude: 0.0), + second: Coordinate3D(latitude: 10.0, longitude: 0.0)), + LineSegment(first: Coordinate3D(latitude: 10.0, longitude: 0.0), + second: Coordinate3D(latitude: 10.0, longitude: 10.0)), + LineSegment(first: Coordinate3D(latitude: 10.0, longitude: 10.0), + second: Coordinate3D(latitude: 0.0, longitude: 10.0)), + LineSegment(first: Coordinate3D(latitude: 0.0, longitude: 10.0), + second: Coordinate3D(latitude: 0.0, longitude: 0.0)), ] let lineString = try XCTUnwrap(LineString(coordinates)) XCTAssertEqual(lineString.lineSegments, lineSegments) + XCTAssertEqual(lineString.lineSegments.map(\.index), [0, 1, 2, 3]) } } diff --git a/Tests/GISToolsTests/Algorithms/RewindTests.swift b/Tests/GISToolsTests/Algorithms/RewindTests.swift new file mode 100644 index 0000000..000791b --- /dev/null +++ b/Tests/GISToolsTests/Algorithms/RewindTests.swift @@ -0,0 +1,106 @@ +@testable import GISTools +import XCTest + +final class RewindTests: XCTestCase { + + private static let lineStringClockwise = LineString([ + Coordinate3D(latitude: -20.0, longitude: 122.0), + Coordinate3D(latitude: -15.0, longitude: 126.0), + Coordinate3D(latitude: -14.0, longitude: 129.0), + Coordinate3D(latitude: -15.0, longitude: 134.0), + Coordinate3D(latitude: -20.0, longitude: 138.0), + Coordinate3D(latitude: -25.0, longitude: 139.0), + Coordinate3D(latitude: -30.0, longitude: 134.0), + Coordinate3D(latitude: -30.0, longitude: 131.0), + Coordinate3D(latitude: -29.0, longitude: 128.0), + Coordinate3D(latitude: -27.0, longitude: 124.0), + ])! + + private static let lineStringCounterClockwise = LineString([ + Coordinate3D(latitude: -27.0, longitude: 124.0), + Coordinate3D(latitude: -29.0, longitude: 128.0), + Coordinate3D(latitude: -30.0, longitude: 131.0), + Coordinate3D(latitude: -30.0, longitude: 134.0), + Coordinate3D(latitude: -25.0, longitude: 139.0), + Coordinate3D(latitude: -20.0, longitude: 138.0), + Coordinate3D(latitude: -15.0, longitude: 134.0), + Coordinate3D(latitude: -14.0, longitude: 129.0), + Coordinate3D(latitude: -15.0, longitude: 126.0), + Coordinate3D(latitude: -20.0, longitude: 122.0), + ])! + + private static let polygonClockwise = Polygon([[ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 1.0, longitude: 1.0), + Coordinate3D(latitude: 0.0, longitude: 1.0), + Coordinate3D(latitude: 0.0, longitude: 0.0), + ]])! + + private static let polygonCounterClockwise = Polygon([[ + Coordinate3D(latitude: 0.0, longitude: 0.0), + Coordinate3D(latitude: 0.0, longitude: 1.0), + Coordinate3D(latitude: 1.0, longitude: 1.0), + Coordinate3D(latitude: 0.0, longitude: 0.0), + ]])! + + // MARK: - + + func testLineStringClockwise() { + let lineStringRewinded = RewindTests.lineStringClockwise.rewinded + XCTAssertEqual(lineStringRewinded, RewindTests.lineStringClockwise) + } + + func testLineStringCounterClockwise() { + let lineStringRewinded = RewindTests.lineStringCounterClockwise.rewinded + XCTAssertEqual(lineStringRewinded.allCoordinates, RewindTests.lineStringClockwise.allCoordinates) + } + + func testPolygonClockwise() { + let polygonRewinded = RewindTests.polygonClockwise.rewinded + XCTAssertEqual(polygonRewinded.allCoordinates, RewindTests.polygonCounterClockwise.allCoordinates) + } + + func testPolygonCounterClockwise() { + let polygonRewinded = RewindTests.polygonCounterClockwise.rewinded + XCTAssertEqual(polygonRewinded, RewindTests.polygonCounterClockwise) + } + + func testFeature() { + let featureRewinded = Feature(RewindTests.lineStringCounterClockwise).rewinded + let result = Feature(RewindTests.lineStringClockwise) + XCTAssertEqual(featureRewinded, result) + } + + func testFeatureCollection() { + let featureCollectionRewinded = FeatureCollection([ + RewindTests.lineStringClockwise, + RewindTests.lineStringCounterClockwise, + RewindTests.polygonClockwise, + RewindTests.polygonCounterClockwise, + ]).rewinded + let result = FeatureCollection([ + RewindTests.lineStringClockwise, + RewindTests.lineStringClockwise, + RewindTests.polygonCounterClockwise, + RewindTests.polygonCounterClockwise, + ]) + XCTAssertEqual(featureCollectionRewinded, result) + } + + func testGeometryCollection() { + let geometryCollectionRewinded = GeometryCollection([ + RewindTests.lineStringClockwise, + RewindTests.lineStringCounterClockwise, + RewindTests.polygonClockwise, + RewindTests.polygonCounterClockwise, + ]).rewinded + let result = GeometryCollection([ + RewindTests.lineStringClockwise, + RewindTests.lineStringClockwise, + RewindTests.polygonCounterClockwise, + RewindTests.polygonCounterClockwise, + ]) + XCTAssertEqual(geometryCollectionRewinded, result) + } + +} diff --git a/Tests/GISToolsTests/Extensions/DoubleExtensionsTests.swift b/Tests/GISToolsTests/Extensions/DoubleExtensionsTests.swift index 52898cb..e427650 100644 --- a/Tests/GISToolsTests/Extensions/DoubleExtensionsTests.swift +++ b/Tests/GISToolsTests/Extensions/DoubleExtensionsTests.swift @@ -28,4 +28,39 @@ final class DoubleExtensionsTests: XCTestCase { XCTAssertEqual(number.rounded(precision: -5), number) } + func testConversions() { + // 1 unit + XCTAssertEqual(1.0.meters, GISTool.convert(length: 1.0, from: .meters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.0.kilometers, GISTool.convert(length: 1.0, from: .kilometers, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.0.centimeters, GISTool.convert(length: 1.0, from: .centimeters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.0.millimeters, GISTool.convert(length: 1.0, from: .millimeters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.0.inches, GISTool.convert(length: 1.0, from: .inches, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.0.feet, GISTool.convert(length: 1.0, from: .feet, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.0.yards, GISTool.convert(length: 1.0, from: .yards, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.0.miles, GISTool.convert(length: 1.0, from: .miles, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.0.nauticalMiles, GISTool.convert(length: 1.0, from: .nauticalmiles, to: .meters)!, accuracy: 0.001) + + // pi units + XCTAssertEqual(Double.pi.meters, GISTool.convert(length: Double.pi, from: .meters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(Double.pi.kilometers, GISTool.convert(length: Double.pi, from: .kilometers, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(Double.pi.centimeters, GISTool.convert(length: Double.pi, from: .centimeters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(Double.pi.millimeters, GISTool.convert(length: Double.pi, from: .millimeters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(Double.pi.inches, GISTool.convert(length: Double.pi, from: .inches, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(Double.pi.feet, GISTool.convert(length: Double.pi, from: .feet, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(Double.pi.yards, GISTool.convert(length: Double.pi, from: .yards, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(Double.pi.miles, GISTool.convert(length: Double.pi, from: .miles, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(Double.pi.nauticalMiles, GISTool.convert(length: Double.pi, from: .nauticalmiles, to: .meters)!, accuracy: 0.001) + + // -1 unit + XCTAssertEqual(-1.0.meters, -GISTool.convert(length: 1.0, from: .meters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.0.kilometers, -GISTool.convert(length: 1.0, from: .kilometers, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.0.centimeters, -GISTool.convert(length: 1.0, from: .centimeters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.0.millimeters, -GISTool.convert(length: 1.0, from: .millimeters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.0.inches, -GISTool.convert(length: 1.0, from: .inches, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.0.feet, -GISTool.convert(length: 1.0, from: .feet, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.0.yards, -GISTool.convert(length: 1.0, from: .yards, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.0.miles, -GISTool.convert(length: 1.0, from: .miles, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.0.nauticalMiles, -GISTool.convert(length: 1.0, from: .nauticalmiles, to: .meters)!, accuracy: 0.001) + } + } diff --git a/Tests/GISToolsTests/Extensions/IntExtensionsTests.swift b/Tests/GISToolsTests/Extensions/IntExtensionsTests.swift new file mode 100644 index 0000000..2754bb2 --- /dev/null +++ b/Tests/GISToolsTests/Extensions/IntExtensionsTests.swift @@ -0,0 +1,41 @@ +@testable import GISTools +import XCTest + +final class IntExtensionsTests: XCTestCase { + + func testConversions() { + // 1 unit + XCTAssertEqual(1.meters, GISTool.convert(length: 1.0, from: .meters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.kilometers, GISTool.convert(length: 1.0, from: .kilometers, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.centimeters, GISTool.convert(length: 1.0, from: .centimeters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.millimeters, GISTool.convert(length: 1.0, from: .millimeters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.inches, GISTool.convert(length: 1.0, from: .inches, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.feet, GISTool.convert(length: 1.0, from: .feet, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.yards, GISTool.convert(length: 1.0, from: .yards, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.miles, GISTool.convert(length: 1.0, from: .miles, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(1.nauticalMiles, GISTool.convert(length: 1.0, from: .nauticalmiles, to: .meters)!, accuracy: 0.001) + + // 314 units + XCTAssertEqual(314.meters, GISTool.convert(length: 314.0, from: .meters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(314.kilometers, GISTool.convert(length: 314.0, from: .kilometers, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(314.centimeters, GISTool.convert(length: 314.0, from: .centimeters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(314.millimeters, GISTool.convert(length: 314.0, from: .millimeters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(314.inches, GISTool.convert(length: 314.0, from: .inches, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(314.feet, GISTool.convert(length: 314.0, from: .feet, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(314.yards, GISTool.convert(length: 314.0, from: .yards, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(314.miles, GISTool.convert(length: 314.0, from: .miles, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(314.nauticalMiles, GISTool.convert(length: 314.0, from: .nauticalmiles, to: .meters)!, accuracy: 0.001) + + // -1 unit + XCTAssertEqual(-1.meters, -GISTool.convert(length: 1.0, from: .meters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.kilometers, -GISTool.convert(length: 1.0, from: .kilometers, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.centimeters, -GISTool.convert(length: 1.0, from: .centimeters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.millimeters, -GISTool.convert(length: 1.0, from: .millimeters, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.inches, -GISTool.convert(length: 1.0, from: .inches, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.feet, -GISTool.convert(length: 1.0, from: .feet, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.yards, -GISTool.convert(length: 1.0, from: .yards, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.miles, -GISTool.convert(length: 1.0, from: .miles, to: .meters)!, accuracy: 0.001) + XCTAssertEqual(-1.nauticalMiles, -GISTool.convert(length: 1.0, from: .nauticalmiles, to: .meters)!, accuracy: 0.001) + } + +} diff --git a/Tests/GISToolsTests/GeoJson/CoordinateTests.swift b/Tests/GISToolsTests/GeoJson/CoordinateTests.swift index 53f3808..d77cfe2 100644 --- a/Tests/GISToolsTests/GeoJson/CoordinateTests.swift +++ b/Tests/GISToolsTests/GeoJson/CoordinateTests.swift @@ -62,12 +62,21 @@ final class CoordinateTests: XCTestCase { } func testDecodable() throws { - let coordinateData = try XCTUnwrap("[10,15]".data(using: .utf8)) + let coordinateData = try XCTUnwrap("[10,15]".data(using: .utf8)) let decodedCoordinate = try JSONDecoder().decode(Coordinate3D.self, from: coordinateData) XCTAssertEqual(decodedCoordinate.asJson, [10.0, 15.0]) } + func testJSONDictionary() throws { + let decodedCoordinate = try XCTUnwrap(Coordinate3D(json: [ + "x": 10.0, + "y": 15.0, + ])) + + XCTAssertEqual(decodedCoordinate.asJson, [10.0, 15.0]) + } + func testDecodableInvalid() throws { let coordinateData1 = try XCTUnwrap("[10]".data(using: .utf8)) XCTAssertThrowsError(try JSONDecoder().decode(Coordinate3D.self, from: coordinateData1)) @@ -85,6 +94,28 @@ final class CoordinateTests: XCTestCase { XCTAssertThrowsError(try JSONDecoder().decode(Coordinate3D.self, from: coordinateData5)) } + func testJSONDictionaryInvalid() throws { + XCTAssertNil(Coordinate3D(json: [ + "x": 10.0, + ])) + + XCTAssertNil(Coordinate3D(json: [ + "y": 15.0, + ])) + + XCTAssertNil(Coordinate3D(json: [])) + + XCTAssertNil(Coordinate3D(json: [ + "x": 10.0, + "y": nil, + ])) + + XCTAssertNil(Coordinate3D(json: [ + "x": 10.0, + "y": NSNull(), + ])) + } + func testDecodableInvalidNull() throws { let coordinateDataM = try XCTUnwrap("[10,null,null,1234]".data(using: .utf8)) XCTAssertThrowsError(try JSONDecoder().decode(Coordinate3D.self, from: coordinateDataM)) @@ -105,4 +136,37 @@ final class CoordinateTests: XCTestCase { XCTAssertEqual(decodedCoordinateZM.asJson, [10.0, 15.0, 500]) } + func testEqualityWithAltitude() { + let a = Coordinate3D(latitude: 10.0, longitude: 10.0, altitude: 100.0) + let b = Coordinate3D(latitude: -100.0, longitude: -100.0, altitude: 100.0) + let c = Coordinate3D(latitude: 10.0, longitude: 10.0, altitude: 99.0) + + XCTAssertTrue(a == a) + XCTAssertFalse(a == b) + XCTAssertFalse(a == c) + XCTAssertFalse(b == c) + + XCTAssertTrue(a.equals(other: a, includingAltitude: false)) + XCTAssertTrue(a.equals(other: a, includingAltitude: true)) + XCTAssertTrue(a.equals(other: c, includingAltitude: false)) + XCTAssertFalse(a.equals(other: c, includingAltitude: true)) + XCTAssertTrue(a.equals(other: c, includingAltitude: true, altitudeDelta: 1.0)) + XCTAssertFalse(a.equals(other: c, includingAltitude: true, altitudeDelta: 0.5)) + + XCTAssertFalse(a.equals(other: b, includingAltitude: false)) + XCTAssertFalse(a.equals(other: b, includingAltitude: true)) + } + + func testEqualityWithoutAltitude() { + let a = Coordinate3D(latitude: 10.0, longitude: 10.0) + let b = Coordinate3D(latitude: -100.0, longitude: -100.0) + + XCTAssertTrue(a == a) + XCTAssertTrue(a.equals(other: a, includingAltitude: false)) + XCTAssertTrue(a.equals(other: a, includingAltitude: true)) + XCTAssertFalse(a == b) + XCTAssertFalse(a.equals(other: b, includingAltitude: false)) + XCTAssertFalse(a.equals(other: b, includingAltitude: true)) + } + } diff --git a/Tests/GISToolsTests/GeoJson/LineStringTests.swift b/Tests/GISToolsTests/GeoJson/LineStringTests.swift index 260259a..7f74600 100644 --- a/Tests/GISToolsTests/GeoJson/LineStringTests.swift +++ b/Tests/GISToolsTests/GeoJson/LineStringTests.swift @@ -35,9 +35,12 @@ final class LineStringTests: XCTestCase { func testCreateLineString() throws { let lineSegments = [ - LineSegment(first: Coordinate3D(latitude: 0.0, longitude: 0.0), second: Coordinate3D(latitude: 10.0, longitude: 0.0)), - LineSegment(first: Coordinate3D(latitude: 10.0, longitude: 0.0), second: Coordinate3D(latitude: 10.0, longitude: 10.0)), - LineSegment(first: Coordinate3D(latitude: 0.0, longitude: 10.0), second: Coordinate3D(latitude: 0.0, longitude: 0.0)), + LineSegment(first: Coordinate3D(latitude: 0.0, longitude: 0.0), + second: Coordinate3D(latitude: 10.0, longitude: 0.0)), + LineSegment(first: Coordinate3D(latitude: 10.0, longitude: 0.0), + second: Coordinate3D(latitude: 10.0, longitude: 10.0)), + LineSegment(first: Coordinate3D(latitude: 0.0, longitude: 10.0), + second: Coordinate3D(latitude: 0.0, longitude: 0.0)), ] let lineString = try XCTUnwrap(LineString(lineSegments)) diff --git a/Tests/GISToolsTests/Other/MapTileTests.swift b/Tests/GISToolsTests/Other/MapTileTests.swift index 6539d59..c37ed6e 100644 --- a/Tests/GISToolsTests/Other/MapTileTests.swift +++ b/Tests/GISToolsTests/Other/MapTileTests.swift @@ -186,6 +186,13 @@ final class MapTileTests: XCTestCase { XCTAssertEqual(worldTile.metersPerPixel, mppAtZoom0, accuracy: 0.00001) } + func testEdgeCases() throws { + let coordinate = Coordinate3D(latitude: -90.0, longitude: -180.0) + let tile = MapTile(coordinate: coordinate, atZoom: 14) + + XCTAssertEqual(tile, MapTile(x: 0, y: (1 << 14) - 1, z: 14)) + } + func testQquadkey() { let tiles = [ MapTile(x: 1, y: 2, z: 3),