Repository navigation
Make the projection system generic (phases 1–5 + follow-ups) - #250
Conversation
Phase 1 of making the projection system generic: replace per-EPSG-code branches with dispatch on a semantic projection category, so that new projections registered in a later phase automatically get sensible default behavior. New API: - ProjectionKind enum (geographic / planar / geocentric / undefined) - Projection.kind, isGeographic, isPlanar, isGeocentric, hasSRID - Projection.wraparoundExtent (±180° for 4326, ±originShift for 3857) - Projection.crsLength(fromMeters:) for meter-to-CRS-unit conversion Refactored to kind/capability dispatch (behavior-neutral): - Geodesic ops (distance, bearing, destination, midpoint, rhumb *) - AntimeridianCutting: duplicated 4326/3857 helpers collapsed into implementations parameterized by wraparoundExtent - BoundingBox date-line splits, padding, expansion, size, normalize - Antimeridian-span checks, unit conversions, grid cell sizing across ~20 algorithm files Also adds a .swiftformat configuration file. Per-CRS switch matrices (Coordinate3D.projected, coders, MapTile) are intentionally unchanged; they become registry data in phase 2.
Phase 2 of making the projection system generic: extract the inline transform math into a ProjectionDefinition strategy protocol and route all conversions through a registry, keeping EPSG:4326 as the pivot. - ProjectionDefinition protocol: forward/inverse relative to the EPSG:4326 pivot, plus validExtent and worldBoundingBox metadata - Definitions for EPSG:4326 (identity), EPSG:3857 (Web Mercator), EPSG:4978 (ECEF, with the geodetic/ECEF helpers moved here) and noSRID (null math) - ProjectionRegistry maps a Projection to its definition - Coordinate3D.projected(to:) now dispatches through the registry; clamped() uses the definition's validExtent; latitudeProjected/ longitudeProjected derive from the full projection - BoundingBox.expanded(byDistance:) and Random's world box use the registry metadata instead of per-CRS switches noSRID keeps its historical semantics, now documented and pinned by tests: verbatim copy to .noSRID, verbatim relabel to EPSG:4326 and EPSG:3857 (planar-meter convention), interpreted as EPSG:4326 into EPSG:4978. New tests: round trips across all projection pairs, pivot routing, noSRID semantics, per-axis helper consistency, altitude/m preservation and clamped() extents.
Phase 3 of making the projection system generic: prove the registry
architecture by adding two new WGS84 projections.
- EPSG:3395 (WGS 84 / World Mercator): ellipsoidal Mercator with
Snyder forward formulas and an iterative inverse (the conformal
latitude iteration). Wraps at ±originShift like EPSG:3857, but y
values differ away from the equator. Valid y extent is the
EPSG-registered bound of ±20_048_966.104014604.
- EPSG:32662 (WGS 84 / Plate Carree): identity x/y in degrees,
wraps at ±180°.
Both are registered with SRID lookup, description, WKT patterns
("Pseudo-Mercator" vs. "Mercator" now disambiguated between 3857
and 3395), planar ProjectionKind and wraparound extents. All
algorithms, BoundingBox, MapTile and the pixel conversions pick
them up automatically through the ProjectionKind/registry
abstractions - no per-algorithm changes needed.
The WKB/WKT/TWKB coordinate constructors collapsed their per-
projection nested switches into one generic expression (behavior-
identical for all existing cases); new projections now decode and
encode correctly with embedded SRIDs.
Tests: conversion values computed independently with the Snyder
formulas, round trips across all five projections, antimeridian
cutting, distance/boundingBox/contains, normalize/clamp,
destination/bearing via the EPSG:4326 pivot, and WKB round trips
in the new projections.
Phase 4 of making the projection system generic: move WKT CRS identification into the projection registry and round out test coverage for the coder and MapTile paths. - ProjectionDefinition gains wktMatchers: [[String]] - each inner list is an alternative fragment set, every fragment of one set must be contained for a match. This preserves the previous A && (B || C) semantics (e.g. "WGS 84" or "WGS_1984") exactly. - ProjectionRegistry probes definitions in registry order (specific patterns first: EPSG:3857 "Pseudo-Mercator" before EPSG:3395 "Mercator"); Projection.init?(wkt:) now delegates to it. noSRID registers no patterns and never matches. - MapTile tests: tile bounding boxes in EPSG:3395/EPSG:32662 verified against manual reprojection of the EPSG:4326 bounds, plus centerCoordinate round trips (the generic pixel-projection path already handles the new projections). - Coder tests: WKT round trips with SRID embedded in the string, TWKB decode fixtures for the new projections (decode-only API), and ProjectionWktMatcherTests covering the 3857/3395 disambiguation, both geographic fragment alternatives and unrecognized strings (GEOGCS["ETRS89"] -> nil). - Test rename: NewProjectionAlgorithmTests.swift split into projection-named files (Epsg3395Tests.swift, Epsg32662Tests.swift) so the names stay meaningful as more projections are added. Behavior note: WKTCoder.decode with an explicit sourceSrid does not consume an embedded "SRID=...;" prefix - decoders of strings produced by WKTCoder.encode must use sourceSrid: nil so the SRID is detected from the string.
Phase 5 of making the projection system generic. Math: standard Snyder transverse Mercator formulas on the WGS84 ellipsoid, verified independently in Python before porting - the meridian arc against Simpson integration of the meridian radius (<0.5mm divergence) and round trips over 252 points covering all zones and latitudes to 89 degrees (max error 1.06e-08 degrees). - UtmDefinition.swift: UtmHemisphere and UtmDefinition implementing both directions of the projection with a per-zone central meridian, the 0.9996 scale factor, the 500_000 false easting and per-hemisphere false northing. Sector extents follow the EPSG convention (easting 100_000-900_000, northing 0-10_000_000); the world bounding box is the zone sector and no WKT pattern is registered (UTM .prj files would need per-zone parsing). - Projection gains all 120 zone cases (northern 32601-32660, southern 32701-32760) and init?(srid:) accepts the contiguous SRID ranges. wraparoundExtent is nil (zone coordinates never wrap), kind is planar; description now defaults to the EPSG label so it scales with the SRID count. - ProjectionRegistry resolves definitions via an eagerly built dictionary; registration coverage is asserted at runtime by the new UtmDefinitionTests sweep over all UTM SRIDs. Tests: SRID-to-zone/hemisphere mapping for all 120 SRIDs, registry coverage sweep, central meridian math per zone, known forward values (zone-edge equator eastings, scaled meridian arcs, both hemispheres), hemisphere symmetry (equal eastings, exact 10_000_000 northing offset), round trips, boundingBox/contains/distance, antimeridian no-ops in date-line zones, normalize/clamp, WKB round trips and random generation within the zone sector.
UTM zone `.prj` files could not be recognized: the fragment-set WKT matching cannot extract the zone number, and a naive fallback would mismatch - UTM `.prj` strings mention "Transverse_Mercator", which the generic EPSG:3395 fragments would match. - File-private UtmWktIdentification namespace with two Swift regex literals: a zone token (UTM / "Universal Transverse Mercator", spaces or underscores as separators, case-insensitive) capturing zone number and hemisphere, and a Central_Meridian parameter for cross-validation against the zone's expected central meridian. - The hemisphere token is mandatory; zones are validated to 1...60; an unparseable or contradicted match yields nil. - A string carrying a UTM zone token short-circuits the matcher: it is resolved to a UTM projection or rejected outright, never falling through to unrelated name fragments. - Tests: realistic ESRI-style `.prj` strings (northern and southern variants), zone/hemisphere token variants, date-line edge zones, central meridian agreement and contradiction cases, and rejected invalid tokens (missing hemisphere, zones outside 1...60). - Clears four redundant #require warnings in the MapTile tests (non-failable boundingBox getters).
Three follow-ups to the projection-genericity effort: Validation API: - Coordinate3D.isValid and BoundingBox.isValid report whether the coordinate/box lies within the valid extent of its projection, resolved through the projection registry. Always true for unbounded projections (EPSG:4978, noSRID). UTM convenience API: - UtmHemisphere is now public (it appears in the public API below) - Projection.utmZone / Projection.utmHemisphere accessors return the zone number or hemisphere of a UTM zone projection, nil for non-UTM projections - Projection(utmZone:hemisphere:) creates a zone projection from a zone number (1...60) and hemisphere Benchmarks: - ProjectionBenchmarks suite (skipped in CI like the other suites): compares the pre-registry inline formulas as a baseline against the registry path, with correctness cross-checks. An EPSG:4326 pivot short-circuit in Coordinate3D.projected(to:) skips the registry lookup and identity copy in the two most common directions (behavior-identical). - Decomposition tests isolate lookup, fixed-definition projection and full-path costs (release: ~5 ns baseline, ~30 ns lookup, ~125 ns full path per coordinate). The overhead only matters for bulk reprojection; algorithm hot paths reproject per feature, and the analysis is documented in the suite docs along with the options if a batch reprojection use case appears. Tests: ValidityTests (extent boundaries for 4326/3857/UTM sectors, unbounded projections, invalid-then-clamp) and UtmDefinitionTests.utmConvenienceApi (zone sweep, invalid zones, non-UTM accessors).
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.
Implements #243 for the WGS84-pivot architecture established in #247, shipping datum-capable built-in projections and the public transformation helper for user-defined datum CRSs. Phase 1 - Datum metadata model: - public Ellipsoid (wgs84/grs80/clarke1866/airy1830 with derived eccentricities and axes) and public Datum (wgs84/etrs89/nad27/ osgb1936) - Projection.datum accessor resolved from the definition; ProjectionDefinition gains a datum requirement (default WGS84); CustomProjection gains a datum field. Phase 2 - verified math infrastructure: - public HelmertTransformation: 7-parameter Helmert with position vector convention, exact rotation matrices, geocentric legs on arbitrary source ellipsoids, both directions with explicit result projection labeling. Well-known sets for NAD27 ("NAD27 to WGS 84 (4)", ~10 m) and OSGB36 ("OSGB 1936 to WGS 84 (6)", EPSG:1314, ~2 m) sourced from the EPSG registry and verified against pyproj/PROJ reference values. - internal TransverseMercatorMath(ellipsoid:) generalizing the Snyder TM formulas with a latitude-of-origin; UtmDefinition delegates to it (guarded by the existing UTM known-value tests, behavior provably unchanged). Phase 3 - built-in datum CRSs: - EPSG:4267 NAD27 geodetic, EPSG:4277 OSGB36 geodetic, EPSG:4258 ETRS89 geodetic (identity shift; OS labels ETRS89 data as WGS84 at meter accuracy) and EPSG:27700 British National Grid (helmert + TM on Airy 1830: lat0 49, lon0 -2, k 0.9996012717, FE 400000, FN -100000) - all registered in the registry seed with WKT matcher fragments (BNG probes before the generic Mercator fragments), datum metadata, test-appropriate unbounded extents (BNG keeps its southwest negative-northing region valid). Fixtures: The reference values were computed independently with pyproj/PROJ before porting, at 9-decimal precision; round-trip errors measured at ~3.3e-9 deg (NAD27) and ~1.4e-8 deg (OSGB36). Tests (+12): datum metadata, Helmert both directions + round trips, per-CRS reference fixtures, route-pivot round trips, British National Grid values with the southwest sector, algorithm smoke tests, WKB round trips and WKT matching for the new SRIDs. ETRS89-bearing WKT strings now match EPSG:4258 instead of returning nil. Also extends the README Projections section with the datum CRSs, the accuracy tier note and attribution, and links the grid shift support issue (#248).
Organizational restructure: all projection-related files move from Sources/GISTools/GeoJson/ into a dedicated Projections/ folder, with git-tracked renames. This is also the time to split the formerly 620-line ProjectionDefinition.swift into dedicated files: - ProjectionDefinition.swift now contains only the protocol plus the shared default-datum extension - new ProjectionRegistry.swift holds the mutex-guarded registry - each built-in projection gets its own dedicated file: Epsg4326Definition.swift, Epsg3857Definition.swift, Epsg4978Definition.swift, Epsg3395Definition.swift, Epsg32662Definition.swift and NoSridDefinition.swift, alongside the already-separate UtmDefinition and the four datum CRS definitions The README's Projections section becomes a top-level item between GeoJSON and SwiftData, with a new "Implemented projections" table (EPSG, response, coordinate units, transformation, per-projection source links), a cleaned-up custom registration example and an updated features bullet that lists the full CRS set instead of the old two-projection description. No functional changes: builds with zero warnings, 2500 tests in 183 suites green.
Adds Array<Coordinate3D>.projected(to:) and the in-place project(to:) variant: batches of coordinates sharing their projection convert in one call, with the conversion setup (TM parameterization and origin constants, Helmert rotation matrices) built once per batch instead of per coordinate. The math is identical to the single-coordinate path - the batch machinery only extracts constants into an internal hoisted representation (BatchPreparedTransforms plus prepared steps on HelmertTransformation, delegated through the same transform internals). The full frozen fixture suite proves equivalence. Semantics: all coordinates in a batch must share their projection (the library-wide invariant, asserted); noSRID values are copied verbatim in both directions; same-projection batches are verbatim copies. Migration (behavior-identical output, verified by the whole suite): - geometry projected(to:) implementations (LineString/MultiPoint as single batches; Polygon/MultiLineString/MultiPolygon as ring-level batches) - WKB decoder: coordinate lists decode raw and batch convert once per geometry (decodeCoordinate keeps the single-coordinate Point path; decodeRawCoordinate added) - WKT decoder: scans source-labeled coordinates, batch converts at the end - TWKB: coordinate sequences batch convert once; Point unchanged Also fixes a mislabeling bug found by the batch equivalence test: Etrs89Definition built its identity results with the EPSG:4326-labeled initializer, so EPSG:4258 coordinates carried an EPSG:4326 projection label (values were correct, labels were not; the per-value datum tests could not catch it). ETRS89 now carries its own SRID label in both directions. Benchmark comparison (release build, ms per 10k coordinates): - 4326 to 3857: 0.549 per-coordinate vs 0.469 batch (-15%) - 4326 to UTM to 4326: 2.717 vs 2.461 (-9%) - 4326 to British National Grid: batch 5.595 (hoists both the Helmert matrices and the TM parameterization; no visible win on that lane - two-step chain dominates) Tests (+9 in ArrayProjectionTests): edge cases, same-projection verbatim copies, batch-equals-per-coordinate equivalence across all CRS families (including the datum CRSs and ECEF), round trips, noSRID verbatim semantics, the mutating project(to:) variant. Note for test authors: the batch API asserts on mixed-projection input (library-wide invariant); uniform batches only. The three batch cases join ProjectionBenchmarks (CI-skipped).
New CRS definitions (each with an EPSG Helmert datum chain and prepared batch transforms): - EPSG:2056 CH1903+/LV95 and EPSG:21781 CH1903/LV03 via the new SwissObliqueMercatorMath, a faithful port of PROJ's somerc stage - EPSG:29902 TM65/Irish Grid and EPSG:29903 TM75/Irish Grid - EPSG:2157 IRENET95/Irish Transverse Mercator Datum model: Ellipsoid.modifiedAiry/.bessel1841 and Datum.ch1903plus/ .ch1903/.tm65/.tm75/.irenet95. All reference values pinned against pyproj ground truth before porting (somerc uses PROJ's kR = k0*a/c, not Snyder's R'). Zone selection: UtmDefinition.zone(for:) and Projection.utmZone(for:) pick the UTM zone for a coordinate including the EPSG Norway (32V) and Svalbard (31X/32X/33X/35X/37X) banding exceptions. README: table/bullet entries for the new CRSs, UTM zone-selection snippet, datum accuracy note extension, reference-link cleanup (deduplicated definitions, pruned dead refs, ascending order) at 2529 tests green
All *Definition.swift files (17 files: the EPSG bases, noSRID, UTM, NAD27, OSGB, Swiss and Irish CRSs and the ProjectionDefinition protocol) move to Sources/GISTools/Projections/Definitions; the projection and math infrastructure stays at Projections/. README definition links updated.
…ew, ETRS89/UTM belt New math (faithful ports of PROJ 9.8.1, validated against pyproj to sub-mm): LambertConformalConicMath (Snyder 15-x with the GeographicLib tauf Newton inverse), LambertAzimuthalEqualAreaMath (all four domains with the authalic series) and ObliqueStereographicMath (the Gauss-conformal reduction, including PROJ's cos^4 phi0 in the Gaussian C constant). New CRS definitions: - EPSG:25831-25837 ETRS89/UTM zone belt (TM on exact GRS80, Datum.etrs89) - EPSG:3035 ETRS89-LAEA Europe and EPSG:3034 ETRS89-LCC Europe - EPSG:2154 RGF93/Lambert-93 - EPSG:28992 Amersfoort/RD New: Helmert "Amersfoort to WGS 84 (4)" + oblique stereographic on Bessel 1841 (Datum.amersfoort) Projection.utmZone/utmHemisphere resolve both UTM belts, and WKT strings naming ETRS89 next to a zone token (ETRS89 / UTM zone 32N, ESRI ETRS_1989_UTM_Zone_32N) resolve into EPSG:25831-25837 when defined. README: table rows for the five new CRSs, datum-accuracy note extension (EPSG:4833, RD grids), reference links. Fixtures use in-zone pyproj values matching the library's stated transverse Mercator accuracy tier; the Snyder-vs-PROJ modern-TM differential at far-off-zone extents is documented and tracked in #246. 2537 tests green
Implements Karney's transverse Mercator (the Krueger series extended to 6th order, arXiv:1002.1417) for the UTM zones: conformal-sphere mapping, Clenshaw-summated Krueger series and a Newton iteration for the conformal latitude inversion. Wired into UtmDefinition (WGS84 zones) and Etrs89UtmDefinition (GRS80 belt) under the unchanged ProjectionDefinition contract; the Snyder TransverseMercatorMath stays for the other TM-based CRSs. Verification (independent Python implementation first, per the established discipline): - validated against Karney's published test set TMcoords.dat (287k points, 80-digit exact reference): worst forward 5.7 nm / reverse 5.0 nm within 3900 km of the central meridian, consistent with the paper's 5 nm bound - cross-checked against PROJ 9.8 across all 120 WGS84 zones (worst 7.5 nm over 1440 random points) and the ETRS89 belt (5.6 nm) - round-trip sweep over all zones and latitudes incl. the poles: worst 6.4 nm; Snyder degrades to ~1 km at 45 degrees off the central meridian while Karney stays sub-micrometer throughout the convergence region (~3900 km for the 5 nm bound) Behavior change (documented, sub-millimeter): Snyder and Karney agree within ~1 mm in-zone (both valid UTM); the UtmAlgorithmTests reference values were re-derived under Karney and the tolerance tightened from 1 mm to 1 um (e.g. zone 19N easting 331792.115 -> 331792.114806 m, zone 20S northing shifts 0.6 mm). Benchmarks (release, 10k coordinates, median): UTM round trip registry path 2.71 -> 7.6-8.7 ms (~2.8-3.2x, the extra ~500 ns/round trip buys nanometer accuracy and guaranteed convergence), batch path 2.47 -> 7.4-7.8 ms; unrelated paths unchanged within noise. Closes #246
|
Follow-up commit pushed: Replace Snyder transverse Mercator in UTM with Karney's algorithm (
|
Cross-validates every sensible pair of built-in projections against reference values pre-computed with pyproj/PROJ 9.8.1: - scripts/generate_projection_matrix.py: the fixture generator. Uses explicit parameterized proj4 strings mirroring the library's parameters (including the Helmert datum transformations in position-vector form) rather than EPSG codes, so PROJ cannot silently select a better transformation (e.g. grid shifts) than the library implements. --check verifies the committed files match regeneration. - Tests/GISToolsTests/TestData/ProjectionMatrix/matrix.csv: the reference data (3612 rows, one per test point and projection; 459 points spanning global coverage, regional CRS centers/corners and all 120 UTM zones with central-meridian, zone-border and high-latitude rows). Data and test code are kept separate. - Tests/GISToolsTests/Algorithms/ProjectionMatrixTests.swift: the test logic, loading the CSV at runtime via the #filePath-relative TestData mechanism. Three suites: matrixConversions (26158 ordered pairs, x/y values plus the projection label and m passthrough), matrixRoundTrips (A -> B -> A) and batchEquivalence (the hoisted batch path on a deterministic sparse subset, ECEF sources carrying their z). Tolerances are measured ceilings, not round numbers: base rows use 1e-8 degrees (geographic) and 1 mm (planar/geocentric); audit-measured worst errors are ~1e-7 m for UTM, ~39 um for the exact-CRS pairs, 0.69-1.6 mm for 2157/3035, and the datum-pair errors up to 77 mm (4267/4277). Per-CRS budgets document the two known deviations: - The Helmert-vs-PROJ ordering difference (library applies the 3D translation in the geocentric domain, PROJ's +towgs84 offsets lat/lon directly) - few centimeters to 77 mm, root-caused and tracked as #251. EPSG:4978 pairs with the geodetic datum CRSs are excluded from the matrix until the Helmert z handling is fixed: the datum's vertical offset currently rides along as an altitude through the EPSG:4326 pivot (4326 -> datum -> 4978 differs from the direct 4326 -> 4978 by ~26 m for Amersfoort). - EPSG:3035's transcribed legacy authalic series loses ~1-2 mm at large distances from the projection origin (PROJ 9.8 uses the Karney auxlat series); 2 mm budget, upgrade tracked as #252. EPSG:32662 is excluded: the library intentionally stores degrees as x/y (see Epsg32662Definition), diverging from PROJ's meter-valued +proj=eqc; its behavior is pinned by Epsg32662Tests. 2549 tests in 190 suites, zero warnings.
|
Follow-up commit pushed: Add projection matrix cross-validation against PROJ reference data ( Key points:
|
Replaces the legacy 3-term Snyder authalic series (pj_authset/ pj_authlat) in LambertAzimuthalEqualAreaMath with PROJ 9.8's auxlat series (pj_auxlat_coeffs for AUTHALIC -> GEOGRAPHIC, order 6 in the third flattening, Karney "On auxiliary latitudes", arXiv:2212.05818), evaluated with Clenshaw summation. The series reaches full double precision for |f| <= 1/150, versus the legacy series' ~1 mm loss at large distances from the projection origin. Verification (independent Python implementation first, per the established discipline): the series was transcribed into Python and validated against PROJ 9.8 before porting — the Cairo laea inverse (the original finding of the projection matrix, ~3500 km from the EPSG:3035 origin) goes from 1.2e-8 deg (1.3 mm) to 2e-14 deg off. Measured over the matrix points: laea inverse worst 0.6 um, round trip worst 0.5 um. Closes #252. The EPSG:3035 pair budget is removed from the projection matrix (all 3035-sourced pairs now held to the plain 1 mm row tolerance, measured headroom ~1600x); the fixture is regenerated. A new laeaAuxlatSeriesRoundTrips test pins the series across the full EPSG:3035 domain (origin, the failing Cairo distance, the Canary worst case, far corners, equator, southern hemisphere) at 1 um forward / 1e-9 deg round-trip tolerance. The LambertConformalConicMath inverse was checked per the issue: it uses the conformal-latitude tauf Newton iteration, not the authalic series — no change needed. 2550 tests in 190 suites, zero warnings.
|
Follow-up commit pushed: Upgrade the laea authalic latitude to the Karney auxlat series ( Key points:
|
The Helmert transformation now passes the input height through unchanged in both directions (the 3D transform still runs on the ECEF position, so x/y remain datum-correct at the given height; only the output z is the input height instead of the datum-shifted one). This matches PROJ's +towgs84 nominal-height semantics, validated in Python against PROJ before porting (datum -> WGS84 with h=100 reproduces the original lat/lon/height to ~0.3 mm). Previously the datum's vertical offset rode along as an altitude through the EPSG:4326 pivot: the library was internally asymmetric (the forward path passed the input height through, the inverse applied the full 3D shift), and 4326 -> datum CRS -> 4978 differed from the direct 4326 -> 4978 by the datum's vertical offset (~26 m for Amersfoort on Bessel 1841, issue #251's finding from the projection matrix). Consequences: - All 176 EPSG:4978 <-> geodetic-datum-CRS pairs are now verified in the projection matrix (26334 pairs, up from 26158); the exclusion machinery is removed from the generator and the emitted test. Measured worst errors: 1.9 mm (4978 -> Irish, within its budget) and 48.9 mm (datum -> 4978 via NAD27, within the documented 0.1 m budget for the geocentric-domain Helmert ordering difference). - The Helmert round trip is no longer exactly closed (~1e-8 deg, the same looseness PROJ's own +towgs84 round trips exhibit); HelmertTransformationTests reflects that with an explanatory comment. The z round trip itself stays exact. - The type and method documentation spell out the horizontal semantics; the generator's datum-budget comments note the measured residuals. Closes #251. 2550 tests in 190 suites, zero warnings.
|
Follow-up commit pushed: Preserve the height in the Helmert datum transformations ( Key points:
Both matrix findings (#251, #252) are now fixed; the matrix's remaining datum budgets document the residual Helmert ordering difference only. |
New built-in CRSs on the Bessel 1841 ellipsoid with the classical
transverse Mercator math (Karney series, consistent with the UTM
zones):
- EPSG:31466-31469, DHDN / Gauss-Krueger zones 2-5 (the German
cadastral grid): k=1, x0 = zone*1M + 500k (the zone-prefix
convention), Datum.dhdn with the EPSG "DHDN to WGS 84 (2)"
Helmert (598.1, 73.7, 418.2, 0.202", 0.045", -2.455", 6.7 ppm,
stated accuracy ~3 m)
- EPSG:31255-31259, MGI / Austria Gauss-Krueger (all five zones:
the x0=0 Central/East variants plus the M28/M31/M34 three-zone
belt with the Ferro-based numbering): y0 = -5M, Datum.mgi with
the EPSG "MGI to WGS 84 (2)" Helmert (577.326, 90.129, 463.919,
5.137", 1.474", 5.297", 2.4232 ppm, ~1.5 m)
Verification per the established discipline: pyproj/PROJ 9.8.1
fixtures computed before finalizing (34 forward values across all 9
zones plus round trips). One implementation bug caught en route
(the first pass omitted the Helmert, 52-89 m off; fixed by wiring
the datum chain like the other datum CRSs). Documented 1.9 mm
residual on MGI: PROJ applies datum shifts as a geodetic-domain
approximation while the library uses the exact XYZ affine -
invisible for DHDN's 0.2" rotations, measurable for MGI's 5"
ones; covered by the 2 mm datum budget with a comment.
Integration:
- New GaussKruegerWktIdentification (like the UTM short-circuit):
handles GDAL ("DHDN / Gauss-Krueger zone 3") and ESRI
("Gauss_Kruger_DHDN_3") names, the Ferro M-tokens (M28/M31/M34),
and parameter-based zone resolution with Central_Meridian /
False_Easting cross-validation (the x0=0 variants share CMs with
the modern zones, so the false easting disambiguates)
- Projection matrix: all 9 GK CRSs added (3626 rows, 26700 pairs)
- New GaussKruegerTests (9 tests): SRID mapping, registry coverage,
both belts' fixtures, zone-prefix convention, false-northing
convention, datum metadata, WKT matching, batch equivalence
- README: feature list and two table rows + reference links
|
Follow-up commit pushed: Add German and Austrian Gauss-Krueger zone belts ( Key points:
|
New Datum.nad83 (identity over WGS84, like ETRS89; the NAD83/WGS84 difference is continental plate motion with no simple Helmert) on GRS80, with six new CRS definitions: - EPSG:4269 NAD83 geodetic (identity) - EPSG:26901-26960 NAD83/UTM zones 1N-60N (Karney TM on GRS80, mirroring the ETRS89 belt) - EPSG:5070 NAD83 / Conus Albers (the CONUS equal-area standard, USGS/EPA/NLCD) and EPSG:3005 NAD83 / BC Albers via a new AlbersEqualAreaMath (Snyder 14-1..14-14) - EPSG:3347 NAD83 / Statistics Canada Lambert and EPSG:3978 NAD83 / Canada Atlas Lambert via the existing LambertConformalConicMath The authalic-latitude conversion is extracted from LambertAzimuthalEqualAreaMath into a shared internal helper (AuthalicLatitude) used by both laea and albers: the Albers inverse uses the Karney auxlat series, matching PROJ 9.8's inverse exactly and avoiding the Newton-stall failure mode near the poles that a naive q-iteration exhibits. Verification per the established discipline: an independent Python Albers implementation was validated against PROJ before porting - two transcription bugs caught en route (a ratio-vs-log mixup in Snyder's q, and missing longitude normalization past +/-180). Final sweep over 3000 points on EPSG:5070/3005: forward worst 33 nm vs PROJ, round trips 3.7 um. Swift-vs-PROJ spot check to 2e-12 m. Integration: - WKT identification: NAD83 datum tokens (NAD83, NAD_1983, ESRI GCS_North_American_1983) resolve to EPSG:4269; the UTM zone short-circuit routes NAD83 zone tokens to the EPSG:269xx belt - Projection matrix: all new CRSs included (4409 rows, 41290 pairs) - New Nad83CrsTests (6 tests): SRID mapping, fixtures across all CRSs, Albers round trips, datum metadata, WKT matching, batch equivalence - README: feature list, six table rows, reference links 2565 tests in 192 suites, zero warnings.
|
Follow-up commit pushed: Add North American NAD83 CRSs: UTM belt, Albers and Canadian grids ( Key points:
|
What it delivers
1. ProjectionKind + capability flags (#237)
ProjectionKind(.geographic/.planar/.geocentric/.undefined) plus convenience flags,wraparoundExtentandcrsLength(fromMeters:). Algorithms and antimeridian handling dispatch on the semantic category instead of EPSG codes; the duplicated 4326/3857 antimeridian helper implementations collapsed into shared parameterized ones.2. ProjectionDefinition registry (#238)
Internal
ProjectionDefinitionstrategy protocol withforward/inverserelative to the EPSG:4326 pivot;ProjectionRegistrymaps projections to definitions. noSRID keeps its verbatim-copy semantics.3. New projections
init?(srid:)— Add UTM zone projections (EPSG:32601–32660, 32701–32760) #241Datum/Ellipsoidmetadata model (Projection.datum)HelmertTransformation(7-param, position-vector, EPSG "NAD27 to WGS 84 (4)" ~10 m / "OSGB 1936 to WGS 84 (6)" EPSG:1314 ~2 m), exact rotation matrices, arbitrary source ellipsoids, both directions with explicit result labelingTransverseMercatorMath(ellipsoid:)— Snyder TM parameterized with a latitude of origin;UtmDefinitiondelegates to it (UTM suite proves behavior preserved)4. Registry-driven WKT matching + coder/tile tests (#240)
wktMatchers: [[String]]alternative fragment sets on each definition;init?(wkt:)probes in registry order (specific before generic). Coders' per-projection nested switches collapsed to generic resolution. Ordering pins: Pseudo-Mercator (3857) and British National Grid (27700) before the generic "Mercator" (3395) fragments — a BNG.prjcontainsTransverse_Mercator..prjfiles identified via regex with Central-Meridian cross-validation (Identify UTM zones in WKT strings via regex #244); UTM-zone-token strings short-circuit to UTM-or-nothing5. Struct-based projection + add-only custom registration (#247)
This is a breaking change (public enum → struct), designed to keep source impact minimal:
static letconstants — dot-shorthand (x == .epsg4326) compiles unchanged;Codablekeeps the SRID wire formatProjection.register(CustomProjection) -> Bool— add-only (no unregister/disable by design);public struct CustomProjectionvalue descriptor with@Sendableforward/inverse closures relative to the EPSG:4326 (WGS84) pivot — the documented seam where datum transformations live (Support CRSs on other datums (NAD27, OSGB, CH1903+, ...) #243)public struct ProjectionExtentreplaces the tupleprojected(to:),kind,clamped(),isValid,wraparoundExtent) read the definition captured in theProjectionvalue — zero registry access, no locking. Release benchmarks: full path ~1.7× direct-math baseline (was 35× before definition capture; a parallel test run melted down at 924 s per-run from per-access Mutex reads, which motivated the capture)6. Batch coordinate conversion (commit
376a561)Array<Coordinate3D>.projected(to:)+ in-placeproject(to:): geometries and the WKB/WKT/TWKB coders now batch-convert whole coordinate lists with the conversion setup (TM parameterization, origin constants, Helmert rotation matrices) hoisted once per batch instead of per coordinate. Math is identical — the batch machinery only extracts constants through an internal prepared representation, proven output-identical by the frozen fixture suite across all CRS families. Release benchmarks: −15% (4326→3857), −9% (4326→UTM→4326); BNG lane flat (two-step chain dominates). Semantics: uniform-projection batches only (library invariant, asserted); noSRID values verbatim in both directions.7. Validation API + UTM convenience (#245)
Coordinate3D.isValid/BoundingBox.isValidagainst the definition's extent;Projection.utmZone/utmHemisphere,Projection(utmZone:hemisphere:),Projection(utmZone:)-style factories.8. Swiss and Irish CRSs (commits
6c9b6f7)New built-in CRSs, all with EPSG Helmert datum chains and prepared batch transforms:
SwissObliqueMercatorMathis a faithful port of PROJ'ssomerc.cppEllipsoid.modifiedAiry/.bessel1841andDatum.ch1903plus/.ch1903/.tm65/.tm75/.irenet959. UTM zone selection (commit
6c9b6f7)UtmDefinition.zone(for:)+Projection.utmZone(for:)return the zone for a WGS84 coordinate, implementing the EPSG banding exceptions: Norway (56–64°N, 3–12°E → zone "32V") and Svalbard (72–84°N → 3°-wide zones 31/33/35/37 and the 9°-wide zone 32). Antimeridian-normalized;niloutside −80°–84° (UPS territory).10. Pan-European and national CRSs (commit
4d2a332)Three new math implementations — faithful ports of PROJ 9.8.1, each validated against pyproj ground truth to sub-millimeters before landing:
LambertConformalConicMath(Snyder 15-x + GeographicLibtaufNewton inverse)LambertAzimuthalEqualAreaMath(all four laea domains incl. the authalic latitude series)ObliqueStereographicMath(the Gauss-conformal reduction +sterea)New CRS definitions:
Datum.amersfoort)11. Karney transverse Mercator for UTM (commit
ce7498f)Replaces the Snyder formulas in
UtmDefinition/Etrs89UtmDefinitionwith Karney's transverse Mercator (the Krüger series extended to 6th order, arXiv:1002.1417; implemented from the paper, no vendored code) under the unchangedProjectionDefinitioncontract — conformal-sphere mapping, Clenshaw-summated Krüger series, Newton iteration for the conformal-latitude inversion. The SnyderTransverseMercatorMathstays for the other TM-based CRSs (BNG, Irish Grids). Composes with future TM-family CRSs (Gauss-Krüger, State Plane TM).Verification (independent Python implementation first, validated before porting):
TMcoords.dat(287k points, 80-digit exact reference): worst forward 5.7 nm / reverse 5.0 nm within 3900 km of the central meridian — consistent with the paper's 5 nm boundBehavior change to acknowledge (sub-millimeter, per #246): Snyder and Karney disagree at the sub-millimeter level in-zone; both are valid UTM within the EPSG-defined accuracy. The
UtmAlgorithmTestsreference values were re-derived under Karney with the tolerance tightened from 1 mm to 1 µm (e.g. zone 19N easting 331792.115 → 331792.114806 m; zone 20S northing shifts 0.6 mm). Downstream users re-projecting Snyder-era UTM values should expect millimeter-scale shifts.Performance (release, 10k coordinates, median): UTM round trip registry path 2.71 → 7.6–8.7 ms (~2.8–3.2×; above the 1.5–2× estimate in the issue — the extra ~500 ns/round trip buys nanometer accuracy and guaranteed convergence), batch path 2.47 → 7.4–7.8 ms. Unaffected paths (3857/4978/27700) unchanged within noise.
12. Projection matrix cross-validation (commit
8cb37323)New
ProjectionMatrixTests: every sensible pair of built-in projections is cross-validated against reference data pre-computed with pyproj/PROJ 9.8.1. The generator (scripts/generate_projection_matrix.py, committed) uses explicit parameterized proj4 strings mirroring the library's parameters — including the Helmert datum transformations in position-vector form — never EPSG codes, so PROJ cannot silently select a better transformation than the library implements. Data (3612 rows inTestData/ProjectionMatrix/matrix.csv) and test code are separate; 459 points span global coverage, regional centers/corners and all 120 UTM zones; 26,158 ordered pairs assert x/y values, the projection label, round trips and batch-API equivalence. Tolerances are measured ceilings (audited: exact-CRS pairs ≤ 39 µm, UTM ≤ 1e-7 m). The known deviations were both fixed by follow-up commits: #252 (laea legacy authalic series) by commitd6fdc0e— removing the EPSG:3035 budget — and #251 (Helmert z ordering) by commitb482f28— the Helmert height passthrough brought the 176 EPSG:4978 ↔ datum-CRS pairs into the matrix (26,334 pairs). EPSG:32662 remains excluded (the library intentionally stores degrees as x/y).13. LAEA authalic latitude upgraded to the auxlat series (commit
d6fdc0e)The laea inverse now converts the authalic latitude through the Karney auxlat series (PROJ 9.8's
pj_auxlat_coeffs, order 6 in n, Clenshaw summation) instead of the legacy 3-term Snyder series: full double precision for |f| ≤ 1/150 vs the legacy ~1 mm loss at large distances. Validated in Python against PROJ before porting (Cairo: 1.2e-8 → 2e-14 deg); measured over the matrix points: inverse worst 0.6 µm, round trip 0.5 µm. The EPSG:3035 pair budget is removed and the fixture regenerated; a new domain-wide round-trip test pins the series at 1 µm / 1e-9 deg. Closes #252. (LambertConformalConicMathchecked per the issue: it uses the conformal-latitude Newton iteration, no shared pattern.)14. Helmert height passthrough (commit
b482f28)HelmertTransformationnow preserves the input height in both directions (the 3D transform still runs on the ECEF position; only the output z is the input height), matching PROJ's+towgs84nominal-height semantics — validated in Python against PROJ before porting. This fixes the pivot inconsistency:4326 → datum CRS → 4978equals the direct4326 → 4978instead of carrying the datum's vertical offset (~26 m for Amersfoort). All 176 EPSG:4978 ↔ datum-CRS pairs are now verified in the projection matrix (26,334 pairs; measured worst 48.9 mm, within the documented 0.1 m ordering-difference budget); the exclusion machinery is gone. The Helmert round trip loosens to ~1e-8 deg — the same looseness PROJ's own+towgs84exhibits. Closes #251.15. German and Austrian Gauss-Krüger zone belts (commit
1f31f9d)Two new zone-belt definitions on Bessel 1841 with the Karney transverse Mercator: EPSG:31466–31469 (DHDN / Gauss-Krüger zones 2–5, the German cadastral grid, x₀ = zone·1M + 500k) and EPSG:31255–31259 (MGI / Austria Gauss-Krüger, all five zones: the x₀=0 Central/East variants plus M28/M31/M34 with the Ferro-based numbering). New
Datum.dhdn("DHDN to WGS 84 (2)", ~3 m) andDatum.mgi("MGI to WGS 84 (2)", ~1.5 m). PROJ fixtures computed before finalizing (34 forward values across all 9 zones); one implementation bug caught en route (the first pass omitted the Helmert — 52–89 m off). Documented 1.9 mm residual on MGI: PROJ applies datum shifts as a geodetic-domain approximation, the library as the exact XYZ affine (invisible for DHDN's 0.2″ rotations); covered by a 2 mm datum budget. NewGaussKruegerWktIdentification(like the UTM short-circuit) handles GDAL/ESRI names, the Ferro M-tokens, and parameter-based zone resolution with CM/False_Easting cross-validation. The projection matrix now covers all 9 GK CRSs (3,626 rows, 26,700 pairs); 9 newGaussKruegerTests.17. North American NAD83 CRSs (commit
06a58ae)New
Datum.nad83(identity over WGS84, like ETRS89; GRS80) and six CRS families: EPSG:4269 (geodetic, identity), EPSG:26901–26960 (NAD83/UTM belt, Karney TM on GRS80), EPSG:5070 (Conus Albers — the USGS/EPA/NLCD standard) and EPSG:3005 (BC Albers) via a newAlbersEqualAreaMath(Snyder 14-1…14-14), and EPSG:3347/EPSG:3978 (Canadian Lambert conic grids) via the existing LCC math. The authalic-latitude conversion is extracted into a sharedAuthalicLatitudehelper used by both laea and albers — the Albers inverse matches PROJ 9.8 exactly (auxlat series) and avoids the near-pole Newton stall a naive q-iteration exhibits. Python-first verification caught two transcription bugs before porting; the final 3,000-point sweep measures forward 33 nm / round trips 3.7 µm. WKT identification handles NAD83 datum tokens and routes NAD83 UTM zone tokens to the 269xx belt. The projection matrix now covers 4,409 rows / 41,290 pairs; 6 newNad83CrsTests.After merge