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