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}