Skip to content

Commit b243efc

Browse files
sbrunnerfdiazcarsisebr72
authored
Introduce PseudoMercatorUtils for accurate Web Mercator handling (#3981)
* Feat: Add geodetic calculation option for Pseudo-Mercator This commit introduces the useGeodeticCalculations parameter for maps. When set to true for Pseudo-Mercator projections, it ensures more accurate scale and bounding box computations by accounting for Mercator projection distortions, especially away from the equator. This change affects map bounds, scale calculation, and WMTS layer scaling. It also includes new unit tests and an example to demonstrate the feature. Co-authored-by: fdiaz <fdiaz@gvsig.com> Co-authored-by: sbrunner <353872+sbrunner@users.noreply.github.com> Co-authored-by: sebr72 <sebastien_riollet@hotmail.com>
1 parent 1dde012 commit b243efc

23 files changed

Lines changed: 1089 additions & 51 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package org.mapfish.print;
2+
3+
import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
4+
5+
/**
6+
* Utility class for handling WGS 84 with Pseudo-Mercator projection specific calculations.
7+
*
8+
* @author fdiaz
9+
*/
10+
public final class PseudoMercatorUtils {
11+
12+
private PseudoMercatorUtils() {}
13+
14+
/**
15+
* Checks if a given CoordinateReferenceSystem is WGS 84 with Pseudo-Mercator projection.
16+
*
17+
* @param crs The CoordinateReferenceSystem to check.
18+
* @return {@code true} if the CRS is Pseudo-Mercator, {@code false} otherwise.
19+
*/
20+
public static boolean isPseudoMercator(final CoordinateReferenceSystem crs) {
21+
// Possible improvement : Some CRS implementations use URNs (e.g. urn:ogc:def:crs:EPSG::3857) or
22+
// provide multiple identifiers. Consider iterating all identifiers and/or using GeoTools
23+
// utilities like CRS.lookupEpsgCode(...) / IdentifiedObjects.lookupIdentifier(...) to reliably
24+
// detect EPSG:3857 (also guard against crs/crs.getName() being null).
25+
26+
// Check CRS name (case-insensitive)
27+
String crsNameCode = crs.getName().getCode().toLowerCase();
28+
boolean nameMatch =
29+
crsNameCode.contains("wgs 84")
30+
&& (crsNameCode.contains("pseudo-mercator")
31+
|| crsNameCode.contains("pseudo mercator")
32+
|| crsNameCode.contains("web-mercator")
33+
|| crsNameCode.contains("web mercator"));
34+
35+
// Check identifiers (safely handle missing identifiers)
36+
if (nameMatch) {
37+
return true;
38+
}
39+
40+
if (crs.getIdentifiers() != null && crs.getIdentifiers().iterator().hasNext()) {
41+
String crsId = crs.getIdentifiers().iterator().next().toString();
42+
return "EPSG:3857".equalsIgnoreCase(crsId);
43+
}
44+
45+
return false;
46+
}
47+
}

core/src/main/java/org/mapfish/print/attribute/ReflectiveAttribute.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,4 +444,22 @@ public Object getValue(
444444
MapfishParser.parse(errorOnExtraParameters, pValue, value);
445445
return value;
446446
}
447+
448+
/**
449+
* Check if a class has any fields with @OneOf annotations.
450+
*
451+
* @param clazz the class to check
452+
* @return true if the class has OneOf constraints
453+
*/
454+
private boolean hasOneOfConstraints(final Class<?> clazz) {
455+
Collection<Field> fields =
456+
ParserUtils.getAttributes(
457+
clazz, field -> !java.lang.reflect.Modifier.isFinal(field.getModifiers()));
458+
for (Field field : fields) {
459+
if (field.getAnnotation(OneOf.class) != null) {
460+
return true;
461+
}
462+
}
463+
return false;
464+
}
447465
}

core/src/main/java/org/mapfish/print/attribute/map/BBoxMapBounds.java

Lines changed: 121 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22

33
import java.awt.Rectangle;
44
import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
5+
import org.geotools.api.referencing.operation.MathTransform;
6+
import org.geotools.geometry.jts.JTS;
57
import org.geotools.geometry.jts.ReferencedEnvelope;
8+
import org.geotools.referencing.CRS;
69
import org.geotools.referencing.GeodeticCalculator;
710
import org.locationtech.jts.geom.Coordinate;
811
import org.locationtech.jts.geom.Envelope;
912
import org.mapfish.print.FloatingPointUtil;
13+
import org.mapfish.print.PrintException;
14+
import org.mapfish.print.PseudoMercatorUtils;
1015
import org.mapfish.print.map.DistanceUnit;
1116
import org.mapfish.print.map.Scale;
1217

@@ -23,12 +28,46 @@ public final class BBoxMapBounds extends MapBounds {
2328
*
2429
* @param projection the projection these bounds are defined in.
2530
* @param envelope the bounds
31+
* @param useGeodeticCalculations force to use geodetic calculations in PseudoMercator projection
2632
*/
27-
public BBoxMapBounds(final CoordinateReferenceSystem projection, final Envelope envelope) {
28-
super(projection);
33+
public BBoxMapBounds(
34+
final CoordinateReferenceSystem projection,
35+
final Envelope envelope,
36+
final boolean useGeodeticCalculations) {
37+
super(projection, useGeodeticCalculations);
2938
this.bbox = envelope;
3039
}
3140

41+
/**
42+
* Constructor.
43+
*
44+
* @param projection the projection these bounds are defined in.
45+
* @param envelope the bounds
46+
*/
47+
public BBoxMapBounds(final CoordinateReferenceSystem projection, final Envelope envelope) {
48+
this(projection, envelope, false);
49+
}
50+
51+
/**
52+
* Constructor.
53+
*
54+
* @param projection the projection these bounds are defined in.
55+
* @param minX min X coordinate for the MapBounds
56+
* @param minY min Y coordinate for the MapBounds
57+
* @param maxX max X coordinate for the MapBounds
58+
* @param maxY max Y coordinate for the MapBounds
59+
* @param useGeodeticCalculations force to use geodetic calculations in PseudoMercator projection
60+
*/
61+
public BBoxMapBounds(
62+
final CoordinateReferenceSystem projection,
63+
final double minX,
64+
final double minY,
65+
final double maxX,
66+
final double maxY,
67+
final boolean useGeodeticCalculations) {
68+
this(projection, new Envelope(minX, maxX, minY, maxY), useGeodeticCalculations);
69+
}
70+
3271
/**
3372
* Constructor.
3473
*
@@ -47,6 +86,22 @@ public BBoxMapBounds(
4786
this(projection, new Envelope(minX, maxX, minY, maxY));
4887
}
4988

89+
/**
90+
* Create from a bbox.
91+
*
92+
* @param bbox the bounds.
93+
* @param useGeodeticCalculations force to use geodetic calculations in PseudoMercator projection
94+
*/
95+
public BBoxMapBounds(final ReferencedEnvelope bbox, final boolean useGeodeticCalculations) {
96+
this(
97+
bbox.getCoordinateReferenceSystem(),
98+
bbox.getMinX(),
99+
bbox.getMinY(),
100+
bbox.getMaxX(),
101+
bbox.getMaxY(),
102+
useGeodeticCalculations);
103+
}
104+
50105
/**
51106
* Create from a bbox.
52107
*
@@ -80,7 +135,8 @@ public MapBounds adjustedEnvelope(final Rectangle paintArea) {
80135
centerX - finalDiff,
81136
this.bbox.getMinY(),
82137
centerX + finalDiff,
83-
this.bbox.getMaxY());
138+
this.bbox.getMaxY(),
139+
this.useGeodeticCalculations());
84140
} else {
85141
double centerY = (this.bbox.getMinY() + this.bbox.getMaxY()) / 2;
86142
double factor = bboxAspectRatio / paintAreaAspectRatio;
@@ -90,7 +146,8 @@ public MapBounds adjustedEnvelope(final Rectangle paintArea) {
90146
this.bbox.getMinX(),
91147
centerY - finalDiff,
92148
this.bbox.getMaxX(),
93-
centerY + finalDiff);
149+
centerY + finalDiff,
150+
this.useGeodeticCalculations());
94151
}
95152
}
96153

@@ -107,34 +164,69 @@ public MapBounds adjustBoundsToNearestScale(
107164
getNearestScale(zoomLevels, tolerance, zoomLevelSnapStrategy, geodetic, paintArea, dpi);
108165

109166
Coordinate center = this.bbox.centre();
110-
return new CenterScaleMapBounds(getProjection(), center.x, center.y, newScale);
167+
return new CenterScaleMapBounds(
168+
getProjection(), center.x, center.y, newScale, this.useGeodeticCalculations());
111169
}
112170

113171
@Override
114172
public Scale getScale(final Rectangle paintArea, final double dpi) {
115173
final ReferencedEnvelope bboxAdjustedToScreen = toReferencedEnvelope(paintArea);
116174

117-
DistanceUnit projUnit = DistanceUnit.fromProjection(getProjection());
175+
CoordinateReferenceSystem crs = getProjection();
176+
DistanceUnit projUnit = DistanceUnit.fromProjection(crs);
118177

119178
double geoWidthInInches;
120-
if (projUnit == DistanceUnit.DEGREES) {
121-
GeodeticCalculator calculator = new GeodeticCalculator(getProjection());
122-
final double centerY = bboxAdjustedToScreen.centre().y;
123-
calculator.setStartingGeographicPoint(bboxAdjustedToScreen.getMinX(), centerY);
124-
calculator.setDestinationGeographicPoint(bboxAdjustedToScreen.getMaxX(), centerY);
125-
double geoWidthInEllipsoidUnits = calculator.getOrthodromicDistance();
126-
DistanceUnit ellipsoidUnit =
127-
DistanceUnit.fromString(calculator.getEllipsoid().getAxisUnit().toString());
128179

129-
geoWidthInInches = ellipsoidUnit.convertTo(geoWidthInEllipsoidUnits, DistanceUnit.IN);
180+
// If it is geodetic/degrees OR it is a special case requiring geodetic calculation
181+
// (PseudoMercator)
182+
if (projUnit == DistanceUnit.DEGREES
183+
|| (this.useGeodeticCalculations() && PseudoMercatorUtils.isPseudoMercator(crs))) {
184+
geoWidthInInches = this.computeGeodeticWidthInInches(bboxAdjustedToScreen);
130185
} else {
131-
// (scale * width ) / dpi = geowidith
186+
// (scale * width ) / dpi = geoWidth
132187
geoWidthInInches = projUnit.convertTo(bboxAdjustedToScreen.getWidth(), DistanceUnit.IN);
133188
}
134189

135190
return new Scale(geoWidthInInches * (dpi / paintArea.getWidth()), projUnit, dpi);
136191
}
137192

193+
@SuppressWarnings("UseSpecificCatch")
194+
private double computeGeodeticWidthInInches(final ReferencedEnvelope bbox) {
195+
try {
196+
CoordinateReferenceSystem crs = bbox.getCoordinateReferenceSystem();
197+
CoordinateReferenceSystem calcCrs = crs;
198+
199+
double centerY = bbox.centre().y;
200+
Coordinate start = new Coordinate(bbox.getMinX(), centerY);
201+
Coordinate end = new Coordinate(bbox.getMaxX(), centerY);
202+
203+
if (this.useGeodeticCalculations() && PseudoMercatorUtils.isPseudoMercator(crs)) {
204+
// Reproject to a geographic CRS (EPSG:4326) for accurate geodetic calculations
205+
final CoordinateReferenceSystem geographicCrs =
206+
GenericMapAttribute.parseProjection("EPSG:4326", true);
207+
final MathTransform transform = CRS.findMathTransform(crs, geographicCrs);
208+
start = JTS.transform(start, null, transform);
209+
end = JTS.transform(end, null, transform);
210+
calcCrs = geographicCrs;
211+
}
212+
213+
// Construct the calculator with the CRS that matches the coordinates
214+
GeodeticCalculator calculator = new GeodeticCalculator(calcCrs);
215+
216+
// --- Common Logic ---
217+
calculator.setStartingGeographicPoint(start.x, start.y);
218+
calculator.setDestinationGeographicPoint(end.x, end.y);
219+
final double orthodromicWidth = calculator.getOrthodromicDistance();
220+
final DistanceUnit ellipsoidUnit =
221+
DistanceUnit.fromString(calculator.getEllipsoid().getAxisUnit().toString());
222+
223+
return ellipsoidUnit.convertTo(orthodromicWidth, DistanceUnit.IN);
224+
225+
} catch (Exception e) {
226+
throw new PrintException("Failed to compute geodetic width", e);
227+
}
228+
}
229+
138230
@Override
139231
public MapBounds adjustBoundsToRotation(final double rotation) {
140232
if (FloatingPointUtil.equals(rotation, 0.0)) {
@@ -157,7 +249,13 @@ public MapBounds adjustBoundsToRotation(final double rotation) {
157249
final double rotatedMinY = this.bbox.getMinY() - heightDifference;
158250
final double rotatedMaxY = this.bbox.getMaxY() + heightDifference;
159251

160-
return new BBoxMapBounds(getProjection(), rotatedMinX, rotatedMinY, rotatedMaxX, rotatedMaxY);
252+
return new BBoxMapBounds(
253+
getProjection(),
254+
rotatedMinX,
255+
rotatedMinY,
256+
rotatedMaxX,
257+
rotatedMaxY,
258+
this.useGeodeticCalculations());
161259
}
162260

163261
private double getRotatedWidth(final double rotation) {
@@ -197,13 +295,15 @@ public MapBounds zoomOut(final double factor) {
197295
double minGeoY = centerY - destHeight / 2.0f;
198296
double maxGeoY = centerY + destHeight / 2.0f;
199297

200-
return new BBoxMapBounds(getProjection(), minGeoX, minGeoY, maxGeoX, maxGeoY);
298+
return new BBoxMapBounds(
299+
getProjection(), minGeoX, minGeoY, maxGeoX, maxGeoY, this.useGeodeticCalculations());
201300
}
202301

203302
@Override
204303
public MapBounds zoomToScale(final Scale scale) {
205304
Coordinate center = this.bbox.centre();
206-
return new CenterScaleMapBounds(getProjection(), center.x, center.y, scale);
305+
return new CenterScaleMapBounds(
306+
getProjection(), center.x, center.y, scale, this.useGeodeticCalculations());
207307
}
208308

209309
@Override
@@ -241,7 +341,8 @@ public MapBounds expand(final int margin, final Rectangle paintArea) {
241341
final double minGeoY = centerY - destHeight / 2.0;
242342
final double maxGeoY = centerY + destHeight / 2.0;
243343

244-
return new BBoxMapBounds(getProjection(), minGeoX, minGeoY, maxGeoX, maxGeoY);
344+
return new BBoxMapBounds(
345+
getProjection(), minGeoX, minGeoY, maxGeoX, maxGeoY, this.useGeodeticCalculations());
245346
}
246347

247348
@Override

0 commit comments

Comments
 (0)