Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,41 @@ let coordinate = Coordinate3D(latitude: 0.0, longitude: 0.0)
print(coordinate.isZero)
```

## Projections
[Implementation][20]

Coordinates born as EPSG:4326 or EPSG:3857 know their projection and all operations keep them there. Coordinates can be re-projected:

```swift
let coordinate = Coordinate3D(latitude: 41.0, longitude: -71.0)
let utm19 = coordinate.projected(to: .epsg32619) // UTM zone 19N
let mercator = utm19.projected(to: .epsg3857)
let projectedBack = mercator.projected(to: .epsg4326)
print(projection.description, projection.srid)
```

Densification, buffer, distance etc. automatically take the projection into account. Beyond the built-in projections (EPSG:4326, 3857, 4978, 3395, 32662 and all 120 UTM zones), custom projections can be registered - registration is add-only, applied for the whole process, typically at startup:

```swift
let custom = CustomProjection(
srid: 900_001,
kind: .planar,
validExtent: ProjectionExtent(minX: -200_000, minY: -200_000, maxX: 200_000, maxY: 200_000),
forward: { coordinate in
Coordinate3D(latitude: coordinate.latitude * 1_000.0,
longitude: coordinate.longitude * 1_000.0)
},
inverse: { coordinate in
Coordinate3D(latitude: coordinate.latitude / 1_000.0,
longitude: coordinate.longitude / 1_000.0)
})
Projection.register(custom)

let customProjection = try Projection(srid: 900_001)
```

All algorithms dispatch on the projection *kind* (geographic/planar/geocentric) and honor the definition's capabilities (wraparound extents, valid ranges, world bounding boxes), so custom projections work across the whole library like built-in ones.

## BoundingBox
[Implementation][18] / [BoundingBox test cases][19]

Expand Down Expand Up @@ -1399,7 +1434,7 @@ Thomas Rasch, Outdooractive
[17]: https://github.com/Outdooractive/gis-tools/blob/main/Tests/GISToolsTests/GeoJson/CoordinateTests.swift
[18]: https://github.com/Outdooractive/gis-tools/blob/main/Sources/GISTools/GeoJson/BoundingBox.swift
[19]: https://github.com/Outdooractive/gis-tools/blob/main/Tests/GISToolsTests/GeoJson/BoundingBoxTests.swift
[20]: https://github.com/Outdooractive/gis-tools/blob/main/Sources/GISTools/GeoJson/Point.swift
[20]: https://github.com/Outdooractive/gis-tools/blob/main/Sources/GISTools/GeoJson/Projection.swift
[21]: https://github.com/Outdooractive/gis-tools/blob/main/Tests/GISToolsTests/GeoJson/PointTests.swift
[22]: https://github.com/Outdooractive/gis-tools/blob/main/Sources/GISTools/GeoJson/MultiPoint.swift
[23]: https://github.com/Outdooractive/gis-tools/blob/main/Tests/GISToolsTests/GeoJson/MultiPointTests.swift
Expand Down
2 changes: 1 addition & 1 deletion Sources/GISTools/Algorithms/Random.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ extension BoundingBox {

/// The world bounding box projected to the given CRS.
private static func worldBox(projection: Projection) -> BoundingBox {
guard let worldBox = ProjectionRegistry.definition(for: projection).worldBoundingBox else {
guard let worldBox = projection.definition.worldBoundingBox else {
// No defined world extent: fall back to the EPSG:4326 world box.
return BoundingBox.world
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/GISTools/GeoJson/BoundingBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ public struct BoundingBox:
ne.longitude = min(wrapExtent, max(-wrapExtent, ne.longitude))
}

if let extent = ProjectionRegistry.definition(for: projection).validExtent {
if let extent = projection.definition.validExtent {
sw.latitude = min(extent.maxY, max(extent.minY, sw.latitude))
ne.latitude = min(extent.maxY, max(extent.minY, ne.latitude))
}
Expand Down
12 changes: 6 additions & 6 deletions Sources/GISTools/GeoJson/Coordinate3D.swift
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ extension Coordinate3D {
///
/// - Returns: A copy clamped to the valid coordinate range
public func clamped() -> Coordinate3D {
guard let extent = ProjectionRegistry.definition(for: projection).validExtent
guard let extent = projection.definition.validExtent
else { return self }

guard longitude < extent.minX || longitude > extent.maxX
Expand Down Expand Up @@ -467,14 +467,14 @@ extension Coordinate3D: Projectable {
// both directions - this is the common case for GeoJSON (4326) and
// hot reprojection loops.
if projection == .epsg4326 {
return ProjectionRegistry.definition(for: newProjection).forward(self)
return newProjection.definition.forward(self)
}
if newProjection == .epsg4326 {
return ProjectionRegistry.definition(for: projection).inverse(self)
return projection.definition.inverse(self)
}

let pivot = ProjectionRegistry.definition(for: projection).inverse(self)
return ProjectionRegistry.definition(for: newProjection).forward(pivot)
let pivot = projection.definition.inverse(self)
return newProjection.definition.forward(pivot)
}

/// Project this coordinate's latitude.
Expand Down Expand Up @@ -749,7 +749,7 @@ extension Coordinate3D {
///
/// - SeeAlso: ``Projection/validExtent``
public var isValid: Bool {
guard let extent = ProjectionRegistry.definition(for: projection).validExtent
guard let extent = projection.definition.validExtent
else { return true }

return longitude >= extent.minX
Expand Down
170 changes: 170 additions & 0 deletions Sources/GISTools/GeoJson/CustomProjection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#if canImport(CoreLocation)
import CoreLocation
#endif
import Foundation
import Synchronization

/// A user-supplied projection definition for ``Projection/register(_:)``.
///
/// All transformations are expressed relative to the **EPSG:4326 (WGS84)
/// pivot**: ``forward`` projects geographic WGS84 coordinates into the custom
/// projection, ``inverse`` maps back. Any projection pair is routed through
/// that pivot, so definitions for coordinates on other datums must perform
/// the datum transformation inside these closures.
///
/// ```swift
/// let custom = CustomProjection(
/// srid: 900_001,
/// kind: .planar,
/// wraparoundExtent: 180_000.0,
/// validExtent: ProjectionExtent(minX: -180_000, minY: -90_000,
/// maxX: 180_000, maxY: 90_000),
/// worldBoundingBox: nil,
/// wktMatchers: [["PROJCS", "My Custom Grid"]],
/// forward: { coordinate in
/// Coordinate3D(
/// x: coordinate.longitude * 1_000.0,
/// y: coordinate.latitude * 1_000.0,
/// projection: .epsg4326)
/// },
/// inverse: { coordinate in
/// Coordinate3D(
/// latitude: coordinate.latitude / 1_000.0,
/// longitude: coordinate.longitude / 1_000.0,
/// m: coordinate.m)
/// })
/// Projection.register(custom)
/// ```
///
/// - Note: Register projections early (e.g. at app startup). Registration is
/// *add-only*: definitions cannot be removed or replaced, and registering
/// an already used SRID is rejected. Decoding an encoded ``Projection``
/// whose SRID is not registered throws during `Codable`.
public struct CustomProjection: Sendable {

/// The SRID of the custom projection. Must be a positive integer that
/// does not collide with an already-registered SRID.
public var srid: Int

/// The semantic category of the coordinates, driving the kind-based
/// algorithm dispatch.
public var kind: ProjectionKind

/// The absolute value beyond which the horizontal axis wraps around
/// (e.g. the antimeridian for geographic coordinate systems).
/// `nil` disables wrapping.
public var wraparoundExtent: Double?

/// The valid extent of the projection in its own coordinates,
/// or `nil` if unbounded.
public var validExtent: ProjectionExtent?

/// A bounding box spanning the whole world in this projection,
/// or `nil` if no meaningful world extent exists.
public var worldBoundingBox: BoundingBox?

/// WKT fragment sets identifying the projection in `.prj`-like strings
/// (e.g. `[["PROJCS", "My Custom Grid"]]`).
///
/// Each inner list is one alternative whose every fragment must be
/// contained in the string for a match. Built-in patterns are tested
/// first, custom patterns last.
public var wktMatchers: [[String]]

/// Converts a coordinate from EPSG:4326 (WGS84) into the custom
/// projection.
public var forward: @Sendable (Coordinate3D) -> Coordinate3D

/// Converts a coordinate from the custom projection into EPSG:4326
/// (WGS84).
public var inverse: @Sendable (Coordinate3D) -> Coordinate3D

public init(
srid: Int,
kind: ProjectionKind,
wraparoundExtent: Double? = nil,
validExtent: ProjectionExtent? = nil,
worldBoundingBox: BoundingBox? = nil,
wktMatchers: [[String]] = [],
forward: @escaping @Sendable (Coordinate3D) -> Coordinate3D,
inverse: @escaping @Sendable (Coordinate3D) -> Coordinate3D
) {
self.srid = srid
self.kind = kind
self.wraparoundExtent = wraparoundExtent
self.validExtent = validExtent
self.worldBoundingBox = worldBoundingBox
self.wktMatchers = wktMatchers
self.forward = forward
self.inverse = inverse
}

}

// MARK: - Registration

extension Projection {

/// Registers a custom projection definition so that it can be resolved
/// via ``init(srid:)`` and used by every algorithm through the
/// projection registry.
///
/// **Registration is add-only**: built-in projections are permanently
/// registered and a custom definition cannot be removed or replaced
/// afterwards.
///
/// The registration is rejected (`false`) when the SRID is `<= 0` or
/// already in use by a built-in or previously registered projection.
/// There is no failure path for *lookups* afterwards: a successfully
/// registered SRID resolves like any built-in one.
///
/// - Parameter projection: The custom definition to register
/// - Returns: `true` when the registration succeeded
@discardableResult
public static func register(_ projection: CustomProjection) -> Bool {
ProjectionRegistry.register(projection)
}

}

// MARK: - Internal wrapper

/// Internal adapter bridging a ``CustomProjection`` into the internal
/// ``ProjectionDefinition`` contract.
struct RegisteredCustomDefinition: ProjectionDefinition {

let custom: CustomProjection

var projection: Projection { Projection(uncheckedSrid: custom.srid, definition: self) }
var kind: ProjectionKind { custom.kind }
var wraparoundExtent: Double? { custom.wraparoundExtent }
var validExtent: ProjectionExtent? { custom.validExtent }
var worldBoundingBox: BoundingBox? { custom.worldBoundingBox }
var wktMatchers: [[String]] { custom.wktMatchers }

func forward(_ coordinate: Coordinate3D) -> Coordinate3D {
var result = custom.forward(coordinate)
if result.projection != projection {
result = Coordinate3D(
x: result.longitude,
y: result.latitude,
z: result.altitude,
m: result.m,
projection: projection)
}
return result
}

func inverse(_ coordinate: Coordinate3D) -> Coordinate3D {
var result = custom.inverse(coordinate)
if result.projection != .epsg4326 {
result = Coordinate3D(
latitude: result.latitude,
longitude: result.longitude,
altitude: result.altitude,
m: result.m)
}
return result
}

}
Loading