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