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