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.HashMap;
008import java.util.List;
009import java.util.Map;
010import java.util.concurrent.ConcurrentHashMap;
011import java.util.regex.Matcher;
012import java.util.regex.Pattern;
013
014import org.openstreetmap.josm.Main;
015import org.openstreetmap.josm.data.Bounds;
016import org.openstreetmap.josm.data.coor.LatLon;
017import org.openstreetmap.josm.data.projection.datum.CentricDatum;
018import org.openstreetmap.josm.data.projection.datum.Datum;
019import org.openstreetmap.josm.data.projection.datum.NTV2Datum;
020import org.openstreetmap.josm.data.projection.datum.NTV2GridShiftFileWrapper;
021import org.openstreetmap.josm.data.projection.datum.NullDatum;
022import org.openstreetmap.josm.data.projection.datum.SevenParameterDatum;
023import org.openstreetmap.josm.data.projection.datum.ThreeParameterDatum;
024import org.openstreetmap.josm.data.projection.datum.WGS84Datum;
025import org.openstreetmap.josm.data.projection.proj.Mercator;
026import org.openstreetmap.josm.data.projection.proj.Proj;
027import org.openstreetmap.josm.data.projection.proj.ProjParameters;
028import org.openstreetmap.josm.tools.Utils;
029
030/**
031 * Custom projection.
032 *
033 * Inspired by PROJ.4 and Proj4J.
034 * @since 5072
035 */
036public class CustomProjection extends AbstractProjection {
037
038    private static final double METER_PER_UNIT_DEGREE = 2 * Math.PI * 6370997 / 360;
039    private static final Map<String, Double> UNITS_TO_METERS = getUnitsToMeters();
040
041    /**
042     * pref String that defines the projection
043     *
044     * null means fall back mode (Mercator)
045     */
046    protected String pref;
047    protected String name;
048    protected String code;
049    protected String cacheDir;
050    protected Bounds bounds;
051    private double metersPerUnit = METER_PER_UNIT_DEGREE; // default to degrees
052    private String axis = "enu"; // default axis orientation is East, North, Up
053
054    /**
055     * Proj4-like projection parameters. See <a href="https://trac.osgeo.org/proj/wiki/GenParms">reference</a>.
056     * @since 7370 (public)
057     */
058    public enum Param {
059
060        /** False easting */
061        x_0("x_0", true),
062        /** False northing */
063        y_0("y_0", true),
064        /** Central meridian */
065        lon_0("lon_0", true),
066        /** Scaling factor */
067        k_0("k_0", true),
068        /** Ellipsoid name (see {@code proj -le}) */
069        ellps("ellps", true),
070        /** Semimajor radius of the ellipsoid axis */
071        a("a", true),
072        /** Eccentricity of the ellipsoid squared */
073        es("es", true),
074        /** Reciprocal of the ellipsoid flattening term (e.g. 298) */
075        rf("rf", true),
076        /** Flattening of the ellipsoid = 1-sqrt(1-e^2) */
077        f("f", true),
078        /** Semiminor radius of the ellipsoid axis */
079        b("b", true),
080        /** Datum name (see {@code proj -ld}) */
081        datum("datum", true),
082        /** 3 or 7 term datum transform parameters */
083        towgs84("towgs84", true),
084        /** Filename of NTv2 grid file to use for datum transforms */
085        nadgrids("nadgrids", true),
086        /** Projection name (see {@code proj -l}) */
087        proj("proj", true),
088        /** Latitude of origin */
089        lat_0("lat_0", true),
090        /** Latitude of first standard parallel */
091        lat_1("lat_1", true),
092        /** Latitude of second standard parallel */
093        lat_2("lat_2", true),
094        /** the exact proj.4 string will be preserved in the WKT representation */
095        wktext("wktext", false),  // ignored
096        /** meters, US survey feet, etc. */
097        units("units", true),
098        /** Don't use the /usr/share/proj/proj_def.dat defaults file */
099        no_defs("no_defs", false),
100        init("init", true),
101        /** crs units to meter multiplier */
102        to_meter("to_meter", true),
103        /** definition of axis for projection */
104        axis("axis", true),
105        /** UTM zone */
106        zone("zone", true),
107        /** indicate southern hemisphere for UTM */
108        south("south", false),
109        // JOSM extensions, not present in PROJ.4
110        wmssrs("wmssrs", true),
111        bounds("bounds", true);
112
113        /** Parameter key */
114        public final String key;
115        /** {@code true} if the parameter has a value */
116        public final boolean hasValue;
117
118        /** Map of all parameters by key */
119        static final Map<String, Param> paramsByKey = new ConcurrentHashMap<>();
120        static {
121            for (Param p : Param.values()) {
122                paramsByKey.put(p.key, p);
123            }
124        }
125
126        Param(String key, boolean hasValue) {
127            this.key = key;
128            this.hasValue = hasValue;
129        }
130    }
131
132    /**
133     * Constructs a new empty {@code CustomProjection}.
134     */
135    public CustomProjection() {
136        // contents can be set later with update()
137    }
138
139    /**
140     * Constructs a new {@code CustomProjection} with given parameters.
141     * @param pref String containing projection parameters
142     * (ex: "+proj=tmerc +lon_0=-3 +k_0=0.9996 +x_0=500000 +ellps=WGS84 +datum=WGS84 +bounds=-8,-5,2,85")
143     */
144    public CustomProjection(String pref) {
145        this(null, null, pref, null);
146    }
147
148    /**
149     * Constructs a new {@code CustomProjection} with given name, code and parameters.
150     *
151     * @param name describe projection in one or two words
152     * @param code unique code for this projection - may be null
153     * @param pref the string that defines the custom projection
154     * @param cacheDir cache directory name
155     */
156    public CustomProjection(String name, String code, String pref, String cacheDir) {
157        this.name = name;
158        this.code = code;
159        this.pref = pref;
160        this.cacheDir = cacheDir;
161        try {
162            update(pref);
163        } catch (ProjectionConfigurationException ex) {
164            try {
165                update(null);
166            } catch (ProjectionConfigurationException ex1) {
167                throw new RuntimeException(ex1);
168            }
169        }
170    }
171
172    /**
173     * Updates this {@code CustomProjection} with given parameters.
174     * @param pref String containing projection parameters (ex: "+proj=lonlat +ellps=WGS84 +datum=WGS84 +bounds=-180,-90,180,90")
175     * @throws ProjectionConfigurationException if {@code pref} cannot be parsed properly
176     */
177    public final void update(String pref) throws ProjectionConfigurationException {
178        this.pref = pref;
179        if (pref == null) {
180            ellps = Ellipsoid.WGS84;
181            datum = WGS84Datum.INSTANCE;
182            proj = new Mercator();
183            bounds = new Bounds(
184                    -85.05112877980659, -180.0,
185                    85.05112877980659, 180.0, true);
186        } else {
187            Map<String, String> parameters = parseParameterList(pref);
188            ellps = parseEllipsoid(parameters);
189            datum = parseDatum(parameters, ellps);
190            if (ellps == null) {
191                ellps = datum.getEllipsoid();
192            }
193            proj = parseProjection(parameters, ellps);
194            // "utm" is a shortcut for a set of parameters
195            if ("utm".equals(parameters.get(Param.proj.key))) {
196                String zoneStr = parameters.get(Param.zone.key);
197                Integer zone;
198                if (zoneStr == null)
199                    throw new ProjectionConfigurationException(tr("UTM projection (''+proj=utm'') requires ''+zone=...'' parameter."));
200                try {
201                    zone = Integer.valueOf(zoneStr);
202                } catch (NumberFormatException e) {
203                    zone = null;
204                }
205                if (zone == null || zone < 1 || zone > 60)
206                    throw new ProjectionConfigurationException(tr("Expected integer value in range 1-60 for ''+zone=...'' parameter."));
207                this.lon0 = 6 * zone - 183;
208                this.k0 = 0.9996;
209                this.x0 = 500000;
210                this.y0 = parameters.containsKey(Param.south.key) ? 10000000 : 0;
211            }
212            String s = parameters.get(Param.x_0.key);
213            if (s != null) {
214                this.x0 = parseDouble(s, Param.x_0.key);
215            }
216            s = parameters.get(Param.y_0.key);
217            if (s != null) {
218                this.y0 = parseDouble(s, Param.y_0.key);
219            }
220            s = parameters.get(Param.lon_0.key);
221            if (s != null) {
222                this.lon0 = parseAngle(s, Param.lon_0.key);
223            }
224            s = parameters.get(Param.k_0.key);
225            if (s != null) {
226                this.k0 = parseDouble(s, Param.k_0.key);
227            }
228            s = parameters.get(Param.bounds.key);
229            if (s != null) {
230                this.bounds = parseBounds(s);
231            }
232            s = parameters.get(Param.wmssrs.key);
233            if (s != null) {
234                this.code = s;
235            }
236            s = parameters.get(Param.units.key);
237            if (s != null) {
238                s = Utils.strip(s, "\"");
239                if (UNITS_TO_METERS.containsKey(s)) {
240                    this.metersPerUnit = UNITS_TO_METERS.get(s);
241                } else {
242                    Main.warn("No metersPerUnit found for: " + s);
243                }
244            }
245            s = parameters.get(Param.to_meter.key);
246            if (s != null) {
247                this.metersPerUnit = parseDouble(s, Param.to_meter.key);
248            }
249            s = parameters.get(Param.axis.key);
250            if (s != null) {
251                this.axis  = s;
252            }
253        }
254    }
255
256    private Map<String, String> parseParameterList(String pref) throws ProjectionConfigurationException {
257        Map<String, String> parameters = new HashMap<>();
258        String[] parts = Utils.WHITE_SPACES_PATTERN.split(pref.trim());
259        if (pref.trim().isEmpty()) {
260            parts = new String[0];
261        }
262        for (String part : parts) {
263            if (part.isEmpty() || part.charAt(0) != '+')
264                throw new ProjectionConfigurationException(tr("Parameter must begin with a ''+'' character (found ''{0}'')", part));
265            Matcher m = Pattern.compile("\\+([a-zA-Z0-9_]+)(=(.*))?").matcher(part);
266            if (m.matches()) {
267                String key = m.group(1);
268                // alias
269                if ("k".equals(key)) {
270                    key = Param.k_0.key;
271                }
272                String value = null;
273                if (m.groupCount() >= 3) {
274                    value = m.group(3);
275                    // some aliases
276                    if (key.equals(Param.proj.key)) {
277                        if ("longlat".equals(value) || "latlon".equals(value) || "latlong".equals(value)) {
278                            value = "lonlat";
279                        }
280                    }
281                }
282                if (!Param.paramsByKey.containsKey(key))
283                    throw new ProjectionConfigurationException(tr("Unknown parameter: ''{0}''.", key));
284                if (Param.paramsByKey.get(key).hasValue && value == null)
285                    throw new ProjectionConfigurationException(tr("Value expected for parameter ''{0}''.", key));
286                if (!Param.paramsByKey.get(key).hasValue && value != null)
287                    throw new ProjectionConfigurationException(tr("No value expected for parameter ''{0}''.", key));
288                parameters.put(key, value);
289            } else
290                throw new ProjectionConfigurationException(tr("Unexpected parameter format (''{0}'')", part));
291        }
292        // recursive resolution of +init includes
293        String initKey = parameters.get(Param.init.key);
294        if (initKey != null) {
295            String init = Projections.getInit(initKey);
296            if (init == null)
297                throw new ProjectionConfigurationException(tr("Value ''{0}'' for option +init not supported.", initKey));
298            Map<String, String> initp = null;
299            try {
300                initp = parseParameterList(init);
301            } catch (ProjectionConfigurationException ex) {
302                throw new ProjectionConfigurationException(tr(initKey+": "+ex.getMessage()), ex);
303            }
304            for (Map.Entry<String, String> e : parameters.entrySet()) {
305                initp.put(e.getKey(), e.getValue());
306            }
307            return initp;
308        }
309        return parameters;
310    }
311
312    public Ellipsoid parseEllipsoid(Map<String, String> parameters) throws ProjectionConfigurationException {
313        String code = parameters.get(Param.ellps.key);
314        if (code != null) {
315            Ellipsoid ellipsoid = Projections.getEllipsoid(code);
316            if (ellipsoid == null) {
317                throw new ProjectionConfigurationException(tr("Ellipsoid ''{0}'' not supported.", code));
318            } else {
319                return ellipsoid;
320            }
321        }
322        String s = parameters.get(Param.a.key);
323        if (s != null) {
324            double a = parseDouble(s, Param.a.key);
325            if (parameters.get(Param.es.key) != null) {
326                double es = parseDouble(parameters, Param.es.key);
327                return Ellipsoid.create_a_es(a, es);
328            }
329            if (parameters.get(Param.rf.key) != null) {
330                double rf = parseDouble(parameters, Param.rf.key);
331                return Ellipsoid.create_a_rf(a, rf);
332            }
333            if (parameters.get(Param.f.key) != null) {
334                double f = parseDouble(parameters, Param.f.key);
335                return Ellipsoid.create_a_f(a, f);
336            }
337            if (parameters.get(Param.b.key) != null) {
338                double b = parseDouble(parameters, Param.b.key);
339                return Ellipsoid.create_a_b(a, b);
340            }
341        }
342        if (parameters.containsKey(Param.a.key) ||
343                parameters.containsKey(Param.es.key) ||
344                parameters.containsKey(Param.rf.key) ||
345                parameters.containsKey(Param.f.key) ||
346                parameters.containsKey(Param.b.key))
347            throw new ProjectionConfigurationException(tr("Combination of ellipsoid parameters is not supported."));
348        return null;
349    }
350
351    public Datum parseDatum(Map<String, String> parameters, Ellipsoid ellps) throws ProjectionConfigurationException {
352        String datumId = parameters.get(Param.datum.key);
353        if (datumId != null) {
354            Datum datum = Projections.getDatum(datumId);
355            if (datum == null) throw new ProjectionConfigurationException(tr("Unknown datum identifier: ''{0}''", datumId));
356            return datum;
357        }
358        if (ellps == null) {
359            if (parameters.containsKey(Param.no_defs.key))
360                throw new ProjectionConfigurationException(tr("Ellipsoid required (+ellps=* or +a=*, +b=*)"));
361            // nothing specified, use WGS84 as default
362            ellps = Ellipsoid.WGS84;
363        }
364
365        String nadgridsId = parameters.get(Param.nadgrids.key);
366        if (nadgridsId != null) {
367            if (nadgridsId.startsWith("@")) {
368                nadgridsId = nadgridsId.substring(1);
369            }
370            if ("null".equals(nadgridsId))
371                return new NullDatum(null, ellps);
372            NTV2GridShiftFileWrapper nadgrids = Projections.getNTV2Grid(nadgridsId);
373            if (nadgrids == null)
374                throw new ProjectionConfigurationException(tr("Grid shift file ''{0}'' for option +nadgrids not supported.", nadgridsId));
375            return new NTV2Datum(nadgridsId, null, ellps, nadgrids);
376        }
377
378        String towgs84 = parameters.get(Param.towgs84.key);
379        if (towgs84 != null)
380            return parseToWGS84(towgs84, ellps);
381
382        if (parameters.containsKey(Param.no_defs.key))
383            throw new ProjectionConfigurationException(tr("Datum required (+datum=*, +towgs84=* or +nadgrids=*)"));
384        return new CentricDatum(null, null, ellps);
385    }
386
387    public Datum parseToWGS84(String paramList, Ellipsoid ellps) throws ProjectionConfigurationException {
388        String[] numStr = paramList.split(",");
389
390        if (numStr.length != 3 && numStr.length != 7)
391            throw new ProjectionConfigurationException(tr("Unexpected number of arguments for parameter ''towgs84'' (must be 3 or 7)"));
392        List<Double> towgs84Param = new ArrayList<>();
393        for (String str : numStr) {
394            try {
395                towgs84Param.add(Double.valueOf(str));
396            } catch (NumberFormatException e) {
397                throw new ProjectionConfigurationException(tr("Unable to parse value of parameter ''towgs84'' (''{0}'')", str), e);
398            }
399        }
400        boolean isCentric = true;
401        for (Double param : towgs84Param) {
402            if (param != 0) {
403                isCentric = false;
404                break;
405            }
406        }
407        if (isCentric)
408            return new CentricDatum(null, null, ellps);
409        boolean is3Param = true;
410        for (int i = 3; i < towgs84Param.size(); i++) {
411            if (towgs84Param.get(i) != 0) {
412                is3Param = false;
413                break;
414            }
415        }
416        if (is3Param)
417            return new ThreeParameterDatum(null, null, ellps,
418                    towgs84Param.get(0),
419                    towgs84Param.get(1),
420                    towgs84Param.get(2));
421        else
422            return new SevenParameterDatum(null, null, ellps,
423                    towgs84Param.get(0),
424                    towgs84Param.get(1),
425                    towgs84Param.get(2),
426                    towgs84Param.get(3),
427                    towgs84Param.get(4),
428                    towgs84Param.get(5),
429                    towgs84Param.get(6));
430    }
431
432    public Proj parseProjection(Map<String, String> parameters, Ellipsoid ellps) throws ProjectionConfigurationException {
433        String id = parameters.get(Param.proj.key);
434        if (id == null) throw new ProjectionConfigurationException(tr("Projection required (+proj=*)"));
435
436        // "utm" is not a real projection, but a shortcut for a set of parameters
437        if ("utm".equals(id)) {
438            id = "tmerc";
439        }
440        Proj proj =  Projections.getBaseProjection(id);
441        if (proj == null) throw new ProjectionConfigurationException(tr("Unknown projection identifier: ''{0}''", id));
442
443        ProjParameters projParams = new ProjParameters();
444
445        projParams.ellps = ellps;
446
447        String s;
448        s = parameters.get(Param.lat_0.key);
449        if (s != null) {
450            projParams.lat0 = parseAngle(s, Param.lat_0.key);
451        }
452        s = parameters.get(Param.lat_1.key);
453        if (s != null) {
454            projParams.lat1 = parseAngle(s, Param.lat_1.key);
455        }
456        s = parameters.get(Param.lat_2.key);
457        if (s != null) {
458            projParams.lat2 = parseAngle(s, Param.lat_2.key);
459        }
460        proj.initialize(projParams);
461        return proj;
462    }
463
464    public static Bounds parseBounds(String boundsStr) throws ProjectionConfigurationException {
465        String[] numStr = boundsStr.split(",");
466        if (numStr.length != 4)
467            throw new ProjectionConfigurationException(tr("Unexpected number of arguments for parameter ''+bounds'' (must be 4)"));
468        return new Bounds(parseAngle(numStr[1], "minlat (+bounds)"),
469                parseAngle(numStr[0], "minlon (+bounds)"),
470                parseAngle(numStr[3], "maxlat (+bounds)"),
471                parseAngle(numStr[2], "maxlon (+bounds)"), false);
472    }
473
474    public static double parseDouble(Map<String, String> parameters, String parameterName) throws ProjectionConfigurationException {
475        if (!parameters.containsKey(parameterName))
476            throw new ProjectionConfigurationException(tr("Unknown parameter ''{0}''", parameterName));
477        String doubleStr = parameters.get(parameterName);
478        if (doubleStr == null)
479            throw new ProjectionConfigurationException(
480                    tr("Expected number argument for parameter ''{0}''", parameterName));
481        return parseDouble(doubleStr, parameterName);
482    }
483
484    public static double parseDouble(String doubleStr, String parameterName) throws ProjectionConfigurationException {
485        try {
486            return Double.parseDouble(doubleStr);
487        } catch (NumberFormatException e) {
488            throw new ProjectionConfigurationException(
489                    tr("Unable to parse value ''{1}'' of parameter ''{0}'' as number.", parameterName, doubleStr), e);
490        }
491    }
492
493    public static double parseAngle(String angleStr, String parameterName) throws ProjectionConfigurationException {
494        String s = angleStr;
495        double value = 0;
496        boolean neg = false;
497        Matcher m = Pattern.compile("^-").matcher(s);
498        if (m.find()) {
499            neg = true;
500            s = s.substring(m.end());
501        }
502        final String FLOAT = "(\\d+(\\.\\d*)?)";
503        boolean dms = false;
504        double deg = 0.0, min = 0.0, sec = 0.0;
505        // degrees
506        m = Pattern.compile("^"+FLOAT+"d").matcher(s);
507        if (m.find()) {
508            s = s.substring(m.end());
509            deg = Double.parseDouble(m.group(1));
510            dms = true;
511        }
512        // minutes
513        m = Pattern.compile("^"+FLOAT+"'").matcher(s);
514        if (m.find()) {
515            s = s.substring(m.end());
516            min = Double.parseDouble(m.group(1));
517            dms = true;
518        }
519        // seconds
520        m = Pattern.compile("^"+FLOAT+"\"").matcher(s);
521        if (m.find()) {
522            s = s.substring(m.end());
523            sec = Double.parseDouble(m.group(1));
524            dms = true;
525        }
526        // plain number (in degrees)
527        if (dms) {
528            value = deg + (min/60.0) + (sec/3600.0);
529        } else {
530            m = Pattern.compile("^"+FLOAT).matcher(s);
531            if (m.find()) {
532                s = s.substring(m.end());
533                value += Double.parseDouble(m.group(1));
534            }
535        }
536        m = Pattern.compile("^(N|E)", Pattern.CASE_INSENSITIVE).matcher(s);
537        if (m.find()) {
538            s = s.substring(m.end());
539        } else {
540            m = Pattern.compile("^(S|W)", Pattern.CASE_INSENSITIVE).matcher(s);
541            if (m.find()) {
542                s = s.substring(m.end());
543                neg = !neg;
544            }
545        }
546        if (neg) {
547            value = -value;
548        }
549        if (!s.isEmpty()) {
550            throw new ProjectionConfigurationException(
551                    tr("Unable to parse value ''{1}'' of parameter ''{0}'' as coordinate value.", parameterName, angleStr));
552        }
553        return value;
554    }
555
556    @Override
557    public Integer getEpsgCode() {
558        if (code != null && code.startsWith("EPSG:")) {
559            try {
560                return Integer.valueOf(code.substring(5));
561            } catch (NumberFormatException e) {
562                Main.warn(e);
563            }
564        }
565        return null;
566    }
567
568    @Override
569    public String toCode() {
570        return code != null ? code : "proj:" + (pref == null ? "ERROR" : pref);
571    }
572
573    @Override
574    public String getCacheDirectoryName() {
575        return cacheDir != null ? cacheDir : "proj-"+Utils.md5Hex(pref == null ? "" : pref).substring(0, 4);
576    }
577
578    @Override
579    public Bounds getWorldBoundsLatLon() {
580        if (bounds != null) return bounds;
581        return new Bounds(
582            new LatLon(-90.0, -180.0),
583            new LatLon(90.0, 180.0));
584    }
585
586    @Override
587    public String toString() {
588        return name != null ? name : tr("Custom Projection");
589    }
590
591    @Override
592    public double getMetersPerUnit() {
593        return metersPerUnit;
594    }
595
596    @Override
597    public boolean switchXY() {
598        // TODO: support for other axis orientation such as West South, and Up Down
599        return this.axis.startsWith("ne");
600    }
601
602    private static Map<String, Double> getUnitsToMeters() {
603        Map<String, Double> ret = new ConcurrentHashMap<>();
604        ret.put("km", 1000d);
605        ret.put("m", 1d);
606        ret.put("dm", 1d/10);
607        ret.put("cm", 1d/100);
608        ret.put("mm", 1d/1000);
609        ret.put("kmi", 1852.0);
610        ret.put("in", 0.0254);
611        ret.put("ft", 0.3048);
612        ret.put("yd", 0.9144);
613        ret.put("mi", 1609.344);
614        ret.put("fathom", 1.8288);
615        ret.put("chain", 20.1168);
616        ret.put("link", 0.201168);
617        ret.put("us-in", 1d/39.37);
618        ret.put("us-ft", 0.304800609601219);
619        ret.put("us-yd", 0.914401828803658);
620        ret.put("us-ch", 20.11684023368047);
621        ret.put("us-mi", 1609.347218694437);
622        ret.put("ind-yd", 0.91439523);
623        ret.put("ind-ft", 0.30479841);
624        ret.put("ind-ch", 20.11669506);
625        ret.put("degree", METER_PER_UNIT_DEGREE);
626        return ret;
627    }
628}