001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.projection;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.util.ArrayList;
007import java.util.Arrays;
008import java.util.EnumMap;
009import java.util.HashMap;
010import java.util.List;
011import java.util.Map;
012import java.util.Optional;
013import java.util.concurrent.ConcurrentHashMap;
014import java.util.regex.Matcher;
015import java.util.regex.Pattern;
016import java.util.stream.IntStream;
017
018import org.openstreetmap.josm.data.Bounds;
019import org.openstreetmap.josm.data.ProjectionBounds;
020import org.openstreetmap.josm.data.coor.EastNorth;
021import org.openstreetmap.josm.data.coor.LatLon;
022import org.openstreetmap.josm.data.coor.conversion.LatLonParser;
023import org.openstreetmap.josm.data.projection.datum.CentricDatum;
024import org.openstreetmap.josm.data.projection.datum.Datum;
025import org.openstreetmap.josm.data.projection.datum.NTV2Datum;
026import org.openstreetmap.josm.data.projection.datum.NullDatum;
027import org.openstreetmap.josm.data.projection.datum.SevenParameterDatum;
028import org.openstreetmap.josm.data.projection.datum.ThreeParameterDatum;
029import org.openstreetmap.josm.data.projection.datum.WGS84Datum;
030import org.openstreetmap.josm.data.projection.proj.ICentralMeridianProvider;
031import org.openstreetmap.josm.data.projection.proj.IScaleFactorProvider;
032import org.openstreetmap.josm.data.projection.proj.Mercator;
033import org.openstreetmap.josm.data.projection.proj.Proj;
034import org.openstreetmap.josm.data.projection.proj.ProjParameters;
035import org.openstreetmap.josm.tools.JosmRuntimeException;
036import org.openstreetmap.josm.tools.Logging;
037import org.openstreetmap.josm.tools.Utils;
038import org.openstreetmap.josm.tools.bugreport.BugReport;
039
040/**
041 * Custom projection.
042 *
043 * Inspired by PROJ.4 and Proj4J.
044 * @since 5072
045 */
046public class CustomProjection extends AbstractProjection {
047
048    /*
049     * Equation for METER_PER_UNIT_DEGREE taken from:
050     * https://github.com/openlayers/ol3/blob/master/src/ol/proj/epsg4326projection.js#L58
051     * Value for Radius taken form:
052     * https://github.com/openlayers/ol3/blob/master/src/ol/sphere/wgs84sphere.js#L11
053     */
054    private static final double METER_PER_UNIT_DEGREE = 2 * Math.PI * 6378137.0 / 360;
055    private static final Map<String, Double> UNITS_TO_METERS = getUnitsToMeters();
056    private static final Map<String, Double> PRIME_MERIDANS = getPrimeMeridians();
057
058    /**
059     * pref String that defines the projection
060     *
061     * null means fall back mode (Mercator)
062     */
063    protected String pref;
064    protected String name;
065    protected String code;
066    protected Bounds bounds;
067    private double metersPerUnitWMTS;
068    /**
069     * Starting in PROJ 4.8.0, the {@code +axis} argument can be used to control the axis orientation of the coordinate system.
070     * The default orientation is "easting, northing, up" but directions can be flipped, or axes flipped using
071     * combinations of the axes in the {@code +axis} switch. The values are: {@code e} (Easting), {@code w} (Westing),
072     * {@code n} (Northing), {@code s} (Southing), {@code u} (Up), {@code d} (Down);
073     * Examples: {@code +axis=enu} (the default easting, northing, elevation), {@code +axis=neu} (northing, easting, up;
074     * useful for "lat/long" geographic coordinates, or south orientated transverse mercator), {@code +axis=wnu}
075     * (westing, northing, up - some planetary coordinate systems have "west positive" coordinate systems)<p>
076     * See <a href="https://proj4.org/usage/projections.html#axis-orientation">proj4.org</a>
077     */
078    private String axis = "enu"; // default axis orientation is East, North, Up
079
080    private static final List<String> LON_LAT_VALUES = Arrays.asList("longlat", "latlon", "latlong");
081
082    /**
083     * Proj4-like projection parameters. See <a href="https://trac.osgeo.org/proj/wiki/GenParms">reference</a>.
084     * @since 7370 (public)
085     */
086    public enum Param {
087
088        /** False easting */
089        x_0("x_0", true),
090        /** False northing */
091        y_0("y_0", true),
092        /** Central meridian */
093        lon_0("lon_0", true),
094        /** Prime meridian */
095        pm("pm", true),
096        /** Scaling factor */
097        k_0("k_0", true),
098        /** Ellipsoid name (see {@code proj -le}) */
099        ellps("ellps", true),
100        /** Semimajor radius of the ellipsoid axis */
101        a("a", true),
102        /** Eccentricity of the ellipsoid squared */
103        es("es", true),
104        /** Reciprocal of the ellipsoid flattening term (e.g. 298) */
105        rf("rf", true),
106        /** Flattening of the ellipsoid = 1-sqrt(1-e^2) */
107        f("f", true),
108        /** Semiminor radius of the ellipsoid axis */
109        b("b", true),
110        /** Datum name (see {@code proj -ld}) */
111        datum("datum", true),
112        /** 3 or 7 term datum transform parameters */
113        towgs84("towgs84", true),
114        /** Filename of NTv2 grid file to use for datum transforms */
115        nadgrids("nadgrids", true),
116        /** Projection name (see {@code proj -l}) */
117        proj("proj", true),
118        /** Latitude of origin */
119        lat_0("lat_0", true),
120        /** Latitude of first standard parallel */
121        lat_1("lat_1", true),
122        /** Latitude of second standard parallel */
123        lat_2("lat_2", true),
124        /** Latitude of true scale (Polar Stereographic) */
125        lat_ts("lat_ts", true),
126        /** longitude of the center of the projection (Oblique Mercator) */
127        lonc("lonc", true),
128        /** azimuth (true) of the center line passing through the center of the
129         * projection (Oblique Mercator) */
130        alpha("alpha", true),
131        /** rectified bearing of the center line (Oblique Mercator) */
132        gamma("gamma", true),
133        /** select "Hotine" variant of Oblique Mercator */
134        no_off("no_off", false),
135        /** legacy alias for no_off */
136        no_uoff("no_uoff", false),
137        /** longitude of first point (Oblique Mercator) */
138        lon_1("lon_1", true),
139        /** longitude of second point (Oblique Mercator) */
140        lon_2("lon_2", true),
141        /** the exact proj.4 string will be preserved in the WKT representation */
142        wktext("wktext", false),  // ignored
143        /** meters, US survey feet, etc. */
144        units("units", true),
145        /** Don't use the /usr/share/proj/proj_def.dat defaults file */
146        no_defs("no_defs", false),
147        init("init", true),
148        /** crs units to meter multiplier */
149        to_meter("to_meter", true),
150        /** definition of axis for projection */
151        axis("axis", true),
152        /** UTM zone */
153        zone("zone", true),
154        /** indicate southern hemisphere for UTM */
155        south("south", false),
156        /** vertical units - ignore, as we don't use height information */
157        vunits("vunits", true),
158        // JOSM extensions, not present in PROJ.4
159        wmssrs("wmssrs", true),
160        bounds("bounds", true);
161
162        /** Parameter key */
163        public final String key;
164        /** {@code true} if the parameter has a value */
165        public final boolean hasValue;
166
167        /** Map of all parameters by key */
168        static final Map<String, Param> paramsByKey = new ConcurrentHashMap<>();
169        static {
170            for (Param p : Param.values()) {
171                paramsByKey.put(p.key, p);
172            }
173            // alias
174            paramsByKey.put("k", Param.k_0);
175        }
176
177        Param(String key, boolean hasValue) {
178            this.key = key;
179            this.hasValue = hasValue;
180        }
181    }
182
183    enum Polarity {
184        NORTH(LatLon.NORTH_POLE),
185        SOUTH(LatLon.SOUTH_POLE);
186
187        @SuppressWarnings("ImmutableEnumChecker")
188        private final LatLon latlon;
189
190        Polarity(LatLon latlon) {
191            this.latlon = latlon;
192        }
193
194        LatLon getLatLon() {
195            return latlon;
196        }
197    }
198
199    private EnumMap<Polarity, EastNorth> polesEN;
200
201    /**
202     * Constructs a new empty {@code CustomProjection}.
203     */
204    public CustomProjection() {
205        // contents can be set later with update()
206    }
207
208    /**
209     * Constructs a new {@code CustomProjection} with given parameters.
210     * @param pref String containing projection parameters
211     * (ex: "+proj=tmerc +lon_0=-3 +k_0=0.9996 +x_0=500000 +ellps=WGS84 +datum=WGS84 +bounds=-8,-5,2,85")
212     */
213    public CustomProjection(String pref) {
214        this(null, null, pref);
215    }
216
217    /**
218     * Constructs a new {@code CustomProjection} with given name, code and parameters.
219     *
220     * @param name describe projection in one or two words
221     * @param code unique code for this projection - may be null
222     * @param pref the string that defines the custom projection
223     */
224    public CustomProjection(String name, String code, String pref) {
225        this.name = name;
226        this.code = code;
227        this.pref = pref;
228        try {
229            update(pref);
230        } catch (ProjectionConfigurationException ex) {
231            Logging.trace(ex);
232            try {
233                update(null);
234            } catch (ProjectionConfigurationException ex1) {
235                throw BugReport.intercept(ex1).put("name", name).put("code", code).put("pref", pref);
236            }
237        }
238    }
239
240    /**
241     * Updates this {@code CustomProjection} with given parameters.
242     * @param pref String containing projection parameters (ex: "+proj=lonlat +ellps=WGS84 +datum=WGS84 +bounds=-180,-90,180,90")
243     * @throws ProjectionConfigurationException if {@code pref} cannot be parsed properly
244     */
245    public final void update(String pref) throws ProjectionConfigurationException {
246        this.pref = pref;
247        if (pref == null) {
248            ellps = Ellipsoid.WGS84;
249            datum = WGS84Datum.INSTANCE;
250            proj = new Mercator();
251            bounds = new Bounds(
252                    -85.05112877980659, -180.0,
253                    85.05112877980659, 180.0, true);
254        } else {
255            Map<String, String> parameters = parseParameterList(pref, false);
256            parameters = resolveInits(parameters, false);
257            ellps = parseEllipsoid(parameters);
258            datum = parseDatum(parameters, ellps);
259            if (ellps == null) {
260                ellps = datum.getEllipsoid();
261            }
262            proj = parseProjection(parameters, ellps);
263            // "utm" is a shortcut for a set of parameters
264            if ("utm".equals(parameters.get(Param.proj.key))) {
265                Integer zone;
266                try {
267                    zone = Integer.valueOf(Optional.ofNullable(parameters.get(Param.zone.key)).orElseThrow(
268                            () -> new ProjectionConfigurationException(tr("UTM projection (''+proj=utm'') requires ''+zone=...'' parameter."))));
269                } catch (NumberFormatException e) {
270                    zone = null;
271                }
272                if (zone == null || zone < 1 || zone > 60)
273                    throw new ProjectionConfigurationException(tr("Expected integer value in range 1-60 for ''+zone=...'' parameter."));
274                this.lon0 = 6d * zone - 183d;
275                this.k0 = 0.9996;
276                this.x0 = 500_000;
277                this.y0 = parameters.containsKey(Param.south.key) ? 10_000_000 : 0;
278            }
279            String s = parameters.get(Param.x_0.key);
280            if (s != null) {
281                this.x0 = parseDouble(s, Param.x_0.key);
282            }
283            s = parameters.get(Param.y_0.key);
284            if (s != null) {
285                this.y0 = parseDouble(s, Param.y_0.key);
286            }
287            s = parameters.get(Param.lon_0.key);
288            if (s != null) {
289                this.lon0 = parseAngle(s, Param.lon_0.key);
290            }
291            if (proj instanceof ICentralMeridianProvider) {
292                this.lon0 = ((ICentralMeridianProvider) proj).getCentralMeridian();
293            }
294            s = parameters.get(Param.pm.key);
295            if (s != null) {
296                if (PRIME_MERIDANS.containsKey(s)) {
297                    this.pm = PRIME_MERIDANS.get(s);
298                } else {
299                    this.pm = parseAngle(s, Param.pm.key);
300                }
301            }
302            s = parameters.get(Param.k_0.key);
303            if (s != null) {
304                this.k0 = parseDouble(s, Param.k_0.key);
305            }
306            if (proj instanceof IScaleFactorProvider) {
307                this.k0 *= ((IScaleFactorProvider) proj).getScaleFactor();
308            }
309            s = parameters.get(Param.bounds.key);
310            this.bounds = s != null ? parseBounds(s) : null;
311            s = parameters.get(Param.wmssrs.key);
312            if (s != null) {
313                this.code = s;
314            }
315            boolean defaultUnits = true;
316            s = parameters.get(Param.units.key);
317            if (s != null) {
318                s = Utils.strip(s, "\"");
319                if (UNITS_TO_METERS.containsKey(s)) {
320                    this.toMeter = UNITS_TO_METERS.get(s);
321                    this.metersPerUnitWMTS = this.toMeter;
322                    defaultUnits = false;
323                } else {
324                    throw new ProjectionConfigurationException(tr("No unit found for: {0}", s));
325                }
326            }
327            s = parameters.get(Param.to_meter.key);
328            if (s != null) {
329                this.toMeter = parseDouble(s, Param.to_meter.key);
330                this.metersPerUnitWMTS = this.toMeter;
331                defaultUnits = false;
332            }
333            if (defaultUnits) {
334                this.toMeter = 1;
335                this.metersPerUnitWMTS = proj.isGeographic() ? METER_PER_UNIT_DEGREE : 1;
336            }
337            s = parameters.get(Param.axis.key);
338            if (s != null) {
339                this.axis = s;
340            }
341        }
342    }
343
344    /**
345     * Parse a parameter list to key=value pairs.
346     *
347     * @param pref the parameter list
348     * @param ignoreUnknownParameter true, if unknown parameter should not raise exception
349     * @return parameters map
350     * @throws ProjectionConfigurationException in case of invalid parameter
351     */
352    public static Map<String, String> parseParameterList(String pref, boolean ignoreUnknownParameter) throws ProjectionConfigurationException {
353        Map<String, String> parameters = new HashMap<>();
354        String trimmedPref = pref.trim();
355        if (trimmedPref.isEmpty()) {
356            return parameters;
357        }
358
359        Pattern keyPattern = Pattern.compile("\\+(?<key>[a-zA-Z0-9_]+)(=(?<value>.*))?");
360        String[] parts = Utils.WHITE_SPACES_PATTERN.split(trimmedPref, -1);
361        for (String part : parts) {
362            Matcher m = keyPattern.matcher(part);
363            if (m.matches()) {
364                String key = m.group("key");
365                String value = m.group("value");
366                // some aliases
367                if (key.equals(Param.proj.key) && LON_LAT_VALUES.contains(value)) {
368                    value = "lonlat";
369                }
370                Param param = Param.paramsByKey.get(key);
371                if (param == null) {
372                    if (!ignoreUnknownParameter)
373                        throw new ProjectionConfigurationException(tr("Unknown parameter: ''{0}''.", key));
374                } else {
375                    if (param.hasValue && value == null)
376                        throw new ProjectionConfigurationException(tr("Value expected for parameter ''{0}''.", key));
377                    if (!param.hasValue && value != null)
378                        throw new ProjectionConfigurationException(tr("No value expected for parameter ''{0}''.", key));
379                    key = param.key; // To be really sure, we might have an alias.
380                }
381                parameters.put(key, value);
382            } else if (!part.startsWith("+")) {
383                throw new ProjectionConfigurationException(tr("Parameter must begin with a ''+'' character (found ''{0}'')", part));
384            } else {
385                throw new ProjectionConfigurationException(tr("Unexpected parameter format (''{0}'')", part));
386            }
387        }
388        return parameters;
389    }
390
391    /**
392     * Recursive resolution of +init includes.
393     *
394     * @param parameters parameters map
395     * @param ignoreUnknownParameter true, if unknown parameter should not raise exception
396     * @return parameters map with +init includes resolved
397     * @throws ProjectionConfigurationException in case of invalid parameter
398     */
399    public static Map<String, String> resolveInits(Map<String, String> parameters, boolean ignoreUnknownParameter)
400            throws ProjectionConfigurationException {
401        // recursive resolution of +init includes
402        String initKey = parameters.get(Param.init.key);
403        if (initKey != null) {
404            Map<String, String> initp;
405            try {
406                initp = parseParameterList(Optional.ofNullable(Projections.getInit(initKey)).orElseThrow(
407                        () -> new ProjectionConfigurationException(tr("Value ''{0}'' for option +init not supported.", initKey))),
408                        ignoreUnknownParameter);
409                initp = resolveInits(initp, ignoreUnknownParameter);
410            } catch (ProjectionConfigurationException ex) {
411                throw new ProjectionConfigurationException(initKey+": "+ex.getMessage(), ex);
412            }
413            initp.putAll(parameters);
414            return initp;
415        }
416        return parameters;
417    }
418
419    /**
420     * Gets the ellipsoid
421     * @param parameters The parameters to get the value from
422     * @return The Ellipsoid as specified with the parameters
423     * @throws ProjectionConfigurationException in case of invalid parameters
424     */
425    public Ellipsoid parseEllipsoid(Map<String, String> parameters) throws ProjectionConfigurationException {
426        String code = parameters.get(Param.ellps.key);
427        if (code != null) {
428            return Optional.ofNullable(Projections.getEllipsoid(code)).orElseThrow(
429                () -> new ProjectionConfigurationException(tr("Ellipsoid ''{0}'' not supported.", code)));
430        }
431        String s = parameters.get(Param.a.key);
432        if (s != null) {
433            double a = parseDouble(s, Param.a.key);
434            if (parameters.get(Param.es.key) != null) {
435                double es = parseDouble(parameters, Param.es.key);
436                return Ellipsoid.createAes(a, es);
437            }
438            if (parameters.get(Param.rf.key) != null) {
439                double rf = parseDouble(parameters, Param.rf.key);
440                return Ellipsoid.createArf(a, rf);
441            }
442            if (parameters.get(Param.f.key) != null) {
443                double f = parseDouble(parameters, Param.f.key);
444                return Ellipsoid.createAf(a, f);
445            }
446            if (parameters.get(Param.b.key) != null) {
447                double b = parseDouble(parameters, Param.b.key);
448                return Ellipsoid.createAb(a, b);
449            }
450        }
451        if (parameters.containsKey(Param.a.key) ||
452                parameters.containsKey(Param.es.key) ||
453                parameters.containsKey(Param.rf.key) ||
454                parameters.containsKey(Param.f.key) ||
455                parameters.containsKey(Param.b.key))
456            throw new ProjectionConfigurationException(tr("Combination of ellipsoid parameters is not supported."));
457        return null;
458    }
459
460    /**
461     * Gets the datum
462     * @param parameters The parameters to get the value from
463     * @param ellps The ellisoid that was previously computed
464     * @return The Datum as specified with the parameters
465     * @throws ProjectionConfigurationException in case of invalid parameters
466     */
467    public Datum parseDatum(Map<String, String> parameters, Ellipsoid ellps) throws ProjectionConfigurationException {
468        Datum result = null;
469        String datumId = parameters.get(Param.datum.key);
470        if (datumId != null) {
471            result = Optional.ofNullable(Projections.getDatum(datumId)).orElseThrow(
472                    () -> new ProjectionConfigurationException(tr("Unknown datum identifier: ''{0}''", datumId)));
473        }
474        if (ellps == null) {
475            if (result == null && parameters.containsKey(Param.no_defs.key))
476                throw new ProjectionConfigurationException(tr("Ellipsoid required (+ellps=* or +a=*, +b=*)"));
477            // nothing specified, use WGS84 as default
478            ellps = result != null ? result.getEllipsoid() : Ellipsoid.WGS84;
479        }
480
481        String nadgridsId = parameters.get(Param.nadgrids.key);
482        if (nadgridsId != null) {
483            if (nadgridsId.startsWith("@")) {
484                nadgridsId = nadgridsId.substring(1);
485            }
486            if ("null".equals(nadgridsId))
487                return new NullDatum(null, ellps);
488            final String fNadgridsId = nadgridsId;
489            return new NTV2Datum(fNadgridsId, null, ellps, Optional.ofNullable(Projections.getNTV2Grid(fNadgridsId)).orElseThrow(
490                    () -> new ProjectionConfigurationException(tr("Grid shift file ''{0}'' for option +nadgrids not supported.", fNadgridsId))));
491        }
492
493        String towgs84 = parameters.get(Param.towgs84.key);
494        if (towgs84 != null) {
495            Datum towgs84Datum = parseToWGS84(towgs84, ellps);
496            if (result == null || towgs84Datum instanceof ThreeParameterDatum || towgs84Datum instanceof SevenParameterDatum) {
497                // +datum has priority over +towgs84=0,0,0[,0,0,0,0]
498                return towgs84Datum;
499            }
500        }
501
502        return result != null ? result : new NullDatum(null, ellps);
503    }
504
505    /**
506     * Parse {@code towgs84} parameter.
507     * @param paramList List of parameter arguments (expected: 3 or 7)
508     * @param ellps ellipsoid
509     * @return parsed datum ({@link ThreeParameterDatum} or {@link SevenParameterDatum})
510     * @throws ProjectionConfigurationException if the arguments cannot be parsed
511     */
512    public Datum parseToWGS84(String paramList, Ellipsoid ellps) throws ProjectionConfigurationException {
513        String[] numStr = paramList.split(",", -1);
514
515        if (numStr.length != 3 && numStr.length != 7)
516            throw new ProjectionConfigurationException(tr("Unexpected number of arguments for parameter ''towgs84'' (must be 3 or 7)"));
517        List<Double> towgs84Param = new ArrayList<>();
518        for (String str : numStr) {
519            try {
520                towgs84Param.add(Double.valueOf(str));
521            } catch (NumberFormatException e) {
522                throw new ProjectionConfigurationException(tr("Unable to parse value of parameter ''towgs84'' (''{0}'')", str), e);
523            }
524        }
525        boolean isCentric = towgs84Param.stream().noneMatch(param -> param != 0);
526        if (isCentric)
527            return Ellipsoid.WGS84.equals(ellps) ? WGS84Datum.INSTANCE : new CentricDatum(null, null, ellps);
528        boolean is3Param = IntStream.range(3, towgs84Param.size()).noneMatch(i -> towgs84Param.get(i) != 0);
529        if (is3Param)
530            return new ThreeParameterDatum(null, null, ellps,
531                    towgs84Param.get(0),
532                    towgs84Param.get(1),
533                    towgs84Param.get(2));
534        else
535            return new SevenParameterDatum(null, null, ellps,
536                    towgs84Param.get(0),
537                    towgs84Param.get(1),
538                    towgs84Param.get(2),
539                    towgs84Param.get(3),
540                    towgs84Param.get(4),
541                    towgs84Param.get(5),
542                    towgs84Param.get(6));
543    }
544
545    /**
546     * Gets a projection using the given ellipsoid
547     * @param parameters Additional parameters
548     * @param ellps The {@link Ellipsoid}
549     * @return The projection
550     * @throws ProjectionConfigurationException in case of invalid parameters
551     */
552    public Proj parseProjection(Map<String, String> parameters, Ellipsoid ellps) throws ProjectionConfigurationException {
553        String id = parameters.get(Param.proj.key);
554        if (id == null) throw new ProjectionConfigurationException(tr("Projection required (+proj=*)"));
555
556        // "utm" is not a real projection, but a shortcut for a set of parameters
557        if ("utm".equals(id)) {
558            id = "tmerc";
559        }
560        Proj proj = Projections.getBaseProjection(id);
561        if (proj == null) throw new ProjectionConfigurationException(tr("Unknown projection identifier: ''{0}''", id));
562
563        ProjParameters projParams = new ProjParameters();
564
565        projParams.ellps = ellps;
566
567        String s;
568        s = parameters.get(Param.lat_0.key);
569        if (s != null) {
570            projParams.lat0 = parseAngle(s, Param.lat_0.key);
571        }
572        s = parameters.get(Param.lat_1.key);
573        if (s != null) {
574            projParams.lat1 = parseAngle(s, Param.lat_1.key);
575        }
576        s = parameters.get(Param.lat_2.key);
577        if (s != null) {
578            projParams.lat2 = parseAngle(s, Param.lat_2.key);
579        }
580        s = parameters.get(Param.lat_ts.key);
581        if (s != null) {
582            projParams.lat_ts = parseAngle(s, Param.lat_ts.key);
583        }
584        s = parameters.get(Param.lonc.key);
585        if (s != null) {
586            projParams.lonc = parseAngle(s, Param.lonc.key);
587        }
588        s = parameters.get(Param.alpha.key);
589        if (s != null) {
590            projParams.alpha = parseAngle(s, Param.alpha.key);
591        }
592        s = parameters.get(Param.gamma.key);
593        if (s != null) {
594            projParams.gamma = parseAngle(s, Param.gamma.key);
595        }
596        s = parameters.get(Param.lon_0.key);
597        if (s != null) {
598            projParams.lon0 = parseAngle(s, Param.lon_0.key);
599        }
600        s = parameters.get(Param.lon_1.key);
601        if (s != null) {
602            projParams.lon1 = parseAngle(s, Param.lon_1.key);
603        }
604        s = parameters.get(Param.lon_2.key);
605        if (s != null) {
606            projParams.lon2 = parseAngle(s, Param.lon_2.key);
607        }
608        if (parameters.containsKey(Param.no_off.key) || parameters.containsKey(Param.no_uoff.key)) {
609            projParams.no_off = Boolean.TRUE;
610        }
611        proj.initialize(projParams);
612        return proj;
613    }
614
615    /**
616     * Converts a string to a bounds object
617     * @param boundsStr The string as comma separated list of angles.
618     * @return The bounds.
619     * @throws ProjectionConfigurationException in case of invalid parameter
620     * @see CustomProjection#parseAngle(String, String)
621     */
622    public static Bounds parseBounds(String boundsStr) throws ProjectionConfigurationException {
623        String[] numStr = boundsStr.split(",", -1);
624        if (numStr.length != 4)
625            throw new ProjectionConfigurationException(tr("Unexpected number of arguments for parameter ''+bounds'' (must be 4)"));
626        return new Bounds(parseAngle(numStr[1], "minlat (+bounds)"),
627                parseAngle(numStr[0], "minlon (+bounds)"),
628                parseAngle(numStr[3], "maxlat (+bounds)"),
629                parseAngle(numStr[2], "maxlon (+bounds)"), false);
630    }
631
632    public static double parseDouble(Map<String, String> parameters, String parameterName) throws ProjectionConfigurationException {
633        if (!parameters.containsKey(parameterName))
634            throw new ProjectionConfigurationException(tr("Unknown parameter: ''{0}''.", parameterName));
635        return parseDouble(Optional.ofNullable(parameters.get(parameterName)).orElseThrow(
636                () -> new ProjectionConfigurationException(tr("Expected number argument for parameter ''{0}''", parameterName))),
637                parameterName);
638    }
639
640    public static double parseDouble(String doubleStr, String parameterName) throws ProjectionConfigurationException {
641        try {
642            return Double.parseDouble(doubleStr);
643        } catch (NumberFormatException e) {
644            throw new ProjectionConfigurationException(
645                    tr("Unable to parse value ''{1}'' of parameter ''{0}'' as number.", parameterName, doubleStr), e);
646        }
647    }
648
649    /**
650     * Convert an angle string to a double value
651     * @param angleStr The string. e.g. -1.1 or 50d10'3"
652     * @param parameterName Only for error message.
653     * @return The angle value, in degrees.
654     * @throws ProjectionConfigurationException in case of invalid parameter
655     */
656    public static double parseAngle(String angleStr, String parameterName) throws ProjectionConfigurationException {
657        try {
658            return LatLonParser.parseCoordinate(angleStr);
659        } catch (IllegalArgumentException e) {
660            throw new ProjectionConfigurationException(
661                    tr("Unable to parse value ''{1}'' of parameter ''{0}'' as coordinate value.", parameterName, angleStr), e);
662        }
663    }
664
665    @Override
666    public Integer getEpsgCode() {
667        if (code != null && code.startsWith("EPSG:")) {
668            try {
669                return Integer.valueOf(code.substring(5));
670            } catch (NumberFormatException e) {
671                Logging.warn(e);
672            }
673        }
674        return null;
675    }
676
677    @Override
678    public String toCode() {
679        if (code != null) {
680            return code;
681        } else if (pref != null) {
682            return "proj:" + pref;
683        } else {
684            return "proj:ERROR";
685        }
686    }
687
688    @Override
689    public Bounds getWorldBoundsLatLon() {
690        if (bounds == null) {
691            Bounds ab = proj.getAlgorithmBounds();
692            if (ab != null) {
693                double minlon = Math.max(ab.getMinLon() + lon0 + pm, -180);
694                double maxlon = Math.min(ab.getMaxLon() + lon0 + pm, 180);
695                bounds = new Bounds(ab.getMinLat(), minlon, ab.getMaxLat(), maxlon, false);
696            } else {
697                bounds = new Bounds(
698                    new LatLon(-90.0, -180.0),
699                    new LatLon(90.0, 180.0));
700            }
701        }
702        return bounds;
703    }
704
705    @Override
706    public String toString() {
707        return name != null ? name : tr("Custom Projection");
708    }
709
710    /**
711     * Factor to convert units of east/north coordinates to meters.
712     *
713     * When east/north coordinates are in degrees (geographic CRS), the scale
714     * at the equator is taken, i.e. 360 degrees corresponds to the length of
715     * the equator in meters.
716     *
717     * @return factor to convert units to meter
718     */
719    @Override
720    public double getMetersPerUnit() {
721        return metersPerUnitWMTS;
722    }
723
724    @Override
725    public boolean switchXY() {
726        // TODO: support for other axis orientation such as West South, and Up Down
727        // +axis=neu
728        return this.axis.startsWith("ne");
729    }
730
731    private static Map<String, Double> getUnitsToMeters() {
732        Map<String, Double> ret = new ConcurrentHashMap<>();
733        ret.put("km", 1000d);
734        ret.put("m", 1d);
735        ret.put("dm", 1d/10);
736        ret.put("cm", 1d/100);
737        ret.put("mm", 1d/1000);
738        ret.put("kmi", 1852.0);
739        ret.put("in", 0.0254);
740        ret.put("ft", 0.3048);
741        ret.put("yd", 0.9144);
742        ret.put("mi", 1609.344);
743        ret.put("fathom", 1.8288);
744        ret.put("chain", 20.1168);
745        ret.put("link", 0.201168);
746        ret.put("us-in", 1d/39.37);
747        ret.put("us-ft", 0.304800609601219);
748        ret.put("us-yd", 0.914401828803658);
749        ret.put("us-ch", 20.11684023368047);
750        ret.put("us-mi", 1609.347218694437);
751        ret.put("ind-yd", 0.91439523);
752        ret.put("ind-ft", 0.30479841);
753        ret.put("ind-ch", 20.11669506);
754        ret.put("degree", METER_PER_UNIT_DEGREE);
755        return ret;
756    }
757
758    private static Map<String, Double> getPrimeMeridians() {
759        Map<String, Double> ret = new ConcurrentHashMap<>();
760        try {
761            ret.put("greenwich", 0.0);
762            ret.put("lisbon", parseAngle("9d07'54.862\"W", null));
763            ret.put("paris", parseAngle("2d20'14.025\"E", null));
764            ret.put("bogota", parseAngle("74d04'51.3\"W", null));
765            ret.put("madrid", parseAngle("3d41'16.58\"W", null));
766            ret.put("rome", parseAngle("12d27'8.4\"E", null));
767            ret.put("bern", parseAngle("7d26'22.5\"E", null));
768            ret.put("jakarta", parseAngle("106d48'27.79\"E", null));
769            ret.put("ferro", parseAngle("17d40'W", null));
770            ret.put("brussels", parseAngle("4d22'4.71\"E", null));
771            ret.put("stockholm", parseAngle("18d3'29.8\"E", null));
772            ret.put("athens", parseAngle("23d42'58.815\"E", null));
773            ret.put("oslo", parseAngle("10d43'22.5\"E", null));
774        } catch (ProjectionConfigurationException ex) {
775            throw new IllegalStateException(ex);
776        }
777        return ret;
778    }
779
780    private static EastNorth getPointAlong(int i, int n, ProjectionBounds r) {
781        double dEast = (r.maxEast - r.minEast) / n;
782        double dNorth = (r.maxNorth - r.minNorth) / n;
783        if (i < n) {
784            return new EastNorth(r.minEast + i * dEast, r.minNorth);
785        } else if (i < 2*n) {
786            i -= n;
787            return new EastNorth(r.maxEast, r.minNorth + i * dNorth);
788        } else if (i < 3*n) {
789            i -= 2*n;
790            return new EastNorth(r.maxEast - i * dEast, r.maxNorth);
791        } else if (i < 4*n) {
792            i -= 3*n;
793            return new EastNorth(r.minEast, r.maxNorth - i * dNorth);
794        } else {
795            throw new AssertionError();
796        }
797    }
798
799    private EastNorth getPole(Polarity whichPole) {
800        if (polesEN == null) {
801            polesEN = new EnumMap<>(Polarity.class);
802            for (Polarity p : Polarity.values()) {
803                polesEN.put(p, null);
804                LatLon ll = p.getLatLon();
805                try {
806                    EastNorth enPole = latlon2eastNorth(ll);
807                    if (enPole.isValid()) {
808                        // project back and check if the result is somewhat reasonable
809                        LatLon llBack = eastNorth2latlon(enPole);
810                        if (llBack.isValid() && ll.greatCircleDistance(llBack) < 1000) {
811                            polesEN.put(p, enPole);
812                        }
813                    }
814                } catch (JosmRuntimeException | IllegalArgumentException | IllegalStateException e) {
815                    Logging.error(e);
816                }
817            }
818        }
819        return polesEN.get(whichPole);
820    }
821
822    @Override
823    public Bounds getLatLonBoundsBox(ProjectionBounds r) {
824        final int n = 10;
825        Bounds result = new Bounds(eastNorth2latlon(r.getMin()));
826        result.extend(eastNorth2latlon(r.getMax()));
827        LatLon llPrev = null;
828        for (int i = 0; i < 4*n; i++) {
829            LatLon llNow = eastNorth2latlon(getPointAlong(i, n, r));
830            result.extend(llNow);
831            // check if segment crosses 180th meridian and if so, make sure
832            // to extend bounds to +/-180 degrees longitude
833            if (llPrev != null) {
834                double lon1 = llPrev.lon();
835                double lon2 = llNow.lon();
836                if (90 < lon1 && lon1 < 180 && -180 < lon2 && lon2 < -90) {
837                    result.extend(new LatLon(llPrev.lat(), 180));
838                    result.extend(new LatLon(llNow.lat(), -180));
839                }
840                if (90 < lon2 && lon2 < 180 && -180 < lon1 && lon1 < -90) {
841                    result.extend(new LatLon(llNow.lat(), 180));
842                    result.extend(new LatLon(llPrev.lat(), -180));
843                }
844            }
845            llPrev = llNow;
846        }
847        // if the box contains one of the poles, the above method did not get
848        // correct min/max latitude value
849        for (Polarity p : Polarity.values()) {
850            EastNorth pole = getPole(p);
851            if (pole != null && r.contains(pole)) {
852                result.extend(p.getLatLon());
853            }
854        }
855        return result;
856    }
857
858    @Override
859    public ProjectionBounds getEastNorthBoundsBox(ProjectionBounds box, Projection boxProjection) {
860        final int n = 8;
861        ProjectionBounds result = null;
862        for (int i = 0; i < 4*n; i++) {
863            EastNorth en = latlon2eastNorth(boxProjection.eastNorth2latlon(getPointAlong(i, n, box)));
864            if (result == null) {
865                result = new ProjectionBounds(en);
866            } else {
867                result.extend(en);
868            }
869        }
870        return result;
871    }
872
873    /**
874     * Return true, if a geographic coordinate reference system is represented.
875     *
876     * I.e. if it returns latitude/longitude values rather than Cartesian
877     * east/north coordinates on a flat surface.
878     * @return true, if it is geographic
879     * @since 12792
880     */
881    public boolean isGeographic() {
882        return proj.isGeographic();
883    }
884
885}