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}