Enhance CoordinateConverter with custom central meridian support and refine UTM distance calculations

- Added `toUtmGridAtMeridian` for flexible UTM projections with an arbitrary central meridian.
- Updated `DistanceCalculator` to use midpoint-based central meridian for accurate planar distance computations.
- Introduced longitude normalization utilities to improve handling of antimeridian crossings.
This commit is contained in:
Jan Meinl
2026-05-16 07:55:27 +02:00
parent fa888405bd
commit ec6d9d92ac
2 changed files with 57 additions and 15 deletions
@@ -194,23 +194,39 @@ public final class DistanceCalculator {
}
private static double utmEuclidean(Coordinate from, Coordinate to, MapDatum datum) {
// Project the source naturally; this fixes the zone (and hemisphere) of
// the planar frame in which the distance is measured.
GridCoordinate fromUtm = CoordinateConverter.convert(from, datum, MapDatum.WGS84, MapGrid.UTM);
// Project the target into the same zone / hemisphere as the source, so
// both points share one central meridian and the wedge-shaped overlap
// between zones causes no jump in the planar coordinates.
// Bring both points onto WGS84 first so they share one ellipsoid.
Coordinate fromWgs84 = CoordinateConverter.transformDatum(from, datum, MapDatum.WGS84);
Coordinate toWgs84 = CoordinateConverter.transformDatum(to, datum, MapDatum.WGS84);
GridCoordinate toUtm = fromUtm.utmZone() != null
? CoordinateConverter.toUtmGrid(toWgs84, MapDatum.WGS84,
fromUtm.utmZone(), fromUtm.southernHemisphere())
: CoordinateConverter.toGrid(toWgs84, MapDatum.WGS84, MapGrid.UTM);
// Pick the central meridian half-way between the two longitudes. This
// distributes the Transverse Mercator scale distortion symmetrically
// and roughly halves the maximum planar error compared to using either
// point's natural UTM zone — important when the two points are far
// apart in longitude or straddle a zone boundary ("Zwickel"). The
// shortest longitude arc is used so an antimeridian crossing is fine.
double dLon = normalizeLongitudeDelta(toWgs84.longitude() - fromWgs84.longitude());
double midLon = normalizeLongitude(fromWgs84.longitude() + dLon / 2.0);
boolean southern = (fromWgs84.latitude() + toWgs84.latitude()) / 2.0 < 0.0;
GridCoordinate fromUtm = CoordinateConverter.toUtmGridAtMeridian(fromWgs84, MapDatum.WGS84, midLon, southern);
GridCoordinate toUtm = CoordinateConverter.toUtmGridAtMeridian(toWgs84, MapDatum.WGS84, midLon, southern);
return Math.hypot(toUtm.easting() - fromUtm.easting(),
toUtm.northing() - fromUtm.northing());
}
private static double normalizeLongitudeDelta(double dLon) {
while (dLon > 180.0) dLon -= 360.0;
while (dLon < -180.0) dLon += 360.0;
return dLon;
}
private static double normalizeLongitude(double lon) {
while (lon > 180.0) lon -= 360.0;
while (lon <= -180.0) lon += 360.0;
return lon;
}
// ------------------------------------------------------------------
// Result assembly
// ------------------------------------------------------------------
@@ -82,7 +82,7 @@ public final class CoordinateConverter {
return new GridCoordinate(coordinate.longitude(), coordinate.latitude(),
coordinate.barometricAltitude(), coordinate.gpsAltitude(), null, null, false);
case TRANSVERSE_MERCATOR:
return transverseMercatorForward(coordinate, datum, grid, null, false);
return transverseMercatorForward(coordinate, datum, grid, null, null, false);
case SWISS_OBLIQUE_MERCATOR:
return swissObliqueMercatorForward(coordinate, datum, grid);
default:
@@ -106,7 +106,28 @@ public final class CoordinateConverter {
if (zone < 1 || zone > 60) {
throw new IllegalArgumentException("UTM zone must be in [1, 60], was " + zone);
}
return transverseMercatorForward(coordinate, datum, MapGrid.UTM, zone, southern);
return transverseMercatorForward(coordinate, datum, MapGrid.UTM, zone, null, southern);
}
/**
* Projects a {@link Coordinate} (referenced to {@code datum}) onto a
* UTM-style Transverse Mercator grid with an arbitrary central meridian
* (in decimal degrees). UTM's scale factor (0.9996), false easting
* (500&nbsp;000&nbsp;m) and hemisphere-dependent false northing are reused;
* only the central meridian is overridden.
* <p>
* This is the primary tool for an accurate planar distance between two
* points that are far apart in longitude: picking the mid-longitude as the
* central meridian distributes the Transverse Mercator scale distortion
* symmetrically and roughly halves the maximum error.
* <p>
* The returned {@link GridCoordinate} carries no UTM zone number (since
* the projection is not a standard UTM zone) but reports the hemisphere
* as requested.
*/
public static GridCoordinate toUtmGridAtMeridian(Coordinate coordinate, MapDatum datum,
double centralMeridianDeg, boolean southern) {
return transverseMercatorForward(coordinate, datum, MapGrid.UTM, null, centralMeridianDeg, southern);
}
/**
@@ -288,7 +309,8 @@ public final class CoordinateConverter {
// ------------------------------------------------------------------
private static GridCoordinate transverseMercatorForward(Coordinate coordinate, MapDatum datum, MapGrid grid,
Integer forcedZone, boolean forcedSouthern) {
Integer forcedZone, Double forcedCentralMeridianDeg,
boolean forcedSouthern) {
double a = datum.semiMajorAxis();
double f = datum.flattening();
double e = datum.firstEccentricity();
@@ -300,7 +322,11 @@ public final class CoordinateConverter {
boolean southern = false;
double centralMeridian;
double falseNorthing = grid.falseNorthing();
if (grid.isZoneDependent()) {
if (forcedCentralMeridianDeg != null) {
southern = forcedSouthern;
centralMeridian = Math.toRadians(forcedCentralMeridianDeg);
falseNorthing = southern ? 10_000_000.0 : 0.0;
} else if (grid.isZoneDependent()) {
if (forcedZone != null) {
zone = forcedZone;
southern = forcedSouthern;