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),