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