From c380480c3d54323c3f1cb06eaecd43a2afafca51 Mon Sep 17 00:00:00 2001 From: "Thomas Rasch (OpenCode)" Date: Tue, 15 Sep 2026 08:33:09 +0000 Subject: [PATCH] Restructure Projection as a struct with a public registration API Implements issue #242 in two steps on this branch. Phase A - struct-based Projection: - Projection becomes a public struct with SRID identity: custom ==/hash and a Codable implementation that encodes the raw SRID identically to the previous enum wire format. All 125 built-in projections keep their constant names, so dot-shorthand usage (x == .epsg4326) keeps compiling unchanged. - The generated case lists are gone: kind, capability flags, wraparoundExtent and crsLength resolve through the registry. - init?(srid:) resolves through an SRID alias table (the historical EPSG:3857 aliases) plus a registry check; init?(wkt:) and the UTM token short-circuit are unchanged; descriptions unchanged. Phase B - add-only custom registration: - public CustomProjection struct: a value-type descriptor with @Sendable forward/inverse closures relative to the EPSG:4326 (WGS84) pivot, plus kind, wraparound extent, valid extent (public ProjectionExtent struct), world bounding box and WKT fragment sets. The pivot contract makes datum-capable custom definitions possible. - public Projection.register(CustomProjection) is add-only: rejected for SRID <= 0, duplicates and built-in shadowing; there is no unregister/deactivate path by design. - ProjectionExtent becomes the public extent type used by all definition metadata. - The registry keeps a Mutex-guarded add-only snapshot: built-ins seeded once, custom definitions appended, never removed or replaced. Hot paths NEVER read the registry - Projection values capture their definition at construction - so projected(to:), kind, clamped(), isValid and wraparoundExtent resolve via the stored definition with zero locking. The registry is only used for lookups (init?(srid:), Codable), WKT matching and registration. Performance (release benchmarks, before -> after): - full projected(to:) path: ~178 ns/coordinate (35x baseline) -> ~1.7x of the direct-math baseline - No hot-path registry access remains; a parallel 2488-test run previously melted down (924s wall) on per-access Mutex reads, which motivated the definition capture. Tests (+6): custom registration/rejection rules, end-to-end round trips, registry-driven capabilities on a custom definition, WKT matching, Codable semantics (register-before-decode) and a concurrent registration storm. Note for test authors: Swift Testing runs tests in parallel against the process-global registry - reserve SRID blocks and use unique WKT fragments. Also adds a Projections section with a custom-projection example to the README. --- README.md | 37 +- Sources/GISTools/Algorithms/Random.swift | 2 +- Sources/GISTools/GeoJson/BoundingBox.swift | 2 +- Sources/GISTools/GeoJson/Coordinate3D.swift | 12 +- .../GISTools/GeoJson/CustomProjection.swift | 170 +++++++ Sources/GISTools/GeoJson/Projection.swift | 454 ++++++++++-------- .../GeoJson/ProjectionDefinition.swift | 187 +++++--- .../GISTools/GeoJson/ProjectionExtent.swift | 32 ++ Sources/GISTools/GeoJson/UtmDefinition.swift | 55 ++- .../GeoJson/CustomProjectionTests.swift | 212 ++++++++ .../GeoJson/ProjectionBenchmarks.swift | 34 +- .../GeoJson/UtmDefinitionTests.swift | 12 +- 12 files changed, 899 insertions(+), 310 deletions(-) create mode 100644 Sources/GISTools/GeoJson/CustomProjection.swift create mode 100644 Sources/GISTools/GeoJson/ProjectionExtent.swift create mode 100644 Tests/GISToolsTests/GeoJson/CustomProjectionTests.swift diff --git a/README.md b/README.md index e31bb0b..d692978 100644 --- a/README.md +++ b/README.md @@ -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] @@ -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 diff --git a/Sources/GISTools/Algorithms/Random.swift b/Sources/GISTools/Algorithms/Random.swift index 6033a79..d531f18 100644 --- a/Sources/GISTools/Algorithms/Random.swift +++ b/Sources/GISTools/Algorithms/Random.swift @@ -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 } diff --git a/Sources/GISTools/GeoJson/BoundingBox.swift b/Sources/GISTools/GeoJson/BoundingBox.swift index 0341a62..ca663f2 100644 --- a/Sources/GISTools/GeoJson/BoundingBox.swift +++ b/Sources/GISTools/GeoJson/BoundingBox.swift @@ -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)) } diff --git a/Sources/GISTools/GeoJson/Coordinate3D.swift b/Sources/GISTools/GeoJson/Coordinate3D.swift index d324826..b316176 100644 --- a/Sources/GISTools/GeoJson/Coordinate3D.swift +++ b/Sources/GISTools/GeoJson/Coordinate3D.swift @@ -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 @@ -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. @@ -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 diff --git a/Sources/GISTools/GeoJson/CustomProjection.swift b/Sources/GISTools/GeoJson/CustomProjection.swift new file mode 100644 index 0000000..e17c2b0 --- /dev/null +++ b/Sources/GISTools/GeoJson/CustomProjection.swift @@ -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 + } + +} diff --git a/Sources/GISTools/GeoJson/Projection.swift b/Sources/GISTools/GeoJson/Projection.swift index b60467d..58dd22f 100644 --- a/Sources/GISTools/GeoJson/Projection.swift +++ b/Sources/GISTools/GeoJson/Projection.swift @@ -1,267 +1,308 @@ - -/// Projections that this library can handle. -public enum Projection: - Int, +/// A coordinate reference system. +/// +/// Projections are value types identified by their ``srid``: equality, +/// hashing and `Codable` operate on the SRID number alone. Transform math +/// and metadata (``kind``, ``wraparoundExtent``, extents, ...) resolve +/// through the projection registry. +/// +/// Every projection known to the library provides a constant (e.g. +/// ``Projection/epsg4326``, ``Projection/epsg32619``). Projections are +/// resolved through ``init(srid:)`` (with SRID aliases), ``init(wkt:)`` +/// or ``init(utmZone:hemisphere:)``. +public struct Projection: + Hashable, CustomStringConvertible, Codable, Sendable { + // MARK: - Built-in projections + /// No SRID (invalid/unknown projection). - case noSRID = 0 + public static let noSRID = Projection.builtin(srid: 0) /// EPSG:3857 - web mercator (https://epsg.io/3857). - case epsg3857 = 3857 + public static let epsg3857 = Projection.builtin(srid: 3857) /// EPSG:4326 - geodetic (https://epsg.io/4326). - case epsg4326 = 4326 + public static let epsg4326 = Projection.builtin(srid: 4326) /// EPSG:4978 - geocentric (ECEF) (https://epsg.io/4978). - case epsg4978 = 4978 + public static let epsg4978 = Projection.builtin(srid: 4978) /// EPSG:3395 - WGS 84 / World Mercator, ellipsoidal Mercator /// (https://epsg.io/3395). - case epsg3395 = 3395 + public static let epsg3395 = Projection.builtin(srid: 3395) /// EPSG:32662 - WGS 84 / Plate Carree, equirectangular /// (https://epsg.io/32662). - case epsg32662 = 32662 + public static let epsg32662 = Projection.builtin(srid: 32_662) // UTM zones (northern EPSG:32601-32660, southern EPSG:32701-32760). /// EPSG:32601 - UTM zone 1N (https://epsg.io/32601). - case epsg32601 = 32601 + public static let epsg32601 = Projection.builtin(srid: 32601) /// EPSG:32602 - UTM zone 2N (https://epsg.io/32602). - case epsg32602 = 32602 + public static let epsg32602 = Projection.builtin(srid: 32602) /// EPSG:32603 - UTM zone 3N (https://epsg.io/32603). - case epsg32603 = 32603 + public static let epsg32603 = Projection.builtin(srid: 32603) /// EPSG:32604 - UTM zone 4N (https://epsg.io/32604). - case epsg32604 = 32604 + public static let epsg32604 = Projection.builtin(srid: 32604) /// EPSG:32605 - UTM zone 5N (https://epsg.io/32605). - case epsg32605 = 32605 + public static let epsg32605 = Projection.builtin(srid: 32605) /// EPSG:32606 - UTM zone 6N (https://epsg.io/32606). - case epsg32606 = 32606 + public static let epsg32606 = Projection.builtin(srid: 32606) /// EPSG:32607 - UTM zone 7N (https://epsg.io/32607). - case epsg32607 = 32607 + public static let epsg32607 = Projection.builtin(srid: 32607) /// EPSG:32608 - UTM zone 8N (https://epsg.io/32608). - case epsg32608 = 32608 + public static let epsg32608 = Projection.builtin(srid: 32608) /// EPSG:32609 - UTM zone 9N (https://epsg.io/32609). - case epsg32609 = 32609 + public static let epsg32609 = Projection.builtin(srid: 32609) /// EPSG:32610 - UTM zone 10N (https://epsg.io/32610). - case epsg32610 = 32610 + public static let epsg32610 = Projection.builtin(srid: 32610) /// EPSG:32611 - UTM zone 11N (https://epsg.io/32611). - case epsg32611 = 32611 + public static let epsg32611 = Projection.builtin(srid: 32611) /// EPSG:32612 - UTM zone 12N (https://epsg.io/32612). - case epsg32612 = 32612 + public static let epsg32612 = Projection.builtin(srid: 32612) /// EPSG:32613 - UTM zone 13N (https://epsg.io/32613). - case epsg32613 = 32613 + public static let epsg32613 = Projection.builtin(srid: 32613) /// EPSG:32614 - UTM zone 14N (https://epsg.io/32614). - case epsg32614 = 32614 + public static let epsg32614 = Projection.builtin(srid: 32614) /// EPSG:32615 - UTM zone 15N (https://epsg.io/32615). - case epsg32615 = 32615 + public static let epsg32615 = Projection.builtin(srid: 32615) /// EPSG:32616 - UTM zone 16N (https://epsg.io/32616). - case epsg32616 = 32616 + public static let epsg32616 = Projection.builtin(srid: 32616) /// EPSG:32617 - UTM zone 17N (https://epsg.io/32617). - case epsg32617 = 32617 + public static let epsg32617 = Projection.builtin(srid: 32617) /// EPSG:32618 - UTM zone 18N (https://epsg.io/32618). - case epsg32618 = 32618 + public static let epsg32618 = Projection.builtin(srid: 32618) /// EPSG:32619 - UTM zone 19N (https://epsg.io/32619). - case epsg32619 = 32619 + public static let epsg32619 = Projection.builtin(srid: 32619) /// EPSG:32620 - UTM zone 20N (https://epsg.io/32620). - case epsg32620 = 32620 + public static let epsg32620 = Projection.builtin(srid: 32620) /// EPSG:32621 - UTM zone 21N (https://epsg.io/32621). - case epsg32621 = 32621 + public static let epsg32621 = Projection.builtin(srid: 32621) /// EPSG:32622 - UTM zone 22N (https://epsg.io/32622). - case epsg32622 = 32622 + public static let epsg32622 = Projection.builtin(srid: 32622) /// EPSG:32623 - UTM zone 23N (https://epsg.io/32623). - case epsg32623 = 32623 + public static let epsg32623 = Projection.builtin(srid: 32623) /// EPSG:32624 - UTM zone 24N (https://epsg.io/32624). - case epsg32624 = 32624 + public static let epsg32624 = Projection.builtin(srid: 32624) /// EPSG:32625 - UTM zone 25N (https://epsg.io/32625). - case epsg32625 = 32625 + public static let epsg32625 = Projection.builtin(srid: 32625) /// EPSG:32626 - UTM zone 26N (https://epsg.io/32626). - case epsg32626 = 32626 + public static let epsg32626 = Projection.builtin(srid: 32626) /// EPSG:32627 - UTM zone 27N (https://epsg.io/32627). - case epsg32627 = 32627 + public static let epsg32627 = Projection.builtin(srid: 32627) /// EPSG:32628 - UTM zone 28N (https://epsg.io/32628). - case epsg32628 = 32628 + public static let epsg32628 = Projection.builtin(srid: 32628) /// EPSG:32629 - UTM zone 29N (https://epsg.io/32629). - case epsg32629 = 32629 + public static let epsg32629 = Projection.builtin(srid: 32629) /// EPSG:32630 - UTM zone 30N (https://epsg.io/32630). - case epsg32630 = 32630 + public static let epsg32630 = Projection.builtin(srid: 32630) /// EPSG:32631 - UTM zone 31N (https://epsg.io/32631). - case epsg32631 = 32631 + public static let epsg32631 = Projection.builtin(srid: 32631) /// EPSG:32632 - UTM zone 32N (https://epsg.io/32632). - case epsg32632 = 32632 + public static let epsg32632 = Projection.builtin(srid: 32632) /// EPSG:32633 - UTM zone 33N (https://epsg.io/32633). - case epsg32633 = 32633 + public static let epsg32633 = Projection.builtin(srid: 32633) /// EPSG:32634 - UTM zone 34N (https://epsg.io/32634). - case epsg32634 = 32634 + public static let epsg32634 = Projection.builtin(srid: 32634) /// EPSG:32635 - UTM zone 35N (https://epsg.io/32635). - case epsg32635 = 32635 + public static let epsg32635 = Projection.builtin(srid: 32635) /// EPSG:32636 - UTM zone 36N (https://epsg.io/32636). - case epsg32636 = 32636 + public static let epsg32636 = Projection.builtin(srid: 32636) /// EPSG:32637 - UTM zone 37N (https://epsg.io/32637). - case epsg32637 = 32637 + public static let epsg32637 = Projection.builtin(srid: 32637) /// EPSG:32638 - UTM zone 38N (https://epsg.io/32638). - case epsg32638 = 32638 + public static let epsg32638 = Projection.builtin(srid: 32638) /// EPSG:32639 - UTM zone 39N (https://epsg.io/32639). - case epsg32639 = 32639 + public static let epsg32639 = Projection.builtin(srid: 32639) /// EPSG:32640 - UTM zone 40N (https://epsg.io/32640). - case epsg32640 = 32640 + public static let epsg32640 = Projection.builtin(srid: 32640) /// EPSG:32641 - UTM zone 41N (https://epsg.io/32641). - case epsg32641 = 32641 + public static let epsg32641 = Projection.builtin(srid: 32641) /// EPSG:32642 - UTM zone 42N (https://epsg.io/32642). - case epsg32642 = 32642 + public static let epsg32642 = Projection.builtin(srid: 32642) /// EPSG:32643 - UTM zone 43N (https://epsg.io/32643). - case epsg32643 = 32643 + public static let epsg32643 = Projection.builtin(srid: 32643) /// EPSG:32644 - UTM zone 44N (https://epsg.io/32644). - case epsg32644 = 32644 + public static let epsg32644 = Projection.builtin(srid: 32644) /// EPSG:32645 - UTM zone 45N (https://epsg.io/32645). - case epsg32645 = 32645 + public static let epsg32645 = Projection.builtin(srid: 32645) /// EPSG:32646 - UTM zone 46N (https://epsg.io/32646). - case epsg32646 = 32646 + public static let epsg32646 = Projection.builtin(srid: 32646) /// EPSG:32647 - UTM zone 47N (https://epsg.io/32647). - case epsg32647 = 32647 + public static let epsg32647 = Projection.builtin(srid: 32647) /// EPSG:32648 - UTM zone 48N (https://epsg.io/32648). - case epsg32648 = 32648 + public static let epsg32648 = Projection.builtin(srid: 32648) /// EPSG:32649 - UTM zone 49N (https://epsg.io/32649). - case epsg32649 = 32649 + public static let epsg32649 = Projection.builtin(srid: 32649) /// EPSG:32650 - UTM zone 50N (https://epsg.io/32650). - case epsg32650 = 32650 + public static let epsg32650 = Projection.builtin(srid: 32650) /// EPSG:32651 - UTM zone 51N (https://epsg.io/32651). - case epsg32651 = 32651 + public static let epsg32651 = Projection.builtin(srid: 32651) /// EPSG:32652 - UTM zone 52N (https://epsg.io/32652). - case epsg32652 = 32652 + public static let epsg32652 = Projection.builtin(srid: 32652) /// EPSG:32653 - UTM zone 53N (https://epsg.io/32653). - case epsg32653 = 32653 + public static let epsg32653 = Projection.builtin(srid: 32653) /// EPSG:32654 - UTM zone 54N (https://epsg.io/32654). - case epsg32654 = 32654 + public static let epsg32654 = Projection.builtin(srid: 32654) /// EPSG:32655 - UTM zone 55N (https://epsg.io/32655). - case epsg32655 = 32655 + public static let epsg32655 = Projection.builtin(srid: 32655) /// EPSG:32656 - UTM zone 56N (https://epsg.io/32656). - case epsg32656 = 32656 + public static let epsg32656 = Projection.builtin(srid: 32656) /// EPSG:32657 - UTM zone 57N (https://epsg.io/32657). - case epsg32657 = 32657 + public static let epsg32657 = Projection.builtin(srid: 32657) /// EPSG:32658 - UTM zone 58N (https://epsg.io/32658). - case epsg32658 = 32658 + public static let epsg32658 = Projection.builtin(srid: 32658) /// EPSG:32659 - UTM zone 59N (https://epsg.io/32659). - case epsg32659 = 32659 + public static let epsg32659 = Projection.builtin(srid: 32659) /// EPSG:32660 - UTM zone 60N (https://epsg.io/32660). - case epsg32660 = 32660 + public static let epsg32660 = Projection.builtin(srid: 32660) /// EPSG:32701 - UTM zone 1S (https://epsg.io/32701). - case epsg32701 = 32701 + public static let epsg32701 = Projection.builtin(srid: 32701) /// EPSG:32702 - UTM zone 2S (https://epsg.io/32702). - case epsg32702 = 32702 + public static let epsg32702 = Projection.builtin(srid: 32702) /// EPSG:32703 - UTM zone 3S (https://epsg.io/32703). - case epsg32703 = 32703 + public static let epsg32703 = Projection.builtin(srid: 32703) /// EPSG:32704 - UTM zone 4S (https://epsg.io/32704). - case epsg32704 = 32704 + public static let epsg32704 = Projection.builtin(srid: 32704) /// EPSG:32705 - UTM zone 5S (https://epsg.io/32705). - case epsg32705 = 32705 + public static let epsg32705 = Projection.builtin(srid: 32705) /// EPSG:32706 - UTM zone 6S (https://epsg.io/32706). - case epsg32706 = 32706 + public static let epsg32706 = Projection.builtin(srid: 32706) /// EPSG:32707 - UTM zone 7S (https://epsg.io/32707). - case epsg32707 = 32707 + public static let epsg32707 = Projection.builtin(srid: 32707) /// EPSG:32708 - UTM zone 8S (https://epsg.io/32708). - case epsg32708 = 32708 + public static let epsg32708 = Projection.builtin(srid: 32708) /// EPSG:32709 - UTM zone 9S (https://epsg.io/32709). - case epsg32709 = 32709 + public static let epsg32709 = Projection.builtin(srid: 32709) /// EPSG:32710 - UTM zone 10S (https://epsg.io/32710). - case epsg32710 = 32710 + public static let epsg32710 = Projection.builtin(srid: 32710) /// EPSG:32711 - UTM zone 11S (https://epsg.io/32711). - case epsg32711 = 32711 + public static let epsg32711 = Projection.builtin(srid: 32711) /// EPSG:32712 - UTM zone 12S (https://epsg.io/32712). - case epsg32712 = 32712 + public static let epsg32712 = Projection.builtin(srid: 32712) /// EPSG:32713 - UTM zone 13S (https://epsg.io/32713). - case epsg32713 = 32713 + public static let epsg32713 = Projection.builtin(srid: 32713) /// EPSG:32714 - UTM zone 14S (https://epsg.io/32714). - case epsg32714 = 32714 + public static let epsg32714 = Projection.builtin(srid: 32714) /// EPSG:32715 - UTM zone 15S (https://epsg.io/32715). - case epsg32715 = 32715 + public static let epsg32715 = Projection.builtin(srid: 32715) /// EPSG:32716 - UTM zone 16S (https://epsg.io/32716). - case epsg32716 = 32716 + public static let epsg32716 = Projection.builtin(srid: 32716) /// EPSG:32717 - UTM zone 17S (https://epsg.io/32717). - case epsg32717 = 32717 + public static let epsg32717 = Projection.builtin(srid: 32717) /// EPSG:32718 - UTM zone 18S (https://epsg.io/32718). - case epsg32718 = 32718 + public static let epsg32718 = Projection.builtin(srid: 32718) /// EPSG:32719 - UTM zone 19S (https://epsg.io/32719). - case epsg32719 = 32719 + public static let epsg32719 = Projection.builtin(srid: 32719) /// EPSG:32720 - UTM zone 20S (https://epsg.io/32720). - case epsg32720 = 32720 + public static let epsg32720 = Projection.builtin(srid: 32720) /// EPSG:32721 - UTM zone 21S (https://epsg.io/32721). - case epsg32721 = 32721 + public static let epsg32721 = Projection.builtin(srid: 32721) /// EPSG:32722 - UTM zone 22S (https://epsg.io/32722). - case epsg32722 = 32722 + public static let epsg32722 = Projection.builtin(srid: 32722) /// EPSG:32723 - UTM zone 23S (https://epsg.io/32723). - case epsg32723 = 32723 + public static let epsg32723 = Projection.builtin(srid: 32723) /// EPSG:32724 - UTM zone 24S (https://epsg.io/32724). - case epsg32724 = 32724 + public static let epsg32724 = Projection.builtin(srid: 32724) /// EPSG:32725 - UTM zone 25S (https://epsg.io/32725). - case epsg32725 = 32725 + public static let epsg32725 = Projection.builtin(srid: 32725) /// EPSG:32726 - UTM zone 26S (https://epsg.io/32726). - case epsg32726 = 32726 + public static let epsg32726 = Projection.builtin(srid: 32726) /// EPSG:32727 - UTM zone 27S (https://epsg.io/32727). - case epsg32727 = 32727 + public static let epsg32727 = Projection.builtin(srid: 32727) /// EPSG:32728 - UTM zone 28S (https://epsg.io/32728). - case epsg32728 = 32728 + public static let epsg32728 = Projection.builtin(srid: 32728) /// EPSG:32729 - UTM zone 29S (https://epsg.io/32729). - case epsg32729 = 32729 + public static let epsg32729 = Projection.builtin(srid: 32729) /// EPSG:32730 - UTM zone 30S (https://epsg.io/32730). - case epsg32730 = 32730 + public static let epsg32730 = Projection.builtin(srid: 32730) /// EPSG:32731 - UTM zone 31S (https://epsg.io/32731). - case epsg32731 = 32731 + public static let epsg32731 = Projection.builtin(srid: 32731) /// EPSG:32732 - UTM zone 32S (https://epsg.io/32732). - case epsg32732 = 32732 + public static let epsg32732 = Projection.builtin(srid: 32732) /// EPSG:32733 - UTM zone 33S (https://epsg.io/32733). - case epsg32733 = 32733 + public static let epsg32733 = Projection.builtin(srid: 32733) /// EPSG:32734 - UTM zone 34S (https://epsg.io/32734). - case epsg32734 = 32734 + public static let epsg32734 = Projection.builtin(srid: 32734) /// EPSG:32735 - UTM zone 35S (https://epsg.io/32735). - case epsg32735 = 32735 + public static let epsg32735 = Projection.builtin(srid: 32735) /// EPSG:32736 - UTM zone 36S (https://epsg.io/32736). - case epsg32736 = 32736 + public static let epsg32736 = Projection.builtin(srid: 32736) /// EPSG:32737 - UTM zone 37S (https://epsg.io/32737). - case epsg32737 = 32737 + public static let epsg32737 = Projection.builtin(srid: 32737) /// EPSG:32738 - UTM zone 38S (https://epsg.io/32738). - case epsg32738 = 32738 + public static let epsg32738 = Projection.builtin(srid: 32738) /// EPSG:32739 - UTM zone 39S (https://epsg.io/32739). - case epsg32739 = 32739 + public static let epsg32739 = Projection.builtin(srid: 32739) /// EPSG:32740 - UTM zone 40S (https://epsg.io/32740). - case epsg32740 = 32740 + public static let epsg32740 = Projection.builtin(srid: 32740) /// EPSG:32741 - UTM zone 41S (https://epsg.io/32741). - case epsg32741 = 32741 + public static let epsg32741 = Projection.builtin(srid: 32741) /// EPSG:32742 - UTM zone 42S (https://epsg.io/32742). - case epsg32742 = 32742 + public static let epsg32742 = Projection.builtin(srid: 32742) /// EPSG:32743 - UTM zone 43S (https://epsg.io/32743). - case epsg32743 = 32743 + public static let epsg32743 = Projection.builtin(srid: 32743) /// EPSG:32744 - UTM zone 44S (https://epsg.io/32744). - case epsg32744 = 32744 + public static let epsg32744 = Projection.builtin(srid: 32744) /// EPSG:32745 - UTM zone 45S (https://epsg.io/32745). - case epsg32745 = 32745 + public static let epsg32745 = Projection.builtin(srid: 32745) /// EPSG:32746 - UTM zone 46S (https://epsg.io/32746). - case epsg32746 = 32746 + public static let epsg32746 = Projection.builtin(srid: 32746) /// EPSG:32747 - UTM zone 47S (https://epsg.io/32747). - case epsg32747 = 32747 + public static let epsg32747 = Projection.builtin(srid: 32747) /// EPSG:32748 - UTM zone 48S (https://epsg.io/32748). - case epsg32748 = 32748 + public static let epsg32748 = Projection.builtin(srid: 32748) /// EPSG:32749 - UTM zone 49S (https://epsg.io/32749). - case epsg32749 = 32749 + public static let epsg32749 = Projection.builtin(srid: 32749) /// EPSG:32750 - UTM zone 50S (https://epsg.io/32750). - case epsg32750 = 32750 + public static let epsg32750 = Projection.builtin(srid: 32750) /// EPSG:32751 - UTM zone 51S (https://epsg.io/32751). - case epsg32751 = 32751 + public static let epsg32751 = Projection.builtin(srid: 32751) /// EPSG:32752 - UTM zone 52S (https://epsg.io/32752). - case epsg32752 = 32752 + public static let epsg32752 = Projection.builtin(srid: 32752) /// EPSG:32753 - UTM zone 53S (https://epsg.io/32753). - case epsg32753 = 32753 + public static let epsg32753 = Projection.builtin(srid: 32753) /// EPSG:32754 - UTM zone 54S (https://epsg.io/32754). - case epsg32754 = 32754 + public static let epsg32754 = Projection.builtin(srid: 32754) /// EPSG:32755 - UTM zone 55S (https://epsg.io/32755). - case epsg32755 = 32755 + public static let epsg32755 = Projection.builtin(srid: 32755) /// EPSG:32756 - UTM zone 56S (https://epsg.io/32756). - case epsg32756 = 32756 + public static let epsg32756 = Projection.builtin(srid: 32756) /// EPSG:32757 - UTM zone 57S (https://epsg.io/32757). - case epsg32757 = 32757 + public static let epsg32757 = Projection.builtin(srid: 32757) /// EPSG:32758 - UTM zone 58S (https://epsg.io/32758). - case epsg32758 = 32758 + public static let epsg32758 = Projection.builtin(srid: 32758) /// EPSG:32759 - UTM zone 59S (https://epsg.io/32759). - case epsg32759 = 32759 + public static let epsg32759 = Projection.builtin(srid: 32759) /// EPSG:32760 - UTM zone 60S (https://epsg.io/32760). - case epsg32760 = 32760 + public static let epsg32760 = Projection.builtin(srid: 32760) + + // MARK: - Initialization + + /// Creates a projection without registry validation. + /// + /// Only used with definitions from the registry; the SRID-info `init` + /// and ``Projection/register(_:)`` validate before this is reached. + /// Constructing arbitrary combinations is a programming error: the + /// SRID and the definition must describe the same projection so that + /// the registry's identity guarantees hold. + internal init(uncheckedSrid srid: Int, definition: any ProjectionDefinition) { + self.sridStorage = srid + self.definition = definition + } + + /// The definition carrying this projection's transform math and + /// metadata - captured at construction so that hot paths resolve + /// without registry lookups. + internal let definition: any ProjectionDefinition + + /// Creates a built-in projection by canonical SRID. + /// + /// Internal factory for the built-in static constants; asserts that + /// the SRID is registered. + internal static func builtin(srid: Int) -> Projection { + guard let definition = ProjectionRegistry.definition(forSrid: srid) else { + preconditionFailure("Built-in projection \(srid) is not registered") + } + return Projection(uncheckedSrid: srid, definition: definition) + } /// Initialize a Projection with a SRID number. /// @@ -269,46 +310,47 @@ public enum Projection: /// - srid: The SRID number (e.g. 4326, 3857) /// - Returns: A `Projection`, or `nil` if the SRID is not supported public init?(srid: Int) { - switch srid { - // A placeholder for 'No SRID' - case 0: self = .noSRID - case 102_100, 102_113, 900_913, 3587, 3785, 3857, 41001, 54004: self = .epsg3857 - case 4326: self = .epsg4326 - case 4978: self = .epsg4978 - case 3395: self = .epsg3395 - case 32662: self = .epsg32662 - // UTM zones 1-60, northern and southern. - case 32_601 ... 32_660, 32_701 ... 32_760: - self = Projection(rawValue: srid) ?? .noSRID - default: return nil - } + guard let canonical = Self.registeredSrid(for: srid) else { return nil } + self = Projection.builtin(srid: canonical) + } + + /// Maps an SRID (or one of the known aliases) to the canonical SRID. + internal static func registeredSrid(for srid: Int) -> Int? { + let canonical = sridAliases[srid] ?? srid + guard ProjectionRegistry.isRegisteredSrid(canonical) else { return nil } + return canonical } + /// Historical web-mercator aliases for EPSG:3857. + private static let sridAliases: [Int: Int] = [ + 102_100: 3857, + 102_113: 3857, + 900_913: 3857, + 3587: 3857, + 3785: 3857, + 41001: 3857, + 54004: 3857, + ] + /// Initialize a Projection from a WKT projection string (e.g. from a `.prj` file). /// /// UTM zones are identified through their zone token first (e.g. /// `PROJCS["WGS_1984_UTM_Zone_19N",...]`, central meridian parameter /// cross-validated when present), since such strings mention - /// `Transverse_Mercator` — which would otherwise wrongly match the + /// `Transverse_Mercator` - which would otherwise wrongly match the /// generic EPSG:3395 Mercator fragments. /// /// The string is then matched against the WKT patterns registered by the /// library's projections (in registry order, first match wins): - /// - EPSG:3857 — `PROJCS["...Pseudo-Mercator..."...]` - /// - EPSG:3395 — `PROJCS["...Mercator..."...]` (without "Pseudo") - /// - EPSG:32662 — `PROJCS["...Plate Carree..."...]` - /// - EPSG:4326 — `GEOGCS["...WGS 84..."...]` or `GEOGCS["...WGS_1984..."...]` - /// - EPSG:4978 — `GEOCCS["...WGS 84..."...]` or `GEOCCS["...WGS_1984..."...]` + /// - EPSG:3857 - `PROJCS["...Pseudo-Mercator..."...]` + /// - EPSG:3395 - `PROJCS["...Mercator..."...]` (without "Pseudo") + /// - EPSG:32662 - `PROJCS["...Plate Carree..."...]` + /// - EPSG:4326 - `GEOGCS["...WGS 84..."...]` or `GEOGCS["...WGS_1984..."...]` + /// - EPSG:4978 - `GEOCCS["...WGS 84..."...]` or `GEOCCS["...WGS_1984..."...]` /// /// - Parameter wkt: A WKT projection string /// - Returns: A `Projection`, or `nil` if the string is not recognised public init?(wkt: String) { - // A UTM zone token identifies a string unambiguously as UTM, so it wins - // over projection name fragments — UTM `.prj` strings mention - // "Transverse_Mercator", which would otherwise wrongly match the - // generic EPSG:3395 fragments. When a zone token is present but the - // identification is malformed or contradicted by its central meridian, - // the string is rejected rather than matched to an unrelated projection. if UtmWktIdentification.hasUtmZoneToken(in: wkt) { guard let utmProjection = UtmWktIdentification.projection(in: wkt) else { return nil @@ -323,17 +365,64 @@ public enum Projection: self = definition.projection } - /// The receiver's SRID number. - public var srid: Int { - self.rawValue + /// Creates a UTM zone projection from its zone number and hemisphere. + /// + /// - Parameters: + /// - utmZone: The UTM zone number (`1 ... 60`) + /// - hemisphere: The hemisphere the zone covers + /// - Returns: The zone projection, or `nil` for an invalid zone number + public init?(utmZone: Int, hemisphere: UtmHemisphere) { + guard utmZone >= 1, utmZone <= 60 else { return nil } + + let srid = hemisphere == .north ? 32_600 + utmZone : 32_700 + utmZone + self.init(srid: srid) } + // MARK: - Identity + /// A human readable description of the receiver. public var description: String { - switch self { - case .noSRID: return "No SRID" - default: return "EPSG:" + String(rawValue) + srid == 0 ? "No SRID" : "EPSG:" + String(srid) + } + + /// The SRID number of the projection (e.g. 4326, 3857). + public var srid: Int { + sridStorage + } + + private let sridStorage: Int + + // MARK: - Equatable/Hashable + + /// Two projections are equal when their SRIDs are equal. + public static func == (lhs: Projection, rhs: Projection) -> Bool { + lhs.srid == rhs.srid + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(srid) + } + + // MARK: - Codable + + /// Decodes the raw SRID number, which must be known to the registry. + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let srid = try container.decode(Int.self) + + guard let projection = Projection(srid: srid) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown projection SRID \(srid)")) } + self = projection + } + + /// Encodes the raw SRID number. + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(srid) } } @@ -342,25 +431,10 @@ public enum Projection: extension Projection { - /// The receiver's semantic category. + /// The receiver's semantic category, resolved from the projection's + /// captured definition. public var kind: ProjectionKind { - switch self { - case .noSRID: .undefined - case .epsg3857, .epsg3395, .epsg32662: .planar - case .epsg4326: .geographic - case .epsg4978: .geocentric - case .epsg32601, .epsg32602, .epsg32603, .epsg32604, .epsg32605, .epsg32606, .epsg32607, .epsg32608, .epsg32609, .epsg32610, .epsg32611, .epsg32612, - .epsg32613, .epsg32614, .epsg32615, .epsg32616, .epsg32617, .epsg32618, .epsg32619, .epsg32620, .epsg32621, .epsg32622, .epsg32623, .epsg32624, - .epsg32625, .epsg32626, .epsg32627, .epsg32628, .epsg32629, .epsg32630, .epsg32631, .epsg32632, .epsg32633, .epsg32634, .epsg32635, .epsg32636, - .epsg32637, .epsg32638, .epsg32639, .epsg32640, .epsg32641, .epsg32642, .epsg32643, .epsg32644, .epsg32645, .epsg32646, .epsg32647, .epsg32648, - .epsg32649, .epsg32650, .epsg32651, .epsg32652, .epsg32653, .epsg32654, .epsg32655, .epsg32656, .epsg32657, .epsg32658, .epsg32659, .epsg32660, - .epsg32701, .epsg32702, .epsg32703, .epsg32704, .epsg32705, .epsg32706, .epsg32707, .epsg32708, .epsg32709, .epsg32710, .epsg32711, .epsg32712, - .epsg32713, .epsg32714, .epsg32715, .epsg32716, .epsg32717, .epsg32718, .epsg32719, .epsg32720, .epsg32721, .epsg32722, .epsg32723, .epsg32724, - .epsg32725, .epsg32726, .epsg32727, .epsg32728, .epsg32729, .epsg32730, .epsg32731, .epsg32732, .epsg32733, .epsg32734, .epsg32735, .epsg32736, - .epsg32737, .epsg32738, .epsg32739, .epsg32740, .epsg32741, .epsg32742, .epsg32743, .epsg32744, .epsg32745, .epsg32746, .epsg32747, .epsg32748, - .epsg32749, .epsg32750, .epsg32751, .epsg32752, .epsg32753, .epsg32754, .epsg32755, .epsg32756, .epsg32757, .epsg32758, .epsg32759, .epsg32760: - .planar - } + definition.kind } /// `true` if the receiver uses angular (degree-based) coordinates. @@ -387,25 +461,7 @@ extension Projection { /// around (±180° for EPSG:4326, ±`originShift` meters for EPSG:3857), /// or `nil` if the horizontal axis does not wrap. public var wraparoundExtent: Double? { - switch self { - case .noSRID, .epsg4978: - nil - case .epsg3857, .epsg3395: - GISTool.originShift - case .epsg4326, .epsg32662: - 180.0 - case .epsg32601, .epsg32602, .epsg32603, .epsg32604, .epsg32605, .epsg32606, .epsg32607, .epsg32608, .epsg32609, .epsg32610, .epsg32611, .epsg32612, - .epsg32613, .epsg32614, .epsg32615, .epsg32616, .epsg32617, .epsg32618, .epsg32619, .epsg32620, .epsg32621, .epsg32622, .epsg32623, .epsg32624, - .epsg32625, .epsg32626, .epsg32627, .epsg32628, .epsg32629, .epsg32630, .epsg32631, .epsg32632, .epsg32633, .epsg32634, .epsg32635, .epsg32636, - .epsg32637, .epsg32638, .epsg32639, .epsg32640, .epsg32641, .epsg32642, .epsg32643, .epsg32644, .epsg32645, .epsg32646, .epsg32647, .epsg32648, - .epsg32649, .epsg32650, .epsg32651, .epsg32652, .epsg32653, .epsg32654, .epsg32655, .epsg32656, .epsg32657, .epsg32658, .epsg32659, .epsg32660, - .epsg32701, .epsg32702, .epsg32703, .epsg32704, .epsg32705, .epsg32706, .epsg32707, .epsg32708, .epsg32709, .epsg32710, .epsg32711, .epsg32712, - .epsg32713, .epsg32714, .epsg32715, .epsg32716, .epsg32717, .epsg32718, .epsg32719, .epsg32720, .epsg32721, .epsg32722, .epsg32723, .epsg32724, - .epsg32725, .epsg32726, .epsg32727, .epsg32728, .epsg32729, .epsg32730, .epsg32731, .epsg32732, .epsg32733, .epsg32734, .epsg32735, .epsg32736, - .epsg32737, .epsg32738, .epsg32739, .epsg32740, .epsg32741, .epsg32742, .epsg32743, .epsg32744, .epsg32745, .epsg32746, .epsg32747, .epsg32748, - .epsg32749, .epsg32750, .epsg32751, .epsg32752, .epsg32753, .epsg32754, .epsg32755, .epsg32756, .epsg32757, .epsg32758, .epsg32759, .epsg32760: - nil - } + definition.wraparoundExtent } /// Converts a length in meters to the receiver's native coordinate units. @@ -437,7 +493,9 @@ private enum UtmWktIdentification { /// string also carries a `Central_Meridian` parameter, it must agree /// with the identified zone. static func projection(in wkt: String) -> Projection? { + // A UTM zone token, e.g. `UTM Zone 19N` or `UTM_Zone_5s`. let zoneRegex = /(?i)(?:UTM|Universal[\s_]+Transverse[\s_]+Mercator)[\s_]*Zone[\s_]*(\d{1,2})[\s_]*([NS])/ + // A central meridian parameter, e.g. `"Central_Meridian",-69`. let centralMeridianRegex = /(?i)Central[\s_]*Meridian["]?\s*,\s*(-?\d+(?:\.\d+)?)/ guard let zoneMatch = wkt.firstMatch(of: zoneRegex) else { return nil } diff --git a/Sources/GISTools/GeoJson/ProjectionDefinition.swift b/Sources/GISTools/GeoJson/ProjectionDefinition.swift index 7cfbf47..5a365e3 100644 --- a/Sources/GISTools/GeoJson/ProjectionDefinition.swift +++ b/Sources/GISTools/GeoJson/ProjectionDefinition.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization /// Internal strategy providing the transform math for a single projection. /// @@ -16,7 +17,7 @@ protocol ProjectionDefinition: Sendable { /// The valid coordinate extent of the projection, or `nil` if the /// coordinate space is unbounded (e.g. geocentric or no SRID). - var validExtent: (minX: Double, minY: Double, maxX: Double, maxY: Double)? { get } + var validExtent: ProjectionExtent? { get } /// A bounding box spanning the whole world in this projection, /// or `nil` if no meaningful world extent exists. @@ -39,6 +40,11 @@ protocol ProjectionDefinition: Sendable { /// - Returns: The coordinate in the receiver's projection func forward(_ coordinate: Coordinate3D) -> Coordinate3D + /// The absolute value beyond which the horizontal axis of the + /// projection wraps around (e.g. ±180° for geographic coordinates), + /// or `nil` if it does not wrap. + var wraparoundExtent: Double? { get } + /// Converts a coordinate from the receiver's projection into EPSG:4326. /// /// - Parameter coordinate: A coordinate in the receiver's projection @@ -47,7 +53,14 @@ protocol ProjectionDefinition: Sendable { } -/// Registry providing the ``ProjectionDefinition`` for each ``Projection``. +/// Registry providing ``ProjectionDefinition`` implementations for +/// registered projections. +/// +/// The registry is **add-only**: the built-in projections are seeded once +/// and custom definitions can be added through ``Projection/register(_:)`` — +/// nothing can be removed or replaced afterwards. Lookups take a consistent +/// snapshot; the internal `Mutex` makes a completed registration visible to +/// all subsequent readers. enum ProjectionRegistry { // MARK: - Definitions @@ -59,57 +72,73 @@ enum ProjectionRegistry { private static let epsg3395Definition = Epsg3395Definition() private static let epsg32662Definition = Epsg32662Definition() - /// All definitions keyed by projection, built eagerly so lookups are - /// constant time and every `Projection` case is covered. + /// All built-in definitions: the 6 base definitions and all UTM zones, + /// keyed by canonical SRID. /// /// `UtmTests` sweeps every UTM SRID and asserts that lookups resolve to /// definitions whose ``ProjectionDefinition/projection`` matches. - private static let definitions: [Projection: any ProjectionDefinition] = { - var map: [Projection: any ProjectionDefinition] = [ - .noSRID: noSridDefinition, - .epsg3857: epsg3857Definition, - .epsg4326: epsg4326Definition, - .epsg4978: epsg4978Definition, - .epsg3395: epsg3395Definition, - .epsg32662: epsg32662Definition, + private static let builtinDefinitions: [Int: any ProjectionDefinition] = { + var map: [Int: any ProjectionDefinition] = [ + 0: noSridDefinition, + 3857: epsg3857Definition, + 4326: epsg4326Definition, + 4978: epsg4978Definition, + 3395: epsg3395Definition, + 32_662: epsg32662Definition, ] - for srid in 32_601 ... 32_760 { - guard let projection = Projection(srid: srid), - let utmDefinition = UtmDefinition.definition(for: projection) - else { continue } - map[projection] = utmDefinition + for srid in 32_601 ... 32_660 { + map[srid] = UtmDefinition.definition(forSrid: srid) + } + for srid in 32_701 ... 32_760 { + map[srid] = UtmDefinition.definition(forSrid: srid) } return map }() - /// The definitions with WKT patterns, in match order. More specific - /// patterns must come before more generic ones (e.g. EPSG:3857's - /// "Pseudo-Mercator" before EPSG:3395's "Mercator"). UTM zones register - /// no WKT patterns. - private static var wktDefinitions: [any ProjectionDefinition] { - [ - epsg3857Definition, - epsg3395Definition, - epsg32662Definition, - epsg4978Definition, - epsg4326Definition, - ] + /// The mutable registry state guarded by the mutex. + private struct State { + /// Definitions keyed by canonical SRID. + var definitions: [Int: any ProjectionDefinition] + + /// WKT probes in match order: the ordered built-in list first + /// (specific patterns before generic ones), custom registrations + /// last. + var wktDefinitions: [any ProjectionDefinition] } + private static let builtinWktDefinitions: [any ProjectionDefinition] = [ + epsg3857Definition, + epsg3395Definition, + epsg32662Definition, + epsg4978Definition, + epsg4326Definition, + ] + + private static let state = State( + definitions: builtinDefinitions, + wktDefinitions: builtinWktDefinitions) + + private static let stateMutex = Mutex(state) + // MARK: - Lookup - /// The definition implementing the math for a projection. + /// The registered definition for a canonical SRID. /// - /// - Parameter projection: The projection to look up - /// - Returns: The definition for the projection - static func definition(for projection: Projection) -> any ProjectionDefinition { - guard let definition = definitions[projection] else { - assertionFailure("No projection definition registered for \(projection)") - return noSridDefinition + /// - Parameter srid: A canonical SRID (aliases are resolved by ``Projection``) + /// - Returns: The definition, or `nil` if the SRID is not registered + static func definition(forSrid srid: Int) -> (any ProjectionDefinition)? { + stateMutex.withLock { snapshot in + snapshot.definitions[srid] + } + } + + /// Whether a canonical SRID has a registered definition. + static func isRegisteredSrid(_ srid: Int) -> Bool { + stateMutex.withLock { snapshot in + snapshot.definitions[srid] != nil } - return definition } /// The first definition with a WKT alternative fully contained in the string. @@ -117,13 +146,45 @@ enum ProjectionRegistry { /// - Parameter wkt: A WKT projection string /// - Returns: The matching definition, or `nil` if the string is not recognised static func definition(matchingWkt wkt: String) -> (any ProjectionDefinition)? { - wktDefinitions.first { definition in - definition.wktMatchers.contains { alternative in - alternative.allSatisfy { wkt.contains($0) } + stateMutex.withLock { snapshot in + snapshot.wktDefinitions.first { definition in + definition.wktMatchers.contains { alternative in + alternative.allSatisfy { wkt.contains($0) } + } } } } + // MARK: - Registration + + /// Registers a custom definition. Add-only: entries are never removed + /// or replaced. + /// + /// Rejected when the SRID is `<= 0` or already registered; the state + /// stays unchanged in that case. + /// + /// - Parameter definition: The custom projection to register + /// - Returns: `true` when the registration succeeded + static func register(_ definition: CustomProjection) -> Bool { + guard definition.srid > 0 else { return false } + + return stateMutex.withLock { state in + guard state.definitions[definition.srid] == nil else { return false } + + let registered = RegisteredCustomDefinition(custom: definition) + var definitions = state.definitions + definitions[definition.srid] = registered + + var wktDefinitions = state.wktDefinitions + if definition.wktMatchers.isNotEmpty { + wktDefinitions.append(registered) + } + + state = State(definitions: definitions, wktDefinitions: wktDefinitions) + return true + } + } + } // MARK: - EPSG:4326 @@ -131,9 +192,10 @@ enum ProjectionRegistry { /// Identity math for the EPSG:4326 pivot projection. struct Epsg4326Definition: ProjectionDefinition { - var projection: Projection { .epsg4326 } + var projection: Projection { Projection(uncheckedSrid: 4326, definition: self) } var kind: ProjectionKind { .geographic } + var wraparoundExtent: Double? { 180.0 } var wktMatchers: [[String]] { [ ["GEOGCS", "WGS 84"], @@ -141,8 +203,8 @@ struct Epsg4326Definition: ProjectionDefinition { ] } - var validExtent: (minX: Double, minY: Double, maxX: Double, maxY: Double)? { - (minX: -180.0, minY: -90.0, maxX: 180.0, maxY: 90.0) + var validExtent: ProjectionExtent? { + ProjectionExtent(minX: -180.0, minY: -90.0, maxX: 180.0, maxY: 90.0) } var worldBoundingBox: BoundingBox? { @@ -168,17 +230,22 @@ struct Epsg4326Definition: ProjectionDefinition { /// Web Mercator math for EPSG:3857. struct Epsg3857Definition: ProjectionDefinition { - var projection: Projection { .epsg3857 } + var projection: Projection { Projection(uncheckedSrid: 3857, definition: self) } var kind: ProjectionKind { .planar } + var wraparoundExtent: Double? { GISTool.originShift } var wktMatchers: [[String]] { [ ["PROJCS", "Pseudo-Mercator"], ] } - var validExtent: (minX: Double, minY: Double, maxX: Double, maxY: Double)? { - (minX: -GISTool.originShift, minY: -GISTool.originShift, maxX: GISTool.originShift, maxY: GISTool.originShift) + var validExtent: ProjectionExtent? { + ProjectionExtent( + minX: -GISTool.originShift, + minY: -GISTool.originShift, + maxX: GISTool.originShift, + maxY: GISTool.originShift) } var worldBoundingBox: BoundingBox? { @@ -220,9 +287,10 @@ struct Epsg3857Definition: ProjectionDefinition { /// Geocentric (ECEF) math for EPSG:4978. struct Epsg4978Definition: ProjectionDefinition { - var projection: Projection { .epsg4978 } + var projection: Projection { Projection(uncheckedSrid: 4978, definition: self) } var kind: ProjectionKind { .geocentric } + var wraparoundExtent: Double? { nil } var wktMatchers: [[String]] { [ ["GEOCCS", "WGS 84"], @@ -231,7 +299,7 @@ struct Epsg4978Definition: ProjectionDefinition { } /// Geocentric coordinates are unbounded. - var validExtent: (minX: Double, minY: Double, maxX: Double, maxY: Double)? { + var validExtent: ProjectionExtent? { nil } @@ -353,17 +421,22 @@ struct Epsg3395Definition: ProjectionDefinition { /// towards ±90°). private static let maxExtent = 20_048_966.104014604 - var projection: Projection { .epsg3395 } + var projection: Projection { Projection(uncheckedSrid: 3395, definition: self) } var kind: ProjectionKind { .planar } + var wraparoundExtent: Double? { GISTool.originShift } var wktMatchers: [[String]] { [ ["PROJCS", "Mercator"], ] } - var validExtent: (minX: Double, minY: Double, maxX: Double, maxY: Double)? { - (minX: -GISTool.originShift, minY: -Self.maxExtent, maxX: GISTool.originShift, maxY: Self.maxExtent) + var validExtent: ProjectionExtent? { + ProjectionExtent( + minX: -GISTool.originShift, + minY: -Self.maxExtent, + maxX: GISTool.originShift, + maxY: Self.maxExtent) } var worldBoundingBox: BoundingBox? { @@ -431,17 +504,18 @@ struct Epsg3395Definition: ProjectionDefinition { /// Plate Carrée coordinates are NOT metric despite the planar kind. struct Epsg32662Definition: ProjectionDefinition { - var projection: Projection { .epsg32662 } + var projection: Projection { Projection(uncheckedSrid: 32_662, definition: self) } var kind: ProjectionKind { .planar } + var wraparoundExtent: Double? { 180.0 } var wktMatchers: [[String]] { [ ["PROJCS", "Plate Carree"], ] } - var validExtent: (minX: Double, minY: Double, maxX: Double, maxY: Double)? { - (minX: -180.0, minY: -90.0, maxX: 180.0, maxY: 90.0) + var validExtent: ProjectionExtent? { + ProjectionExtent(minX: -180.0, minY: -90.0, maxX: 180.0, maxY: 90.0) } var worldBoundingBox: BoundingBox? { @@ -480,15 +554,16 @@ struct Epsg32662Definition: ProjectionDefinition { /// values verbatim as well. struct NoSridDefinition: ProjectionDefinition { - var projection: Projection { .noSRID } + var projection: Projection { Projection(uncheckedSrid: 0, definition: self) } var kind: ProjectionKind { .undefined } + var wraparoundExtent: Double? { nil } var wktMatchers: [[String]] { [] } /// Coordinates without an SRID have no defined extent. - var validExtent: (minX: Double, minY: Double, maxX: Double, maxY: Double)? { + var validExtent: ProjectionExtent? { nil } diff --git a/Sources/GISTools/GeoJson/ProjectionExtent.swift b/Sources/GISTools/GeoJson/ProjectionExtent.swift new file mode 100644 index 0000000..fbf8bcd --- /dev/null +++ b/Sources/GISTools/GeoJson/ProjectionExtent.swift @@ -0,0 +1,32 @@ + +/// The valid extent of a projection. +/// +/// Coordinates inside the extent are within the projection's usable range; +/// ``Coordinate3D/isValid`` and ``Coordinate3D/clamped()`` use it. +public struct ProjectionExtent: + Hashable, + Sendable +{ + + /// The smallest x (easting/longitude) value. + public let minX: Double + /// The smallest y (northing/latitude) value. + public let minY: Double + /// The largest x (easting/longitude) value. + public let maxX: Double + /// The largest y (northing/latitude) value. + public let maxY: Double + + public init( + minX: Double, + minY: Double, + maxX: Double, + maxY: Double + ) { + self.minX = minX + self.minY = minY + self.maxX = maxX + self.maxY = maxY + } + +} diff --git a/Sources/GISTools/GeoJson/UtmDefinition.swift b/Sources/GISTools/GeoJson/UtmDefinition.swift index 867cf6c..13f7911 100644 --- a/Sources/GISTools/GeoJson/UtmDefinition.swift +++ b/Sources/GISTools/GeoJson/UtmDefinition.swift @@ -32,8 +32,15 @@ public enum UtmHemisphere: Sendable { /// and matches the use expected of UTM coordinates. struct UtmDefinition: ProjectionDefinition { - /// The projection case of the zone (EPSG:326xx/327xx). - let projection: Projection + /// The projection of the zone (EPSG:326xx/327xx), carrying this + /// definition itself. + var projection: Projection { + Projection(uncheckedSrid: srid, definition: self) + } + + private var srid: Int { + hemisphere == .north ? 32_600 + zone : 32_700 + zone + } /// The UTM zone number, 1–60. let zone: Int @@ -41,6 +48,15 @@ struct UtmDefinition: ProjectionDefinition { /// The hemisphere the zone covers. let hemisphere: UtmHemisphere + /// Creates a UTM definition from its zone number and hemisphere. + init( + zone: Int, + hemisphere: UtmHemisphere + ) { + self.zone = zone + self.hemisphere = hemisphere + } + /// The UTM central scale factor. private static let k0 = 0.9996 @@ -55,13 +71,12 @@ struct UtmDefinition: ProjectionDefinition { var kind: ProjectionKind { .planar } /// The EPSG-defined sector extent of the zone. - var validExtent: (minX: Double, minY: Double, maxX: Double, maxY: Double)? { - ( + var validExtent: ProjectionExtent? { + ProjectionExtent( minX: 100_000.0, minY: 0.0, maxX: 900_000.0, - maxY: 10_000_000.0 - ) + maxY: 10_000_000.0) } /// The sector of the zone in its own coordinates (the full extent of @@ -73,6 +88,11 @@ struct UtmDefinition: ProjectionDefinition { northEast: Coordinate3D(x: extent.maxX, y: extent.maxY, projection: projection)) } + /// UTM zone coordinates never wrap. + var wraparoundExtent: Double? { + nil + } + /// UTM zones have no unique WKT fragment. var wktMatchers: [[String]] { [] @@ -86,8 +106,14 @@ struct UtmDefinition: ProjectionDefinition { /// - Parameter projection: The projection to look up /// - Returns: The definition for the zone, or `nil` if the projection is not UTM static func definition(for projection: Projection) -> UtmDefinition? { - let srid = projection.rawValue + definition(forSrid: projection.srid) + } + /// Returns the UTM definition for a canonical SRID. + /// + /// - Parameter srid: An EPSG:326xx/327xx SRID + /// - Returns: The definition for the zone, or `nil` if the SRID is not UTM + static func definition(forSrid srid: Int) -> UtmDefinition? { let isNorthern = srid >= 32_601 && srid <= 32_660 let isSouthern = srid >= 32_701 && srid <= 32_760 guard isNorthern || isSouthern else { return nil } @@ -96,7 +122,7 @@ struct UtmDefinition: ProjectionDefinition { let zone = isNorthern ? srid - 32_600 : srid - 32_700 guard zone >= 1, zone <= 60 else { return nil } - return UtmDefinition(projection: projection, zone: zone, hemisphere: hemisphere) + return UtmDefinition(zone: zone, hemisphere: hemisphere) } // MARK: - Conversions @@ -224,18 +250,5 @@ extension Projection { UtmDefinition.definition(for: self)?.hemisphere } - /// Creates a UTM zone projection from its zone number and hemisphere. - /// - /// - Parameters: - /// - utmZone: The UTM zone number (`1 ... 60`) - /// - hemisphere: The hemisphere the zone covers - /// - Returns: The zone projection, or `nil` for an invalid zone number - public init?(utmZone: Int, hemisphere: UtmHemisphere) { - guard utmZone >= 1, utmZone <= 60 else { return nil } - - let srid = hemisphere == .north ? 32_600 + utmZone : 32_700 + utmZone - guard let projection = Projection(srid: srid) else { return nil } - self = projection - } } diff --git a/Tests/GISToolsTests/GeoJson/CustomProjectionTests.swift b/Tests/GISToolsTests/GeoJson/CustomProjectionTests.swift new file mode 100644 index 0000000..fa990c5 --- /dev/null +++ b/Tests/GISToolsTests/GeoJson/CustomProjectionTests.swift @@ -0,0 +1,212 @@ +#if canImport(CoreLocation) +import CoreLocation +#endif +import Foundation +@testable import GISTools +import Testing +import Synchronization + +/// Tests for the custom projection registration API. +struct CustomProjectionTests { + + /// SRID pool for this suite: unique per test call, since registration is + /// add-only and process-global. + private static let sridCounter = Mutex(900_500) + + private static func nextSrid(_ count: Int = 1) -> Int { + sridCounter.withLock { value -> Int in + let start = value + value += count + return start + } + } + + /// A simple planar "scaled degrees" projection: degrees × 1_000, + /// round-tripping exactly. + private static func scaledDegrees(srid: Int) -> CustomProjection { + CustomProjection( + srid: srid, + kind: .planar, + wraparoundExtent: 180_000.0, + validExtent: ProjectionExtent( + minX: -180_000.0, + minY: -90_000.0, + maxX: 180_000.0, + maxY: 90_000.0), + worldBoundingBox: nil, + wktMatchers: [["PROJCS", "Scaled Test Grid \(srid)"]], + forward: { coordinate in + Coordinate3D( + latitude: coordinate.latitude * 1_000.0, + longitude: coordinate.longitude * 1_000.0, + altitude: coordinate.altitude, + m: coordinate.m) + }, + inverse: { coordinate in + Coordinate3D( + latitude: coordinate.latitude / 1_000.0, + longitude: coordinate.longitude / 1_000.0, + altitude: coordinate.altitude, + m: coordinate.m) + }) + } + + // Validates a basic registration and end-to-end projections. + @Test + func registrationAndRoundTrip() async throws { + let srid = Self.nextSrid() + let custom = Self.scaledDegrees(srid: srid) + #expect(Projection.register(custom)) + #expect(Projection.register(custom) == false) // add-only + + let projection = try #require(Projection(srid: srid)) + #expect(projection.srid == srid) + #expect(projection.kind == .planar) + + let original = Coordinate3D(latitude: 41.0, longitude: -71.0, altitude: 50.0, m: 3.0) + let projected = original.projected(to: projection) + #expect(projected.projection.srid == srid) + #expect(abs(projected.longitude - -71_000.0) < 0.000001) + #expect(abs(projected.latitude - 41_000.0) < 0.000001) + #expect(projected.altitude == 50.0) + #expect(projected.m == 3.0) + + let back = projected.projected(to: .epsg4326) + #expect(abs(back.latitude - 41.0) < 0.000001) + #expect(abs(back.longitude - -71.0) < 0.000001) + + // The next SRID without registration produces nil. + #expect(Projection(srid: srid + 1) == nil) + } + + // Validates that re-registering or illicit SRIDs are rejected. + @Test + func registrationRejections() async throws { + let srid = Self.nextSrid() + #expect(Projection.register(Self.scaledDegrees(srid: srid))) + #expect(Projection.register(Self.scaledDegrees(srid: srid)) == false) + #expect(Projection.register(Self.scaledDegrees(srid: 0)) == false) + #expect(Projection.register(Self.scaledDegrees(srid: -1)) == false) + + // Built-in SRIDs cannot be shadowed. + #expect(Projection.register(Self.scaledDegrees(srid: 4326)) == false) + #expect(Projection.register(Self.scaledDegrees(srid: 32_619)) == false) + #expect(Projection.epsg4326.kind == .geographic) // built-in intact + } + + // Validates registry-driven capabilities: validity, clamping, + // normalization via wraparoundExtent and kind-based dispatch. + @Test + func capabilities() async throws { + let srid = Self.nextSrid() + #expect(Projection.register(Self.scaledDegrees(srid: srid))) + let projection = try #require(Projection(srid: srid)) + + // Out-of-extent coordinates are invalid and clamp to the extent. + let invalid = Coordinate3D( + x: 0.0, + y: 91_000.0, + projection: projection) + #expect(invalid.isValid == false) + let clamped = invalid.clamped() + #expect(clamped.isValid) + #expect(clamped.latitude == 90_000.0) + + // wraparoundExtent-based normalization. + let normalized = Coordinate3D( + x: 180_500.0, + y: 0.0, + projection: projection).normalized() + #expect(abs(normalized.longitude - -179_500.0) < 0.000001) + #expect(projection.wraparoundExtent == 180_000.0) + + // Kind-based distance dispatch (planar => Euclidean). + let origin = Coordinate3D(x: 0.0, y: 0.0, projection: projection) + let point = Coordinate3D(x: 3_000.0, y: 4_000.0, projection: projection) + #expect(abs(origin.distance(from: point) - 5_000.0) < 0.000001) + } + + // Validates WKT matching for custom fragment sets. + @Test + func wktMatching() async throws { + let srid = Self.nextSrid() + #expect(Projection.register(Self.scaledDegrees(srid: srid))) + + let matched = Projection(wkt: "PROJCS[\"Scaled Test Grid \(srid)\"]") + #expect(matched != nil) + #expect(matched?.srid == srid) + #expect(Projection(wkt: "PROJCS[\"Something Unrelated\"]") == nil) + } + + // Validates Codable interplay: encoding emits the SRID; decoding an + // unknown SRID throws and registered SRIDs round trip. + @Test + func codable() async throws { + let srid = Self.nextSrid() + #expect(Projection.register(Self.scaledDegrees(srid: srid))) + let projection = try #require(Projection(srid: srid)) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + let data = try encoder.encode(projection) + #expect(String(data: data, encoding: .utf8) == String(srid)) + + let decoded = try decoder.decode(Projection.self, from: data) + #expect(decoded == projection) + + // Unknown SRIDs throw dataCorrupted on decode. + do { + _ = try decoder.decode(Projection.self, from: Data("899_999".utf8)) + Issue.record("Decoding an unregistered SRID should throw") + } + catch is DecodingError {} + } + + // Concurrent registrations of distinct SRIDs all succeed; the registry + // snapshot remains consistent afterwards. + @Test + func concurrentRegistration() async throws { + let start = Self.nextSrid(8) + let count = 8 + + try await withThrowingTaskGroup(of: Int.self) { group in + for i in 0 ..< count { + group.addTask { + let srid = start + i + let registered = Projection.register( + CustomProjection( + srid: srid, + kind: .planar, + forward: { coordinate in + Coordinate3D( + latitude: coordinate.latitude, + longitude: coordinate.longitude, + altitude: coordinate.altitude, + m: coordinate.m) + }, + inverse: { coordinate in + Coordinate3D( + latitude: coordinate.latitude * 1_000.0, + longitude: coordinate.longitude * 1_000.0, + altitude: coordinate.altitude, + m: coordinate.m) + })) + return registered ? srid : -1 + } + } + + var results: Set = [] + for try await srid in group { + if srid >= 0 { + results.insert(srid) + } + } + #expect(results.count == count) + } + + for i in 0 ..< count { + #expect(Projection(srid: start + i) != nil) + } + } + +} diff --git a/Tests/GISToolsTests/GeoJson/ProjectionBenchmarks.swift b/Tests/GISToolsTests/GeoJson/ProjectionBenchmarks.swift index 7295a4f..b94796b 100644 --- a/Tests/GISToolsTests/GeoJson/ProjectionBenchmarks.swift +++ b/Tests/GISToolsTests/GeoJson/ProjectionBenchmarks.swift @@ -14,20 +14,13 @@ import Testing /// benchmark compares the registry dispatch overhead against direct math. /// The assertions verify that both produce the same results. /// -/// Results (release build, 2026-09, per coordinate): -/// - baseline direct math: ~5 ns -/// - registry lookup alone: ~30 ns (one dictionary read) -/// - projection math through a fixed definition: ~9 ns -/// - full `projected(to:)` path: ~125 ns -/// -/// The full-path cost is bounded by the per-call dictionary lookup(s) and -/// existential dispatch; it is not fully closeable to the inlined baseline -/// without a non-existential registry redesign. The overhead only matters -/// for bulk reprojection (>100k points); algorithm hot paths reproject per -/// geometry feature rather than per field, so this is acceptable. If bulk -/// reprojection of large point clouds ever becomes a use case, consider -/// amortizing lookups with a `project(coordinates:)` batch API or -/// non-existential dispatch (tracked separately). +/// Results (release build, 2026-09, per coordinate): the full +/// `projected(to:)` path is within ~1.5-2x of the direct-math baseline. +/// Since the projection-genericity restructure, ``Projection`` values +/// capture their definition at construction; hot paths therefore resolve +/// through the captured definition and never touch the registry. The +/// registry lookup benchmark documents the cold-path cost a +/// `Projection(srid:)` resolution pays. /// /// Numbers print to the test output (visible with `swift test --verbose`). /// All tests are skipped in CI. @@ -170,21 +163,22 @@ struct ProjectionBenchmarks { extension ProjectionBenchmarks { - // Isolates the registry lookup cost (two dictionary reads per projected - // call would be double this). + // Isolates the registry lookup cost. This is the cold-path cost a + // single Projection(srid:) resolution pays, not a hot-path cost: hot + // paths resolve through the definition captured in the Projection value. @Test(.disabled(if: CIHelper.isRunningInCI, "Skipping performance test in CI")) func performanceRegistryLookupAlone() { let duration = Self.timePerIteration(Self.coordinates, { _ in - let _ = ProjectionRegistry.definition(for: .epsg3857) + let _ = ProjectionRegistry.definition(forSrid: 3857) }) print("[ProjectionBenchmark] lookup alone: \(String(format: "%.3f", duration))ms / 10k") } - // Isolates the full projection cost of the two direct fragments from the - // above for a well-known direction (4326 pivot identity + one virtual call). + // Isolates the direct projection call cost against a fixed definition + // (same math as the full path minus the struct plumbing). @Test(.disabled(if: CIHelper.isRunningInCI, "Skipping performance test in CI")) func performanceDefinitionAndForward() { - let definition = ProjectionRegistry.definition(for: .epsg3857) + let definition = Projection.builtin(srid: 3857).definition let duration = Self.timePerIteration(Self.coordinates, { coordinate in let _ = definition.forward(coordinate) }) diff --git a/Tests/GISToolsTests/GeoJson/UtmDefinitionTests.swift b/Tests/GISToolsTests/GeoJson/UtmDefinitionTests.swift index f6bfbe4..a1c8dd7 100644 --- a/Tests/GISToolsTests/GeoJson/UtmDefinitionTests.swift +++ b/Tests/GISToolsTests/GeoJson/UtmDefinitionTests.swift @@ -11,7 +11,7 @@ struct UtmDefinitionTests { func sridMapping() async throws { for zone in 1 ... 60 { let northern = try #require(Projection(srid: 32_600 + zone)) - #expect(northern.rawValue == 32_600 + zone) + #expect(northern.srid == 32_600 + zone) let northernDefinition = UtmDefinition.definition(for: northern) #expect(northernDefinition?.zone == zone) #expect(northernDefinition?.hemisphere == .north) @@ -36,15 +36,15 @@ struct UtmDefinitionTests { func registryCoverage() async throws { for srid in [0, 3857, 4326, 4978, 3395, 32662] { let projection = try #require(Projection(srid: srid)) - #expect(ProjectionRegistry.definition(for: projection).projection == projection) + #expect(projection.definition.projection == projection) } for srid in 32_601 ... 32_660 { let projection = try #require(Projection(srid: srid)) - #expect(ProjectionRegistry.definition(for: projection).projection == projection) + #expect(projection.definition.projection == projection) } for srid in 32_701 ... 32_760 { let projection = try #require(Projection(srid: srid)) - #expect(ProjectionRegistry.definition(for: projection).projection == projection) + #expect(projection.definition.projection == projection) } } @@ -84,12 +84,12 @@ struct UtmDefinitionTests { func utmConvenienceApi() async throws { for zone in [1, 19, 33, 60] { let northern = try #require(Projection(utmZone: zone, hemisphere: .north)) - #expect(northern.rawValue == 32_600 + zone) + #expect(northern.srid == 32_600 + zone) #expect(northern.utmZone == zone) #expect(northern.utmHemisphere == .north) let southern = try #require(Projection(utmZone: zone, hemisphere: .south)) - #expect(southern.rawValue == 32_700 + zone) + #expect(southern.srid == 32_700 + zone) #expect(southern.utmZone == zone) #expect(southern.utmHemisphere == .south) }