diff --git a/src/ProjNet/CoordinateSystems/Projections/ProjectionsRegistry.cs b/src/ProjNet/CoordinateSystems/Projections/ProjectionsRegistry.cs index 7ca6186..d3e315e 100644 --- a/src/ProjNet/CoordinateSystems/Projections/ProjectionsRegistry.cs +++ b/src/ProjNet/CoordinateSystems/Projections/ProjectionsRegistry.cs @@ -1,35 +1,35 @@ -using ProjNet.CoordinateSystems.Transformations; -using System; -using System.Collections.Generic; - -namespace ProjNet.CoordinateSystems.Projections -{ - /// - /// Registry class for all known s. - /// - public class ProjectionsRegistry - { - private static readonly Dictionary TypeRegistry = new Dictionary(); - private static readonly Dictionary ConstructorRegistry = new Dictionary(); - - private static readonly object RegistryLock = new object(); - - /// - /// Static constructor - /// - static ProjectionsRegistry() - { - Register("mercator", typeof(Mercator)); - Register("mercator_1sp", typeof(Mercator)); +using ProjNet.CoordinateSystems.Transformations; +using System; +using System.Collections.Generic; + +namespace ProjNet.CoordinateSystems.Projections +{ + /// + /// Registry class for all known s. + /// + public class ProjectionsRegistry + { + private static readonly Dictionary TypeRegistry = new Dictionary(); + private static readonly Dictionary ConstructorRegistry = new Dictionary(); + + private static readonly object RegistryLock = new object(); + + /// + /// Static constructor + /// + static ProjectionsRegistry() + { + Register("mercator", typeof(Mercator)); + Register("mercator_1sp", typeof(Mercator)); Register("mercator_2sp", typeof(Mercator)); Register("mercator_auxiliary_sphere", typeof(MercatorAuxiliarySphere)); - Register("pseudo_mercator", typeof(PseudoMercator)); - Register("popular_visualisation_pseudo_mercator", typeof(PseudoMercator)); + Register("pseudo_mercator", typeof(PseudoMercator)); + Register("popular_visualisation_pseudo_mercator", typeof(PseudoMercator)); Register("google_mercator", typeof(PseudoMercator)); - Register("transverse_mercator", typeof(TransverseMercator)); - Register("gauss_kruger", typeof(TransverseMercator)); - + Register("transverse_mercator", typeof(TransverseMercator)); + Register("gauss_kruger", typeof(TransverseMercator)); + Register("albers", typeof(AlbersProjection)); Register("albers_conic_equal_area", typeof(AlbersProjection)); @@ -39,66 +39,66 @@ static ProjectionsRegistry() Register("lambert_conformal_conic", typeof(LambertConformalConic2SP)); Register("lambert_conformal_conic_2sp", typeof(LambertConformalConic2SP)); - Register("lambert_conic_conformal_(2sp)", typeof(LambertConformalConic2SP)); - Register("lambert_tangential_conformal_conic_projection", typeof(LambertConformalConic2SP)); - - Register("lambert_azimuthal_equal_area", typeof(LambertAzimuthalEqualAreaProjection)); - - Register("cassini_soldner", typeof(CassiniSoldnerProjection)); - Register("hotine_oblique_mercator", typeof(HotineObliqueMercatorProjection)); - Register("hotine_oblique_mercator_azimuth_center", typeof(HotineObliqueMercatorProjection)); - Register("oblique_mercator", typeof(ObliqueMercatorProjection)); - Register("oblique_stereographic", typeof(ObliqueStereographicProjection)); - Register("orthographic", typeof(OrthographicProjection)); - Register("polar_stereographic", typeof(PolarStereographicProjection)); - } - - /// - /// Method to register a new Map - /// - /// - /// - public static void Register(string name, Type type) - { - if (string.IsNullOrWhiteSpace(name)) - throw new ArgumentNullException(nameof(name)); - - if (type == null) - throw new ArgumentNullException(nameof(type)); - - if (!typeof(MathTransform).IsAssignableFrom(type)) - throw new ArgumentException("The provided type does not implement 'GeoAPI.CoordinateSystems.Transformations.IMathTransform'!", nameof(type)); - - var ci = CheckConstructor(type); - if (ci == null) - throw new ArgumentException("The provided type is lacking a suitable constructor", nameof(type)); - - string key = ProjectionNameToRegistryKey(name); - lock (RegistryLock) - { - if (TypeRegistry.ContainsKey(key)) - { - var rt = TypeRegistry[key]; - if (ReferenceEquals(type, rt)) - return; - throw new ArgumentException("A different projection type has been registered with this name", "name"); - } - - TypeRegistry.Add(key, type); - ConstructorRegistry.Add(key, ci); - } - } - + Register("lambert_conic_conformal_(2sp)", typeof(LambertConformalConic2SP)); + Register("lambert_tangential_conformal_conic_projection", typeof(LambertConformalConic2SP)); + + Register("lambert_azimuthal_equal_area", typeof(LambertAzimuthalEqualAreaProjection)); + + Register("cassini_soldner", typeof(CassiniSoldnerProjection)); + Register("hotine_oblique_mercator", typeof(HotineObliqueMercatorProjection)); + Register("hotine_oblique_mercator_azimuth_center", typeof(HotineObliqueMercatorProjection)); + Register("oblique_mercator", typeof(ObliqueMercatorProjection)); + Register("oblique_stereographic", typeof(ObliqueStereographicProjection)); + Register("orthographic", typeof(OrthographicProjection)); + Register("polar_stereographic", typeof(PolarStereographicProjection)); + } + + /// + /// Method to register a new Map + /// + /// + /// + public static void Register(string name, Type type) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentNullException(nameof(name)); + + if (type == null) + throw new ArgumentNullException(nameof(type)); + + if (!typeof(MathTransform).IsAssignableFrom(type)) + throw new ArgumentException("The provided type does not implement 'GeoAPI.CoordinateSystems.Transformations.IMathTransform'!", nameof(type)); + + var ci = CheckConstructor(type); + if (ci == null) + throw new ArgumentException("The provided type is lacking a suitable constructor", nameof(type)); + + string key = ProjectionNameToRegistryKey(name); + lock (RegistryLock) + { + if (TypeRegistry.ContainsKey(key)) + { + var rt = TypeRegistry[key]; + if (ReferenceEquals(type, rt)) + return; + throw new ArgumentException("A different projection type has been registered with this name", "name"); + } + + TypeRegistry.Add(key, type); + ConstructorRegistry.Add(key, ci); + } + } + private static string ProjectionNameToRegistryKey(string name) { return name.ToLowerInvariant().Replace(' ', '_').Replace("-", "_"); - } - + } + /// /// Register an alias for an existing Map. /// /// - /// + /// public static void RegisterAlias(string aliasName, string existingName) { lock (RegistryLock) @@ -110,52 +110,52 @@ public static void RegisterAlias(string aliasName, string existingName) Register(aliasName, existingProjectionType); } - } - - private static Type CheckConstructor(Type type) - { - // find a constructor that accepts exactly one parameter that's an - // instance of List, and then return the exact - // parameter type so that we can create instances of this type with - // minimal copying in the future, when possible. - foreach (var c in type.GetConstructors()) - { - var parameters = c.GetParameters(); - if (parameters.Length == 1 && parameters[0].ParameterType.IsAssignableFrom(typeof(List))) - { - return parameters[0].ParameterType; - } - } - - return null; - } - - internal static MathTransform CreateProjection(string className, IEnumerable parameters) - { - string key = ProjectionNameToRegistryKey(className); - - Type projectionType; - Type ci; - - lock (RegistryLock) - { - if (!TypeRegistry.TryGetValue(key, out projectionType)) - throw new NotSupportedException($"Projection {className} is not supported."); - ci = ConstructorRegistry[key]; - } - - if (!ci.IsInstanceOfType(parameters)) - { - parameters = new List(parameters); - } - - var res = (MapProjection)Activator.CreateInstance(projectionType, parameters); - if (!res.Name.Equals(className, StringComparison.InvariantCultureIgnoreCase)) - { - res.Alias = res.Name; - res.Name = className; - } - return res; - } - } -} + } + + private static Type CheckConstructor(Type type) + { + // find a constructor that accepts exactly one parameter that's an + // instance of List, and then return the exact + // parameter type so that we can create instances of this type with + // minimal copying in the future, when possible. + foreach (var c in type.GetConstructors()) + { + var parameters = c.GetParameters(); + if (parameters.Length == 1 && parameters[0].ParameterType.IsAssignableFrom(typeof(List))) + { + return parameters[0].ParameterType; + } + } + + return null; + } + + internal static MathTransform CreateProjection(string className, IEnumerable parameters) + { + string key = ProjectionNameToRegistryKey(className); + + Type projectionType; + Type ci; + + lock (RegistryLock) + { + if (!TypeRegistry.TryGetValue(key, out projectionType)) + throw new NotSupportedException($"Projection {className} is not supported."); + ci = ConstructorRegistry[key]; + } + + if (!ci.IsInstanceOfType(parameters)) + { + parameters = new List(parameters); + } + + var res = (MapProjection)Activator.CreateInstance(projectionType, parameters); + if (!res.Name.Equals(className, StringComparison.InvariantCultureIgnoreCase)) + { + res.Alias = res.Name; + res.Name = className; + } + return res; + } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2AbridgedTransformation.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2AbridgedTransformation.cs new file mode 100644 index 0000000..6f0c5b9 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2AbridgedTransformation.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Abridged transformation as used in WKT2 BOUNDCRS. + /// + [Serializable] + public sealed class Wkt2AbridgedTransformation + { + /// + /// Initializes a new instance. + /// + /// Transformation name. + /// Transformation method name. + public Wkt2AbridgedTransformation(string name, string methodName) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + MethodName = methodName ?? throw new ArgumentNullException(nameof(methodName)); + } + + /// + /// Gets the transformation name. + /// + public string Name { get; } + + /// + /// Gets the transformation method name. + /// + public string MethodName { get; } + + /// + /// Gets the transformation parameters. + /// + public List Parameters { get; } = new List(); + + /// + /// Gets or sets the transformation identifier. + /// + public Wkt2Id Id { get; set; } + + /// + /// Gets or sets an optional remark. + /// + public string Remark { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Axis.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Axis.cs new file mode 100644 index 0000000..8fd7c75 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Axis.cs @@ -0,0 +1,47 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Axis element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2Axis + { + /// + /// Initializes a new instance. + /// + /// Axis name. + /// Axis direction (e.g. north, east). + public Wkt2Axis(string name, string direction) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Direction = direction ?? throw new ArgumentNullException(nameof(direction)); + } + + /// + /// Gets the axis name. + /// + public string Name { get; } + + /// + /// Gets the axis direction. + /// + public string Direction { get; } + + /// + /// Gets or sets the axis order. + /// + public int? Order { get; set; } + + /// + /// Gets or sets an optional axis unit. + /// + public Wkt2Unit Unit { get; set; } + + /// + /// Gets or sets an optional identifier. + /// + public Wkt2Id Id { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2BBox.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2BBox.cs new file mode 100644 index 0000000..dbc4268 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2BBox.cs @@ -0,0 +1,46 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Represents a WKT2 bounding box with south, west, north, east bounds. + /// + [Serializable] + public sealed class Wkt2BBox + { + /// + /// Initializes a new instance of the class. + /// + /// The southern latitude bound. + /// The western longitude bound. + /// The northern latitude bound. + /// The eastern longitude bound. + public Wkt2BBox(double south, double west, double north, double east) + { + South = south; + West = west; + North = north; + East = east; + } + + /// + /// Gets the southern latitude bound. + /// + public double South { get; } + + /// + /// Gets the western longitude bound. + /// + public double West { get; } + + /// + /// Gets the northern latitude bound. + /// + public double North { get; } + + /// + /// Gets the eastern longitude bound. + /// + public double East { get; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2BoundCrs.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2BoundCrs.cs new file mode 100644 index 0000000..c2ca554 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2BoundCrs.cs @@ -0,0 +1,56 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Bound CRS element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2BoundCrs : Wkt2CrsBase + { + /// + /// Initializes a new instance. + /// + /// CRS keyword (e.g. BOUNDCRS). + /// CRS name. + /// Source CRS. + /// Target CRS. + /// Abridged transformation description. + public Wkt2BoundCrs(string keyword, string name, Wkt2CrsBase sourceCrs, Wkt2CrsBase targetCrs, Wkt2AbridgedTransformation transformation) + : base(name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + SourceCrs = sourceCrs ?? throw new ArgumentNullException(nameof(sourceCrs)); + TargetCrs = targetCrs ?? throw new ArgumentNullException(nameof(targetCrs)); + Transformation = transformation ?? throw new ArgumentNullException(nameof(transformation)); + } + + /// + /// Gets the WKT2 keyword. + /// + public string Keyword { get; } + + /// + /// Gets the source CRS. + /// + public Wkt2CrsBase SourceCrs { get; } + + /// + /// Gets the target CRS. + /// + public Wkt2CrsBase TargetCrs { get; } + + /// + /// Gets the abridged transformation. + /// + public Wkt2AbridgedTransformation Transformation { get; } + + /// + /// Serializes the CRS back to WKT2. + /// + public override string ToWkt2String() + { + return IO.CoordinateSystems.CoordinateSystemWkt2Writer.Write(this); + } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CompoundCrs.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CompoundCrs.cs new file mode 100644 index 0000000..51b1cd6 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CompoundCrs.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Compound CRS element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2CompoundCrs : Wkt2CrsBase + { + /// + /// Initializes a new instance. + /// + /// CRS keyword (e.g. COMPOUNDCRS). + /// CRS name. + /// Component CRS list. + public Wkt2CompoundCrs(string keyword, string name, IEnumerable components) + : base(name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + Components = new List(components ?? throw new ArgumentNullException(nameof(components))); + } + + /// + /// Gets the WKT2 keyword. + /// + public string Keyword { get; } + + /// + /// Gets the component CRSs. + /// + public List Components { get; } + + /// + /// Serializes the CRS back to WKT2. + /// + public override string ToWkt2String() + { + return IO.CoordinateSystems.CoordinateSystemWkt2Writer.Write(this); + } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ConcatenatedOperation.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ConcatenatedOperation.cs new file mode 100644 index 0000000..2d2eb3e --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ConcatenatedOperation.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Represents a WKT2 CONCATENATEDOPERATION element. + /// + [Serializable] + public sealed class Wkt2ConcatenatedOperation : Wkt2CrsBase + { + /// + /// Initializes a new instance. + /// + /// The operation name. + public Wkt2ConcatenatedOperation(string name) + : base(name) + { + } + + /// + /// Gets or sets the version text. + /// + public string Version { get; set; } + + /// + /// Gets or sets the source CRS. + /// + public Wkt2CrsBase SourceCrs { get; set; } + + /// + /// Gets or sets the target CRS. + /// + public Wkt2CrsBase TargetCrs { get; set; } + + /// + /// Gets the ordered list of operation steps. + /// + public List Steps { get; } = new List(); + + /// + /// Gets or sets the operation accuracy. + /// + public double? OperationAccuracy { get; set; } + + /// + public override string ToWkt2String() + { + return IO.CoordinateSystems.CoordinateSystemWkt2Writer.Write(this); + } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversion.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversion.cs new file mode 100644 index 0000000..653f016 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversion.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Conversion element as used in WKT2 projected CRSs. + /// + [Serializable] + public sealed class Wkt2Conversion + { + /// + /// Initializes a new instance. + /// + /// Conversion name. + /// Method name. + public Wkt2Conversion(string name, string methodName) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + MethodName = methodName ?? throw new ArgumentNullException(nameof(methodName)); + } + + /// + /// Gets the conversion name. + /// + public string Name { get; } + + /// + /// Gets the conversion method name. + /// + public string MethodName { get; } + + /// + /// Gets the conversion parameters. + /// + public List Parameters { get; } = new List(); + + /// + /// Gets or sets the conversion identifier. + /// + public Wkt2Id Id { get; set; } + + /// + /// Gets or sets an optional remark. + /// + public string Remark { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversions.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversions.cs new file mode 100644 index 0000000..344ef65 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversions.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using ProjNet.CoordinateSystems; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Conversion helpers between the WKT2 model types and the existing ProjNet coordinate system model. + /// + public static class Wkt2Conversions + { + /// + /// Converts a WKT2 projected CRS model to a ProjNet . + /// + /// The WKT2 projected CRS. + /// A ProjNet projected coordinate system. + public static ProjectedCoordinateSystem ToProjNetProjectedCoordinateSystem(this Wkt2ProjCrs crs) + { + if (crs == null) throw new ArgumentNullException(nameof(crs)); + + var baseGcs = crs.BaseCrs.ToProjNetGeographicCoordinateSystem(); + + // Projection method mapping is best-effort; WKT2 method names vary. + string method = MapProjectionMethodName(crs.Conversion.MethodName); + + var parameters = new List(); + foreach (var p in crs.Conversion.Parameters) + parameters.Add(new ProjectionParameter(p.Name, p.Value)); + + var projection = new Projection(method, parameters, crs.Conversion.Name, string.Empty, -1, string.Empty, string.Empty, string.Empty); + + var linearUnit = LinearUnit.Metre; + if (crs.CoordinateSystem != null && crs.CoordinateSystem.Unit != null) + { + // ProjNet `LinearUnit` expects meters per unit. + // WKT2 LENGTHUNIT factor is in meters per unit. + linearUnit = new LinearUnit(crs.CoordinateSystem.Unit.ConversionFactor, crs.CoordinateSystem.Unit.Name, string.Empty, -1, string.Empty, string.Empty, string.Empty); + } + + var axes = new List(2) + { + new AxisInfo("East", AxisOrientationEnum.East), + new AxisInfo("North", AxisOrientationEnum.North) + }; + + // Best-effort for IDs + string authority = crs.Id != null ? crs.Id.Authority : string.Empty; + long authorityCode = -1; + if (crs.Id != null) + long.TryParse(crs.Id.Code, out authorityCode); + + return new ProjectedCoordinateSystem(baseGcs.HorizontalDatum, baseGcs, linearUnit, projection, axes, + crs.Name, authority, authorityCode, string.Empty, string.Empty, string.Empty); + } + + /// + /// Converts a ProjNet to a WKT2 projected CRS model. + /// + /// The ProjNet projected coordinate system. + /// A WKT2 projected CRS model. + public static Wkt2ProjCrs FromProjNetProjectedCoordinateSystem(this ProjectedCoordinateSystem pcs) + { + if (pcs == null) throw new ArgumentNullException(nameof(pcs)); + if (pcs.GeographicCoordinateSystem == null) + throw new ArgumentException("ProjectedCoordinateSystem.GeographicCoordinateSystem cannot be null.", nameof(pcs)); + if (pcs.Projection == null) + throw new ArgumentException("ProjectedCoordinateSystem.Projection cannot be null.", nameof(pcs)); + + var baseCrs = pcs.GeographicCoordinateSystem.FromProjNetGeographicCoordinateSystem(); + + var conversion = new Wkt2Conversion(pcs.Projection.Name, MapProjNetToWkt2MethodName(pcs.Projection.ClassName)); + for (int i = 0; i < pcs.Projection.NumParameters; i++) + { + var p = pcs.Projection.GetParameter(i); + conversion.Parameters.Add(new Wkt2Parameter(p.Name, p.Value)); + } + + var unit = new Wkt2Unit("LENGTHUNIT", pcs.LinearUnit.Name, pcs.LinearUnit.MetersPerUnit); + var cs = new Wkt2CoordinateSystem("cartesian", 2) { Unit = unit }; + cs.Axes.Add(new Wkt2Axis("easting", "east") { Order = 1 }); + cs.Axes.Add(new Wkt2Axis("northing", "north") { Order = 2 }); + + var crs = new Wkt2ProjCrs("PROJCRS", pcs.Name, baseCrs, conversion, cs); + if (!string.IsNullOrWhiteSpace(pcs.Authority) && pcs.AuthorityCode > 0) + crs.Id = new Wkt2Id(pcs.Authority, pcs.AuthorityCode.ToString()); + + return crs; + } + + /// + /// Converts a WKT2 geographic CRS model to a ProjNet . + /// + /// The WKT2 geographic CRS. + /// A ProjNet geographic coordinate system. + public static GeographicCoordinateSystem ToProjNetGeographicCoordinateSystem(this Wkt2GeogCrs crs) + { + if (crs == null) throw new ArgumentNullException(nameof(crs)); + + // Normalize WKT2 -> ProjNet conventions: + // - ProjNet GCS is horizontal (2D) + // - ProjNet expects Lon/East then Lat/North axis order + // - Per-axis units are not supported; use CS unit (ANGLEUNIT) if available + + var ellipsoid = new Ellipsoid( + crs.Datum.Ellipsoid.SemiMajorAxis, + 0.0, + crs.Datum.Ellipsoid.InverseFlattening, + true, + LinearUnit.Metre, + crs.Datum.Ellipsoid.Name, + string.Empty, + -1, + string.Empty, + string.Empty, + string.Empty); + + var datum = new HorizontalDatum( + ellipsoid, + null, + DatumType.HD_Geocentric, + crs.Datum.Name, + string.Empty, + -1, + string.Empty, + string.Empty, + string.Empty); + + var angUnit = AngularUnit.Degrees; + if (crs.CoordinateSystem != null && crs.CoordinateSystem.Unit != null) + { + angUnit = new AngularUnit( + crs.CoordinateSystem.Unit.ConversionFactor, + crs.CoordinateSystem.Unit.Name, + string.Empty, + -1, + string.Empty, + string.Empty, + string.Empty); + } + + PrimeMeridian pm; + if (crs.PrimeMeridian != null) + { + pm = new PrimeMeridian(crs.PrimeMeridian.Longitude, angUnit, crs.PrimeMeridian.Name, string.Empty, -1, string.Empty, string.Empty, string.Empty); + } + else + { + pm = new PrimeMeridian(0.0, angUnit, "Greenwich", string.Empty, -1, string.Empty, string.Empty, string.Empty); + } + + var axes = new List(2) + { + new AxisInfo("Lon", AxisOrientationEnum.East), + new AxisInfo("Lat", AxisOrientationEnum.North) + }; + + // Best-effort for IDs + string authority = crs.Id != null ? crs.Id.Authority : string.Empty; + long authorityCode = -1; + if (crs.Id != null) + long.TryParse(crs.Id.Code, out authorityCode); + + return new GeographicCoordinateSystem( + angUnit, + datum, + pm, + axes, + crs.Name, + authority, + authorityCode, + string.Empty, + string.Empty, + string.Empty); + } + + /// + /// Converts a ProjNet to a WKT2 geographic CRS model. + /// + /// The ProjNet geographic coordinate system. + /// A WKT2 geographic CRS model. + public static Wkt2GeogCrs FromProjNetGeographicCoordinateSystem(this GeographicCoordinateSystem gcs) + { + if (gcs == null) throw new ArgumentNullException(nameof(gcs)); + if (gcs.HorizontalDatum?.Ellipsoid == null) + throw new ArgumentException("GeographicCoordinateSystem.HorizontalDatum.Ellipsoid cannot be null.", nameof(gcs)); + + var unit = new Wkt2Unit("ANGLEUNIT", gcs.AngularUnit.Name, gcs.AngularUnit.RadiansPerUnit); + + var cs = new Wkt2CoordinateSystem("ellipsoidal", 2) + { + Unit = unit + }; + cs.Axes.Add(new Wkt2Axis("longitude", "east") { Order = 1 }); + cs.Axes.Add(new Wkt2Axis("latitude", "north") { Order = 2 }); + + var ellipsoid = new Wkt2Ellipsoid(gcs.HorizontalDatum.Ellipsoid.Name, gcs.HorizontalDatum.Ellipsoid.SemiMajorAxis, gcs.HorizontalDatum.Ellipsoid.InverseFlattening) + { + LengthUnit = new Wkt2Unit("LENGTHUNIT", "metre", 1.0) + }; + + var datum = new Wkt2GeodeticDatum("DATUM", gcs.HorizontalDatum.Name, ellipsoid); + + var crs = new Wkt2GeogCrs("GEOGCRS", gcs.Name, datum, cs) + { + PrimeMeridian = new Wkt2PrimeMeridian(gcs.PrimeMeridian.Name, gcs.PrimeMeridian.Longitude) { AngleUnit = unit } + }; + + if (!string.IsNullOrWhiteSpace(gcs.Authority) && gcs.AuthorityCode > 0) + crs.Id = new Wkt2Id(gcs.Authority, gcs.AuthorityCode.ToString()); + + return crs; + } + + private static readonly Dictionary Wkt2ToProjNetMethodMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // Transverse Mercator variants + { "Transverse Mercator", "Transverse_Mercator" }, + { "Transverse Mercator (South Orientated)", "Transverse_Mercator" }, + { "Gauss-Kruger", "Transverse_Mercator" }, + + // Mercator variants + { "Mercator", "Mercator_1SP" }, + { "Mercator (variant A)", "Mercator_1SP" }, + { "Mercator (1SP)", "Mercator_1SP" }, + { "Mercator (variant B)", "Mercator_2SP" }, + { "Mercator (2SP)", "Mercator_2SP" }, + { "Mercator Auxiliary Sphere", "Mercator_Auxiliary_Sphere" }, + { "Popular Visualisation Pseudo Mercator", "Popular_Visualisation_Pseudo_Mercator" }, + + // Lambert variants + { "Lambert Conic Conformal (2SP)", "lambert_conformal_conic_2sp" }, + { "Lambert Conic Conformal (1SP)", "Lambert_Conformal_Conic" }, + { "Lambert Azimuthal Equal Area", "Lambert_Azimuthal_Equal_Area" }, + + // Albers + { "Albers Equal Area", "Albers_Conic_Equal_Area" }, + { "Albers", "Albers" }, + + // Stereographic variants + { "Oblique Stereographic", "Oblique_Stereographic" }, + { "Polar Stereographic (variant A)", "Polar_Stereographic" }, + { "Polar Stereographic (variant B)", "Polar_Stereographic" }, + { "Polar Stereographic", "Polar_Stereographic" }, + + // Other projections + { "Hotine Oblique Mercator (variant A)", "Hotine_Oblique_Mercator" }, + { "Hotine Oblique Mercator (variant B)", "Hotine_Oblique_Mercator" }, + { "Hotine Oblique Mercator", "Hotine_Oblique_Mercator" }, + { "Oblique Mercator", "Oblique_Mercator" }, + { "Cassini-Soldner", "Cassini_Soldner" }, + { "Krovak", "Krovak" }, + { "Krovak (North Orientated)", "Krovak" }, + { "American Polyconic", "Polyconic" }, + { "Polyconic", "Polyconic" }, + { "Orthographic", "Orthographic" }, + }; + + internal static string MapProjectionMethodName(string wkt2Method) + { + if (string.IsNullOrWhiteSpace(wkt2Method)) + return ""; + + string m = wkt2Method.Trim(); + return Wkt2ToProjNetMethodMap.TryGetValue(m, out string projNetName) + ? projNetName + : m; + } + + private static readonly Dictionary ProjNetToWkt2MethodMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "Transverse_Mercator", "Transverse Mercator" }, + { "Mercator_1SP", "Mercator (variant A)" }, + { "Mercator_2SP", "Mercator (variant B)" }, + { "Mercator", "Mercator (variant A)" }, + { "Mercator_Auxiliary_Sphere", "Mercator Auxiliary Sphere" }, + { "Popular_Visualisation_Pseudo_Mercator", "Popular Visualisation Pseudo Mercator" }, + { "Pseudo_Mercator", "Popular Visualisation Pseudo Mercator" }, + { "Google_Mercator", "Popular Visualisation Pseudo Mercator" }, + { "lambert_conformal_conic_2sp", "Lambert Conic Conformal (2SP)" }, + { "Lambert_Conformal_Conic", "Lambert Conic Conformal (1SP)" }, + { "Lambert_Conic_Conformal_(2SP)", "Lambert Conic Conformal (2SP)" }, + { "Lambert_Azimuthal_Equal_Area", "Lambert Azimuthal Equal Area" }, + { "Albers_Conic_Equal_Area", "Albers Equal Area" }, + { "Albers", "Albers Equal Area" }, + { "Oblique_Stereographic", "Oblique Stereographic" }, + { "Polar_Stereographic", "Polar Stereographic (variant A)" }, + { "Hotine_Oblique_Mercator", "Hotine Oblique Mercator (variant A)" }, + { "Hotine_Oblique_Mercator_Azimuth_Center", "Hotine Oblique Mercator (variant A)" }, + { "Oblique_Mercator", "Oblique Mercator" }, + { "Cassini_Soldner", "Cassini-Soldner" }, + { "Krovak", "Krovak" }, + { "Polyconic", "American Polyconic" }, + { "Orthographic", "Orthographic" }, + { "Gauss_Kruger", "Transverse Mercator" }, + }; + + internal static string MapProjNetToWkt2MethodName(string projNetMethod) + { + if (string.IsNullOrWhiteSpace(projNetMethod)) + return ""; + + string m = projNetMethod.Trim(); + return ProjNetToWkt2MethodMap.TryGetValue(m, out string wkt2Name) + ? wkt2Name + : m; + } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CoordinateOperation.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CoordinateOperation.cs new file mode 100644 index 0000000..ad0967e --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CoordinateOperation.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Represents a single coordinate operation used as a STEP in a concatenated operation. + /// + [Serializable] + public sealed class Wkt2CoordinateOperation + { + /// + /// Initializes a new instance. + /// + /// The WKT2 keyword (CONVERSION, COORDINATEOPERATION, etc.). + /// The operation name. + public Wkt2CoordinateOperation(string keyword, string name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + /// + /// Gets the WKT2 keyword (CONVERSION, COORDINATEOPERATION, etc.). + /// + public string Keyword { get; } + + /// + /// Gets the operation name. + /// + public string Name { get; } + + /// + /// Gets or sets the method name. + /// + public string Method { get; set; } + + /// + /// Gets the operation parameters. + /// + public List Parameters { get; } = new List(); + + /// + /// Gets or sets the source CRS (for COORDINATEOPERATION). + /// + public Wkt2CrsBase SourceCrs { get; set; } + + /// + /// Gets or sets the target CRS (for COORDINATEOPERATION). + /// + public Wkt2CrsBase TargetCrs { get; set; } + + /// + /// Gets or sets the operation accuracy. + /// + public double? OperationAccuracy { get; set; } + + /// + /// Gets or sets the identifier. + /// + public Wkt2Id Id { get; set; } + + /// + /// Gets or sets a remark. + /// + public string Remark { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CoordinateSystem.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CoordinateSystem.cs new file mode 100644 index 0000000..64b3d7e --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CoordinateSystem.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Coordinate system element as used in WKT2 (CS plus axes and units). + /// + [Serializable] + public sealed class Wkt2CoordinateSystem + { + /// + /// Initializes a new instance. + /// + /// Coordinate system type (e.g. ellipsoidal, cartesian). + /// Number of dimensions. + public Wkt2CoordinateSystem(string type, int dimension) + { + Type = type ?? throw new ArgumentNullException(nameof(type)); + Dimension = dimension; + } + + /// + /// Gets the coordinate system type. + /// + public string Type { get; } + + /// + /// Gets the dimension. + /// + public int Dimension { get; } + + /// + /// Gets the axes. + /// + public List Axes { get; } = new List(); + + /// + /// Gets or sets the coordinate system unit. + /// + public Wkt2Unit Unit { get; set; } + + /// + /// Gets or sets an optional identifier. + /// + public Wkt2Id Id { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CrsBase.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CrsBase.cs new file mode 100644 index 0000000..42a4675 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CrsBase.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Base class for WKT2 CRS model objects. + /// + [Serializable] + public abstract class Wkt2CrsBase + { + /// + /// Initializes a new instance. + /// + /// CRS name. + protected Wkt2CrsBase(string name) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + /// + /// Gets the CRS name. + /// + public string Name { get; } + + /// + /// Gets or sets the CRS identifier. + /// + public Wkt2Id Id { get; set; } + + /// + /// Gets or sets an optional remark. + /// + public string Remark { get; set; } + + /// + /// Gets the list of usage metadata for this CRS. + /// + public List Usages { get; } = new List(); + + /// + /// Serializes the model back to a WKT2 string. + /// + public abstract string ToWkt2String(); + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2DerivedGeogCrs.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2DerivedGeogCrs.cs new file mode 100644 index 0000000..a743b6a --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2DerivedGeogCrs.cs @@ -0,0 +1,56 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Derived geographic CRS element as used in WKT2 (DERIVEDGEOGCRS). + /// + [Serializable] + public sealed class Wkt2DerivedGeogCrs : Wkt2CrsBase + { + /// + /// Initializes a new instance. + /// + /// CRS keyword (e.g. DERIVEDGEOGCRS). + /// CRS name. + /// Base geographic CRS. + /// Deriving conversion. + /// Coordinate system. + public Wkt2DerivedGeogCrs(string keyword, string name, Wkt2GeogCrs baseCrs, Wkt2Conversion derivingConversion, Wkt2CoordinateSystem coordinateSystem) + : base(name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + BaseCrs = baseCrs ?? throw new ArgumentNullException(nameof(baseCrs)); + DerivingConversion = derivingConversion ?? throw new ArgumentNullException(nameof(derivingConversion)); + CoordinateSystem = coordinateSystem ?? throw new ArgumentNullException(nameof(coordinateSystem)); + } + + /// + /// Gets the WKT2 keyword. + /// + public string Keyword { get; } + + /// + /// Gets the base geographic CRS. + /// + public Wkt2GeogCrs BaseCrs { get; } + + /// + /// Gets the deriving conversion. + /// + public Wkt2Conversion DerivingConversion { get; } + + /// + /// Gets the derived geographic CRS coordinate system. + /// + public Wkt2CoordinateSystem CoordinateSystem { get; } + + /// + /// Serializes the CRS back to WKT2. + /// + public override string ToWkt2String() + { + return IO.CoordinateSystems.CoordinateSystemWkt2Writer.Write(this); + } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Ellipsoid.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Ellipsoid.cs new file mode 100644 index 0000000..7952dc0 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Ellipsoid.cs @@ -0,0 +1,49 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Ellipsoid element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2Ellipsoid + { + /// + /// Initializes a new instance. + /// + /// Ellipsoid name. + /// Semi-major axis length. + /// Inverse flattening. + public Wkt2Ellipsoid(string name, double semiMajorAxis, double inverseFlattening) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + SemiMajorAxis = semiMajorAxis; + InverseFlattening = inverseFlattening; + } + + /// + /// Gets the ellipsoid name. + /// + public string Name { get; } + + /// + /// Gets the semi-major axis. + /// + public double SemiMajorAxis { get; } + + /// + /// Gets the inverse flattening. + /// + public double InverseFlattening { get; } + + /// + /// Gets or sets an optional length unit. + /// + public Wkt2Unit LengthUnit { get; set; } + + /// + /// Gets or sets an optional identifier. + /// + public Wkt2Id Id { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2EngCrs.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2EngCrs.cs new file mode 100644 index 0000000..483bb73 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2EngCrs.cs @@ -0,0 +1,49 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Engineering CRS element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2EngCrs : Wkt2CrsBase + { + /// + /// Initializes a new instance. + /// + /// CRS keyword (e.g. ENGCRS). + /// CRS name. + /// Engineering datum. + /// Coordinate system. + public Wkt2EngCrs(string keyword, string name, Wkt2EngineeringDatum datum, Wkt2CoordinateSystem coordinateSystem) + : base(name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + Datum = datum ?? throw new ArgumentNullException(nameof(datum)); + CoordinateSystem = coordinateSystem ?? throw new ArgumentNullException(nameof(coordinateSystem)); + } + + /// + /// Gets the WKT2 keyword. + /// + public string Keyword { get; } + + /// + /// Gets the datum. + /// + public Wkt2EngineeringDatum Datum { get; } + + /// + /// Gets the coordinate system. + /// + public Wkt2CoordinateSystem CoordinateSystem { get; } + + /// + /// Serializes the CRS back to WKT2. + /// + public override string ToWkt2String() + { + return IO.CoordinateSystems.CoordinateSystemWkt2Writer.Write(this); + } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2EngineeringDatum.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2EngineeringDatum.cs new file mode 100644 index 0000000..add4667 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2EngineeringDatum.cs @@ -0,0 +1,47 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Engineering datum element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2EngineeringDatum + { + /// + /// Initializes a new instance. + /// + /// Datum keyword (e.g. EDATUM). + /// Datum name. + public Wkt2EngineeringDatum(string keyword, string name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + /// + /// Gets the datum keyword. + /// + public string Keyword { get; } + + /// + /// Gets the datum name. + /// + public string Name { get; } + + /// + /// Gets or sets the optional anchor description for this datum. + /// + public string Anchor { get; set; } + + /// + /// Gets or sets an optional identifier. + /// + public Wkt2Id Id { get; set; } + + /// + /// Gets or sets an optional remark. + /// + public string Remark { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2GeodeticDatum.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2GeodeticDatum.cs new file mode 100644 index 0000000..26ab2bd --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2GeodeticDatum.cs @@ -0,0 +1,54 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Geodetic datum element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2GeodeticDatum + { + /// + /// Initializes a new instance. + /// + /// Datum keyword (e.g. DATUM or TRF). + /// Datum name. + /// Associated ellipsoid. + public Wkt2GeodeticDatum(string keyword, string name, Wkt2Ellipsoid ellipsoid) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + Ellipsoid = ellipsoid ?? throw new ArgumentNullException(nameof(ellipsoid)); + } + + /// + /// Gets the datum keyword. + /// + public string Keyword { get; } + + /// + /// Gets the datum name. + /// + public string Name { get; } + + /// + /// Gets the ellipsoid. + /// + public Wkt2Ellipsoid Ellipsoid { get; } + + /// + /// Gets or sets the optional anchor description for this datum. + /// + public string Anchor { get; set; } + + /// + /// Gets or sets the optional frame reference epoch for dynamic datums. + /// + public double? FrameEpoch { get; set; } + + /// + /// Gets or sets an optional identifier. + /// + public Wkt2Id Id { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2GeogCrs.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2GeogCrs.cs new file mode 100644 index 0000000..ff9a2d9 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2GeogCrs.cs @@ -0,0 +1,54 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Geographic CRS element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2GeogCrs : Wkt2CrsBase + { + /// + /// Initializes a new instance. + /// + /// CRS keyword (e.g. GEOGCRS). + /// CRS name. + /// Geodetic datum. + /// Coordinate system. + public Wkt2GeogCrs(string keyword, string name, Wkt2GeodeticDatum datum, Wkt2CoordinateSystem coordinateSystem) + : base(name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + Datum = datum ?? throw new ArgumentNullException(nameof(datum)); + CoordinateSystem = coordinateSystem ?? throw new ArgumentNullException(nameof(coordinateSystem)); + } + + /// + /// Gets the WKT2 keyword. + /// + public string Keyword { get; } + + /// + /// Gets the datum. + /// + public Wkt2GeodeticDatum Datum { get; } + + /// + /// Gets or sets the prime meridian. + /// + public Wkt2PrimeMeridian PrimeMeridian { get; set; } + + /// + /// Gets the coordinate system. + /// + public Wkt2CoordinateSystem CoordinateSystem { get; } + + /// + /// Serializes the CRS back to WKT2. + /// + public override string ToWkt2String() + { + return IO.CoordinateSystems.CoordinateSystemWkt2Writer.Write(this); + } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Id.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Id.cs new file mode 100644 index 0000000..69580ca --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Id.cs @@ -0,0 +1,42 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Identifier element as used in WKT2 (e.g. ID["EPSG",4326]). + /// + [Serializable] + public sealed class Wkt2Id + { + /// + /// Initializes a new instance. + /// + /// Authority name. + /// Authority code. + public Wkt2Id(string authority, string code) + { + Authority = authority ?? throw new ArgumentNullException(nameof(authority)); + Code = code ?? throw new ArgumentNullException(nameof(code)); + } + + /// + /// Gets the authority name. + /// + public string Authority { get; } + + /// + /// Gets the authority code. + /// + public string Code { get; } + + /// + /// Gets or sets an optional URI. + /// + public string Uri { get; set; } + + /// + /// Gets or sets an optional version string. + /// + public string Version { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Parameter.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Parameter.cs new file mode 100644 index 0000000..dbbcb5f --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Parameter.cs @@ -0,0 +1,37 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Parameter element as used in WKT2 conversions/transformations. + /// + [Serializable] + public sealed class Wkt2Parameter + { + /// + /// Initializes a new instance. + /// + /// Parameter name. + /// Parameter value. + public Wkt2Parameter(string name, double value) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Value = value; + } + + /// + /// Gets the parameter name. + /// + public string Name { get; } + + /// + /// Gets the parameter value. + /// + public double Value { get; } + + /// + /// Gets or sets an optional identifier. + /// + public Wkt2Id Id { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ParametricCrs.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ParametricCrs.cs new file mode 100644 index 0000000..c01fe67 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ParametricCrs.cs @@ -0,0 +1,49 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Parametric CRS element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2ParametricCrs : Wkt2CrsBase + { + /// + /// Initializes a new instance. + /// + /// CRS keyword (e.g. PARAMETRICCRS). + /// CRS name. + /// Parametric datum. + /// Coordinate system. + public Wkt2ParametricCrs(string keyword, string name, Wkt2ParametricDatum datum, Wkt2CoordinateSystem coordinateSystem) + : base(name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + Datum = datum ?? throw new ArgumentNullException(nameof(datum)); + CoordinateSystem = coordinateSystem ?? throw new ArgumentNullException(nameof(coordinateSystem)); + } + + /// + /// Gets the WKT2 keyword. + /// + public string Keyword { get; } + + /// + /// Gets the parametric datum. + /// + public Wkt2ParametricDatum Datum { get; } + + /// + /// Gets the parametric CRS coordinate system. + /// + public Wkt2CoordinateSystem CoordinateSystem { get; } + + /// + /// Serializes the CRS back to WKT2. + /// + public override string ToWkt2String() + { + return IO.CoordinateSystems.CoordinateSystemWkt2Writer.Write(this); + } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ParametricDatum.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ParametricDatum.cs new file mode 100644 index 0000000..0703136 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ParametricDatum.cs @@ -0,0 +1,47 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Parametric datum element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2ParametricDatum + { + /// + /// Initializes a new instance. + /// + /// Datum keyword (e.g. PDATUM). + /// Datum name. + public Wkt2ParametricDatum(string keyword, string name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + /// + /// Gets the datum keyword. + /// + public string Keyword { get; } + + /// + /// Gets the datum name. + /// + public string Name { get; } + + /// + /// Gets or sets the optional anchor description for this datum. + /// + public string Anchor { get; set; } + + /// + /// Gets or sets an optional identifier. + /// + public Wkt2Id Id { get; set; } + + /// + /// Gets or sets an optional remark. + /// + public string Remark { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2PrimeMeridian.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2PrimeMeridian.cs new file mode 100644 index 0000000..3a48a40 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2PrimeMeridian.cs @@ -0,0 +1,42 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Prime meridian element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2PrimeMeridian + { + /// + /// Initializes a new instance. + /// + /// Prime meridian name. + /// Longitude value. + public Wkt2PrimeMeridian(string name, double longitude) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Longitude = longitude; + } + + /// + /// Gets the prime meridian name. + /// + public string Name { get; } + + /// + /// Gets the longitude. + /// + public double Longitude { get; } + + /// + /// Gets or sets an optional angle unit. + /// + public Wkt2Unit AngleUnit { get; set; } + + /// + /// Gets or sets an optional identifier. + /// + public Wkt2Id Id { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ProjCrs.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ProjCrs.cs new file mode 100644 index 0000000..6f41821 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ProjCrs.cs @@ -0,0 +1,56 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Projected CRS element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2ProjCrs : Wkt2CrsBase + { + /// + /// Initializes a new instance. + /// + /// CRS keyword (e.g. PROJCRS). + /// CRS name. + /// Base geographic CRS. + /// Conversion. + /// Coordinate system. + public Wkt2ProjCrs(string keyword, string name, Wkt2GeogCrs baseCrs, Wkt2Conversion conversion, Wkt2CoordinateSystem coordinateSystem) + : base(name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + BaseCrs = baseCrs ?? throw new ArgumentNullException(nameof(baseCrs)); + Conversion = conversion ?? throw new ArgumentNullException(nameof(conversion)); + CoordinateSystem = coordinateSystem ?? throw new ArgumentNullException(nameof(coordinateSystem)); + } + + /// + /// Gets the WKT2 keyword. + /// + public string Keyword { get; } + + /// + /// Gets the base geographic CRS. + /// + public Wkt2GeogCrs BaseCrs { get; } + + /// + /// Gets the defining conversion (map projection). + /// + public Wkt2Conversion Conversion { get; } + + /// + /// Gets the projected CRS coordinate system. + /// + public Wkt2CoordinateSystem CoordinateSystem { get; } + + /// + /// Serializes the CRS back to WKT2. + /// + public override string ToWkt2String() + { + return IO.CoordinateSystems.CoordinateSystemWkt2Writer.Write(this); + } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2TemporalDatum.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2TemporalDatum.cs new file mode 100644 index 0000000..bd105e4 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2TemporalDatum.cs @@ -0,0 +1,52 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Temporal datum element as used in WKT2 (TDATUM / TIMEDATUM). + /// + [Serializable] + public sealed class Wkt2TemporalDatum + { + /// + /// Initializes a new instance. + /// + /// Datum keyword (e.g. TDATUM or TIMEDATUM). + /// Datum name. + public Wkt2TemporalDatum(string keyword, string name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + /// + /// Gets the datum keyword. + /// + public string Keyword { get; } + + /// + /// Gets the datum name. + /// + public string Name { get; } + + /// + /// Gets or sets the calendar type. + /// + public string Calendar { get; set; } + + /// + /// Gets or sets the time origin. + /// + public string TimeOrigin { get; set; } + + /// + /// Gets or sets an optional identifier. + /// + public Wkt2Id Id { get; set; } + + /// + /// Gets or sets an optional remark. + /// + public string Remark { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2TimeCrs.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2TimeCrs.cs new file mode 100644 index 0000000..f8d683b --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2TimeCrs.cs @@ -0,0 +1,49 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Temporal CRS element as used in WKT2 (TIMECRS). + /// + [Serializable] + public sealed class Wkt2TimeCrs : Wkt2CrsBase + { + /// + /// Initializes a new instance. + /// + /// CRS keyword (e.g. TIMECRS). + /// CRS name. + /// Temporal datum. + /// Coordinate system. + public Wkt2TimeCrs(string keyword, string name, Wkt2TemporalDatum datum, Wkt2CoordinateSystem coordinateSystem) + : base(name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + Datum = datum ?? throw new ArgumentNullException(nameof(datum)); + CoordinateSystem = coordinateSystem ?? throw new ArgumentNullException(nameof(coordinateSystem)); + } + + /// + /// Gets the WKT2 keyword. + /// + public string Keyword { get; } + + /// + /// Gets the temporal datum. + /// + public Wkt2TemporalDatum Datum { get; } + + /// + /// Gets the temporal CRS coordinate system. + /// + public Wkt2CoordinateSystem CoordinateSystem { get; } + + /// + /// Serializes the CRS back to WKT2. + /// + public override string ToWkt2String() + { + return IO.CoordinateSystems.CoordinateSystemWkt2Writer.Write(this); + } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Unit.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Unit.cs new file mode 100644 index 0000000..7c84aa4 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Unit.cs @@ -0,0 +1,44 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Unit element as used in WKT2 (e.g. ANGLEUNIT, LENGTHUNIT). + /// + [Serializable] + public sealed class Wkt2Unit + { + /// + /// Initializes a new instance. + /// + /// Unit keyword (e.g. ANGLEUNIT). + /// Unit name. + /// Conversion factor to the SI base unit. + public Wkt2Unit(string keyword, string name, double conversionFactor) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + ConversionFactor = conversionFactor; + } + + /// + /// Gets the unit keyword. + /// + public string Keyword { get; } + + /// + /// Gets the unit name. + /// + public string Name { get; } + + /// + /// Gets the conversion factor. + /// + public double ConversionFactor { get; } + + /// + /// Gets or sets an optional identifier. + /// + public Wkt2Id Id { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Usage.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Usage.cs new file mode 100644 index 0000000..dce06c6 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Usage.cs @@ -0,0 +1,26 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Represents a WKT2 USAGE element containing scope, area, and bounding box metadata. + /// + [Serializable] + public sealed class Wkt2Usage + { + /// + /// Gets or sets the scope description. + /// + public string Scope { get; set; } + + /// + /// Gets or sets the area description. + /// + public string Area { get; set; } + + /// + /// Gets or sets the bounding box. + /// + public Wkt2BBox BBox { get; set; } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2VertCrs.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2VertCrs.cs new file mode 100644 index 0000000..0042880 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2VertCrs.cs @@ -0,0 +1,49 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Vertical CRS element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2VertCrs : Wkt2CrsBase + { + /// + /// Initializes a new instance. + /// + /// CRS keyword (e.g. VERTCRS). + /// CRS name. + /// Vertical datum. + /// Coordinate system. + public Wkt2VertCrs(string keyword, string name, Wkt2VerticalDatum datum, Wkt2CoordinateSystem coordinateSystem) + : base(name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + Datum = datum ?? throw new ArgumentNullException(nameof(datum)); + CoordinateSystem = coordinateSystem ?? throw new ArgumentNullException(nameof(coordinateSystem)); + } + + /// + /// Gets the WKT2 keyword. + /// + public string Keyword { get; } + + /// + /// Gets the vertical datum. + /// + public Wkt2VerticalDatum Datum { get; } + + /// + /// Gets the vertical CRS coordinate system. + /// + public Wkt2CoordinateSystem CoordinateSystem { get; } + + /// + /// Serializes the CRS back to WKT2. + /// + public override string ToWkt2String() + { + return IO.CoordinateSystems.CoordinateSystemWkt2Writer.Write(this); + } + } +} diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2VerticalDatum.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2VerticalDatum.cs new file mode 100644 index 0000000..07aed6a --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2VerticalDatum.cs @@ -0,0 +1,47 @@ +using System; + +namespace ProjNet.CoordinateSystems.Wkt2 +{ + /// + /// Vertical datum element as used in WKT2. + /// + [Serializable] + public sealed class Wkt2VerticalDatum + { + /// + /// Initializes a new instance. + /// + /// Datum keyword (e.g. VDATUM). + /// Datum name. + public Wkt2VerticalDatum(string keyword, string name) + { + Keyword = keyword ?? throw new ArgumentNullException(nameof(keyword)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + /// + /// Gets the datum keyword. + /// + public string Keyword { get; } + + /// + /// Gets the datum name. + /// + public string Name { get; } + + /// + /// Gets or sets the optional anchor description for this datum. + /// + public string Anchor { get; set; } + + /// + /// Gets or sets an optional identifier. + /// + public Wkt2Id Id { get; set; } + + /// + /// Gets or sets an optional remark. + /// + public string Remark { get; set; } + } +} diff --git a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs new file mode 100644 index 0000000..fd5c880 --- /dev/null +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs @@ -0,0 +1,2062 @@ +using System; +using System.Collections.Generic; +using System.IO; +using ProjNet.CoordinateSystems; +using ProjNet.CoordinateSystems.Wkt2; + +namespace ProjNet.IO.CoordinateSystems +{ + /// + /// Reads and parses WKT2 (OGC 18-010r7 / ISO 19162:2019) CRS definitions. + /// + public static class CoordinateSystemWkt2Reader + { + /// + /// Parses WKT2 into a native WKT2 model. + /// + public static Wkt2CrsBase ParseCrs(string wkt) + { + if (string.IsNullOrWhiteSpace(wkt)) + throw new ArgumentNullException(nameof(wkt)); + + using (TextReader reader = new StringReader(wkt)) + { + var tokenizer = new WktStreamTokenizer(reader); + tokenizer.NextToken(); + + string rootKeyword = tokenizer.GetStringValue(); + switch (rootKeyword.ToUpperInvariant()) + { + case "GEOGCRS": + case "GEOGRAPHICCRS": + return ReadGeogCrs(rootKeyword, tokenizer); + case "PROJCRS": + case "PROJECTEDCRS": + return ReadProjCrs(rootKeyword, tokenizer); + case "VERTCRS": + case "VERTICALCRS": + return ReadVertCrs(rootKeyword, tokenizer); + case "COMPOUNDCRS": + return ReadCompoundCrs(rootKeyword, tokenizer); + case "BOUNDCRS": + return ReadBoundCrs(rootKeyword, tokenizer); + case "ENGCRS": + case "ENGINEERINGCRS": + return ReadEngCrs(rootKeyword, tokenizer); + case "PARAMETRICCRS": + return ReadParametricCrs(rootKeyword, tokenizer); + case "TIMECRS": + return ReadTimeCrs(rootKeyword, tokenizer); + case "DERIVEDGEOGCRS": + return ReadDerivedGeogCrs(rootKeyword, tokenizer); + case "CONCATENATEDOPERATION": + return ReadConcatenatedOperation(rootKeyword, tokenizer); + default: + throw new ArgumentException($"'{rootKeyword}' is not recognized as a supported WKT2 CRS."); + } + } + } + + /// + /// Parses WKT2 and converts to existing ProjNet model (normalized to ProjNet conventions). + /// + public static IInfo Parse(string wkt) + { + var crs = ParseCrs(wkt); + switch (crs) + { + case Wkt2GeogCrs geog: + return Wkt2Conversions.ToProjNetGeographicCoordinateSystem(geog); + case Wkt2ProjCrs proj: + return Wkt2Conversions.ToProjNetProjectedCoordinateSystem(proj); + default: + throw new NotSupportedException($"WKT2 CRS model '{crs.GetType().Name}' is not supported for conversion."); + } + + } + + private static Wkt2EngCrs ReadEngCrs(string keyword, WktStreamTokenizer tokenizer) + { + // ENGCRS["name", EDATUM/DATUM[...], CS[...], AXIS..., UNIT..., ID..., ...] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + Wkt2EngineeringDatum datum = null; + Wkt2CoordinateSystem cs = null; + Wkt2Id id = null; + string remark = null; + var usages = new List(); + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "EDATUM": + case "DATUM": + datum = ReadEngineeringDatum(element, tokenizer); + break; + case "CS": + cs = ReadCoordinateSystem(tokenizer); + break; + case "AXIS": + if (cs == null) + cs = new Wkt2CoordinateSystem("cartesian", 2); + cs.Axes.Add(ReadAxis(tokenizer)); + break; + case "LENGTHUNIT": + case "SCALEUNIT": + case "TIMEUNIT": + case "UNIT": + if (cs == null) + cs = new Wkt2CoordinateSystem("cartesian", 2); + cs.Unit = ReadUnit(element, tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "USAGE": + usages.Add(ReadUsage(tokenizer)); + break; + case "SCOPE": + { + var scopeBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Scope = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(scopeBracket); + usages.Add(u); + } + break; + case "AREA": + { + var areaBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Area = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(areaBracket); + usages.Add(u); + } + break; + case "BBOX": + { + var u = new Wkt2Usage { BBox = ReadBBox(tokenizer) }; + usages.Add(u); + } + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + if (datum == null) + throw new ArgumentException("ENGCRS is missing EDATUM/DATUM."); + if (cs == null) + cs = new Wkt2CoordinateSystem("cartesian", 2); + + var crs = new Wkt2EngCrs(keyword, name, datum, cs) + { + Id = id, + Remark = remark + }; + foreach (var u in usages) + crs.Usages.Add(u); + return crs; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2EngineeringDatum ReadEngineeringDatum(string keyword, WktStreamTokenizer tokenizer) + { + // EDATUM/DATUM["name", ANCHOR[...], ID[...], REMARK[...]] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + Wkt2Id id = null; + string remark = null; + string anchor = null; + + tokenizer.NextToken(); + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "ANCHOR": + var anchorBracket = tokenizer.ReadOpener(); + anchor = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadCloser(anchorBracket); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + return new Wkt2EngineeringDatum(keyword, name) { Id = id, Remark = remark, Anchor = anchor }; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2ParametricCrs ReadParametricCrs(string keyword, WktStreamTokenizer tokenizer) + { + // PARAMETRICCRS["name", PDATUM/DATUM[...], CS[parametric,1], AXIS..., (PARAMETRICUNIT|UNIT)...] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + Wkt2ParametricDatum datum = null; + Wkt2CoordinateSystem cs = null; + Wkt2Id id = null; + string remark = null; + var usages = new List(); + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "PDATUM": + case "DATUM": + datum = ReadParametricDatum(element, tokenizer); + break; + case "CS": + cs = ReadCoordinateSystem(tokenizer); + break; + case "AXIS": + if (cs == null) + cs = new Wkt2CoordinateSystem("parametric", 1); + cs.Axes.Add(ReadAxis(tokenizer)); + break; + case "PARAMETRICUNIT": + case "SCALEUNIT": + case "TIMEUNIT": + case "UNIT": + if (cs == null) + cs = new Wkt2CoordinateSystem("parametric", 1); + cs.Unit = ReadUnit(element, tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "USAGE": + usages.Add(ReadUsage(tokenizer)); + break; + case "SCOPE": + { + var scopeBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Scope = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(scopeBracket); + usages.Add(u); + } + break; + case "AREA": + { + var areaBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Area = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(areaBracket); + usages.Add(u); + } + break; + case "BBOX": + { + var u = new Wkt2Usage { BBox = ReadBBox(tokenizer) }; + usages.Add(u); + } + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + if (datum == null) + throw new ArgumentException("PARAMETRICCRS is missing PDATUM/DATUM."); + if (cs == null) + cs = new Wkt2CoordinateSystem("parametric", 1); + + var crs = new Wkt2ParametricCrs(keyword, name, datum, cs) + { + Id = id, + Remark = remark + }; + foreach (var u in usages) + crs.Usages.Add(u); + return crs; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2ParametricDatum ReadParametricDatum(string keyword, WktStreamTokenizer tokenizer) + { + // PDATUM/DATUM["name", ANCHOR[...], ID[...], REMARK[...]] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + Wkt2Id id = null; + string remark = null; + string anchor = null; + + tokenizer.NextToken(); + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "ANCHOR": + var anchorBracket = tokenizer.ReadOpener(); + anchor = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadCloser(anchorBracket); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + return new Wkt2ParametricDatum(keyword, name) { Id = id, Remark = remark, Anchor = anchor }; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2BoundCrs ReadBoundCrs(string keyword, WktStreamTokenizer tokenizer) + { + // BOUNDCRS["name", SOURCECRS[...], TARGETCRS[...], ABRIDGEDTRANSFORMATION[...], ID[..]?, REMARK[..]?] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + Wkt2CrsBase sourceCrs = null; + Wkt2CrsBase targetCrs = null; + Wkt2AbridgedTransformation transformation = null; + Wkt2Id id = null; + string remark = null; + var usages = new List(); + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "SOURCECRS": + sourceCrs = ReadBoundCrsChildCrs(tokenizer); + break; + case "TARGETCRS": + targetCrs = ReadBoundCrsChildCrs(tokenizer); + break; + case "ABRIDGEDTRANSFORMATION": + transformation = ReadAbridgedTransformation(tokenizer); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case "USAGE": + usages.Add(ReadUsage(tokenizer)); + break; + case "SCOPE": + { + var scopeBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Scope = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(scopeBracket); + usages.Add(u); + } + break; + case "AREA": + { + var areaBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Area = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(areaBracket); + usages.Add(u); + } + break; + case "BBOX": + { + var u = new Wkt2Usage { BBox = ReadBBox(tokenizer) }; + usages.Add(u); + } + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + if (sourceCrs == null) + throw new ArgumentException("BOUNDCRS is missing SOURCECRS."); + if (targetCrs == null) + throw new ArgumentException("BOUNDCRS is missing TARGETCRS."); + if (transformation == null) + throw new ArgumentException("BOUNDCRS is missing ABRIDGEDTRANSFORMATION."); + + var crs = new Wkt2BoundCrs(keyword, name, sourceCrs, targetCrs, transformation) + { + Id = id, + Remark = remark + }; + foreach (var u in usages) + crs.Usages.Add(u); + return crs; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2CrsBase ReadBoundCrsChildCrs(WktStreamTokenizer tokenizer) + { + // SOURCECRS[TARGETCRS] wraps a CRS inside its own brackets. + var bracket = tokenizer.ReadOpener(); + tokenizer.NextToken(); + + Wkt2CrsBase crs = null; + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "GEOGCRS": + case "GEOGRAPHICCRS": + crs = ReadGeogCrs(element, tokenizer); + break; + case "PROJCRS": + case "PROJECTEDCRS": + crs = ReadProjCrs(element, tokenizer); + break; + case "VERTCRS": + case "VERTICALCRS": + crs = ReadVertCrs(element, tokenizer); + break; + case "COMPOUNDCRS": + crs = ReadCompoundCrs(element, tokenizer); + break; + case "ENGCRS": + case "ENGINEERINGCRS": + crs = ReadEngCrs(element, tokenizer); + break; + case "PARAMETRICCRS": + crs = ReadParametricCrs(element, tokenizer); + break; + case "TIMECRS": + crs = ReadTimeCrs(element, tokenizer); + break; + case "DERIVEDGEOGCRS": + crs = ReadDerivedGeogCrs(element, tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + if (crs == null) + throw new ArgumentException("SOURCECRS/TARGETCRS has no CRS."); + return crs; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2AbridgedTransformation ReadAbridgedTransformation(WktStreamTokenizer tokenizer) + { + // ABRIDGEDTRANSFORMATION["name", METHOD["..."], PARAMETER[...], ...] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + string methodName = null; + var parameters = new System.Collections.Generic.List(); + Wkt2Id id = null; + string remark = null; + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "METHOD": + methodName = ReadMethodName(tokenizer); + break; + case "PARAMETER": + parameters.Add(ReadParameter(tokenizer)); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + if (string.IsNullOrWhiteSpace(methodName)) + throw new ArgumentException("ABRIDGEDTRANSFORMATION is missing METHOD."); + var transform = new Wkt2AbridgedTransformation(name, methodName); + foreach (var p in parameters) + transform.Parameters.Add(p); + transform.Id = id; + transform.Remark = remark; + return transform; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2CompoundCrs ReadCompoundCrs(string keyword, WktStreamTokenizer tokenizer) + { + // COMPOUNDCRS["name", , , ... , ID[..]?, REMARK[..]?] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + var components = new System.Collections.Generic.List(); + Wkt2Id id = null; + string remark = null; + var usages = new List(); + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "GEOGCRS": + case "GEOGRAPHICCRS": + components.Add(ReadGeogCrs(element, tokenizer)); + break; + case "PROJCRS": + case "PROJECTEDCRS": + components.Add(ReadProjCrs(element, tokenizer)); + break; + case "VERTCRS": + case "VERTICALCRS": + components.Add(ReadVertCrs(element, tokenizer)); + break; + case "TIMECRS": + components.Add(ReadTimeCrs(element, tokenizer)); + break; + case "DERIVEDGEOGCRS": + components.Add(ReadDerivedGeogCrs(element, tokenizer)); + break; + case "ENGCRS": + case "ENGINEERINGCRS": + components.Add(ReadEngCrs(element, tokenizer)); + break; + case "PARAMETRICCRS": + components.Add(ReadParametricCrs(element, tokenizer)); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case "USAGE": + usages.Add(ReadUsage(tokenizer)); + break; + case "SCOPE": + { + var scopeBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Scope = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(scopeBracket); + usages.Add(u); + } + break; + case "AREA": + { + var areaBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Area = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(areaBracket); + usages.Add(u); + } + break; + case "BBOX": + { + var u = new Wkt2Usage { BBox = ReadBBox(tokenizer) }; + usages.Add(u); + } + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + if (components.Count == 0) + throw new ArgumentException("COMPOUNDCRS has no component CRS."); + + var crs = new Wkt2CompoundCrs(keyword, name, components) + { + Id = id, + Remark = remark + }; + foreach (var u in usages) + crs.Usages.Add(u); + return crs; + default: + SkipUnknownElement(tokenizer); + break; + } + + tokenizer.NextToken(); + } + } + + private static Wkt2VertCrs ReadVertCrs(string keyword, WktStreamTokenizer tokenizer) + { + // VERTCRS["name", VDATUM/DATUM[...], CS[vertical,1], AXIS[...], LENGTHUNIT[...], ID[...], ...] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + Wkt2VerticalDatum datum = null; + Wkt2CoordinateSystem cs = null; + Wkt2Id id = null; + string remark = null; + var usages = new List(); + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "VDATUM": + case "DATUM": + datum = ReadVerticalDatum(element, tokenizer); + break; + case "CS": + cs = ReadCoordinateSystem(tokenizer); + break; + case "AXIS": + if (cs == null) + cs = new Wkt2CoordinateSystem("vertical", 1); + cs.Axes.Add(ReadAxis(tokenizer)); + break; + case "LENGTHUNIT": + case "SCALEUNIT": + case "TIMEUNIT": + case "UNIT": + if (cs == null) + cs = new Wkt2CoordinateSystem("vertical", 1); + cs.Unit = ReadUnit(element, tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "USAGE": + usages.Add(ReadUsage(tokenizer)); + break; + case "SCOPE": + { + var scopeBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Scope = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(scopeBracket); + usages.Add(u); + } + break; + case "AREA": + { + var areaBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Area = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(areaBracket); + usages.Add(u); + } + break; + case "BBOX": + { + var u = new Wkt2Usage { BBox = ReadBBox(tokenizer) }; + usages.Add(u); + } + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + + if (datum == null) + throw new ArgumentException("VERTCRS is missing VDATUM/DATUM."); + if (cs == null) + cs = new Wkt2CoordinateSystem("vertical", 1); + + var crs = new Wkt2VertCrs(keyword, name, datum, cs) + { + Id = id, + Remark = remark + }; + foreach (var u in usages) + crs.Usages.Add(u); + return crs; + + default: + SkipUnknownElement(tokenizer); + break; + } + + tokenizer.NextToken(); + } + } + + private static Wkt2VerticalDatum ReadVerticalDatum(string keyword, WktStreamTokenizer tokenizer) + { + // VDATUM/DATUM["name", ANCHOR[...], ID[...], REMARK[...]] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + Wkt2Id id = null; + string remark = null; + string anchor = null; + + tokenizer.NextToken(); + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "ANCHOR": + var anchorBracket = tokenizer.ReadOpener(); + anchor = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadCloser(anchorBracket); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + return new Wkt2VerticalDatum(keyword, name) { Id = id, Remark = remark, Anchor = anchor }; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2ProjCrs ReadProjCrs(string keyword, WktStreamTokenizer tokenizer) + { + // PROJCRS["name", BASEGEOGCRS[...]|GEOGCRS[...], CONVERSION[...], CS[...], AXIS..., UNIT..., ID...] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + Wkt2GeogCrs baseCrs = null; + Wkt2Conversion conversion = null; + Wkt2CoordinateSystem cs = null; + Wkt2Id id = null; + string remark = null; + var usages = new List(); + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "BASEGEOGCRS": + case "BASEGEODCRS": + baseCrs = ReadBaseGeogCrs(tokenizer); + break; + case "GEOGCRS": + case "GEOGRAPHICCRS": + baseCrs = ReadGeogCrs(element, tokenizer); + break; + case "CONVERSION": + conversion = ReadConversion(tokenizer); + break; + case "CS": + cs = ReadCoordinateSystem(tokenizer); + break; + case "AXIS": + if (cs == null) + cs = new Wkt2CoordinateSystem("cartesian", 2); + cs.Axes.Add(ReadAxis(tokenizer)); + break; + case "LENGTHUNIT": + case "SCALEUNIT": + case "TIMEUNIT": + case "UNIT": + if (cs == null) + cs = new Wkt2CoordinateSystem("cartesian", 2); + cs.Unit = ReadUnit(element, tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "USAGE": + usages.Add(ReadUsage(tokenizer)); + break; + case "SCOPE": + { + var scopeBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Scope = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(scopeBracket); + usages.Add(u); + } + break; + case "AREA": + { + var areaBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Area = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(areaBracket); + usages.Add(u); + } + break; + case "BBOX": + { + var u = new Wkt2Usage { BBox = ReadBBox(tokenizer) }; + usages.Add(u); + } + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + if (baseCrs == null) + throw new ArgumentException("PROJCRS is missing BASEGEOGCRS/GEOGCRS."); + if (conversion == null) + throw new ArgumentException("PROJCRS is missing CONVERSION."); + if (cs == null) + cs = new Wkt2CoordinateSystem("cartesian", 2); + + var crs = new Wkt2ProjCrs(keyword, name, baseCrs, conversion, cs) + { + Id = id, + Remark = remark + }; + foreach (var u in usages) + crs.Usages.Add(u); + return crs; + default: + SkipUnknownElement(tokenizer); + break; + } + + tokenizer.NextToken(); + } + } + + private static Wkt2GeogCrs ReadBaseGeogCrs(WktStreamTokenizer tokenizer) + { + // BASEGEOGCRS["name", DATUM/TRF[...], PRIMEM[...]?, ID...] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + Wkt2GeodeticDatum datum = null; + Wkt2PrimeMeridian primeMeridian = null; + Wkt2Id id = null; + string remark = null; + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "DATUM": + case "TRF": + case "GEODETICDATUM": + case "DYNAMICDATUM": + datum = ReadGeodeticDatum(element, tokenizer); + break; + case "PRIMEM": + case "PRIMEMERIDIAN": + primeMeridian = ReadPrimeMeridian(tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case "ID": + id = ReadId(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + if (datum == null) + throw new ArgumentException("BASEGEOGCRS is missing DATUM/TRF."); + + var cs = new Wkt2CoordinateSystem("ellipsoidal", 2); + var crs = new Wkt2GeogCrs("GEOGCRS", name, datum, cs) + { + PrimeMeridian = primeMeridian, + Id = id, + Remark = remark + }; + return crs; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2Conversion ReadConversion(WktStreamTokenizer tokenizer) + { + // CONVERSION["name", METHOD["..."], PARAMETER[...], ...] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + string methodName = null; + var parameters = new System.Collections.Generic.List(); + Wkt2Id id = null; + string remark = null; + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "METHOD": + methodName = ReadMethodName(tokenizer); + break; + case "PARAMETER": + parameters.Add(ReadParameter(tokenizer)); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + if (string.IsNullOrWhiteSpace(methodName)) + throw new ArgumentException("CONVERSION is missing METHOD."); + var conversion = new Wkt2Conversion(name, methodName); + foreach (var p in parameters) + conversion.Parameters.Add(p); + conversion.Id = id; + conversion.Remark = remark; + return conversion; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2TemporalDatum ReadTemporalDatum(string keyword, WktStreamTokenizer tokenizer) + { + // TDATUM/TIMEDATUM["name", CALENDAR[...], TIMEORIGIN[...], ID[...], REMARK[...]] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + Wkt2Id id = null; + string remark = null; + string calendar = null; + string timeOrigin = null; + + tokenizer.NextToken(); + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "CALENDAR": + var calBracket = tokenizer.ReadOpener(); + calendar = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadCloser(calBracket); + break; + case "TIMEORIGIN": + var origBracket = tokenizer.ReadOpener(); + timeOrigin = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadCloser(origBracket); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + return new Wkt2TemporalDatum(keyword, name) + { + Calendar = calendar, + TimeOrigin = timeOrigin, + Id = id, + Remark = remark + }; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2TimeCrs ReadTimeCrs(string keyword, WktStreamTokenizer tokenizer) + { + // TIMECRS["name", TDATUM/TIMEDATUM[...], CS[...], AXIS..., TIMEUNIT..., ID...] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + Wkt2TemporalDatum datum = null; + Wkt2CoordinateSystem cs = null; + Wkt2Id id = null; + string remark = null; + var usages = new List(); + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "TDATUM": + case "TIMEDATUM": + datum = ReadTemporalDatum(element, tokenizer); + break; + case "CS": + cs = ReadCoordinateSystem(tokenizer); + break; + case "AXIS": + if (cs == null) + cs = new Wkt2CoordinateSystem("temporal", 1); + cs.Axes.Add(ReadAxis(tokenizer)); + break; + case "TIMEUNIT": + case "UNIT": + if (cs == null) + cs = new Wkt2CoordinateSystem("temporal", 1); + cs.Unit = ReadUnit(element, tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "USAGE": + usages.Add(ReadUsage(tokenizer)); + break; + case "SCOPE": + { + var scopeBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Scope = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(scopeBracket); + usages.Add(u); + } + break; + case "AREA": + { + var areaBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Area = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(areaBracket); + usages.Add(u); + } + break; + case "BBOX": + { + var u = new Wkt2Usage { BBox = ReadBBox(tokenizer) }; + usages.Add(u); + } + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + + if (datum == null) + throw new ArgumentException("TIMECRS is missing TDATUM/TIMEDATUM."); + if (cs == null) + cs = new Wkt2CoordinateSystem("temporal", 1); + + var crs = new Wkt2TimeCrs(keyword, name, datum, cs) + { + Id = id, + Remark = remark + }; + foreach (var u in usages) + crs.Usages.Add(u); + return crs; + + default: + SkipUnknownElement(tokenizer); + break; + } + + tokenizer.NextToken(); + } + } + + private static Wkt2DerivedGeogCrs ReadDerivedGeogCrs(string keyword, WktStreamTokenizer tokenizer) + { + // DERIVEDGEOGCRS["name", BASEGEOGCRS[...], DERIVINGCONVERSION[...], CS[...], AXIS..., UNIT..., ID...] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + Wkt2GeogCrs baseCrs = null; + Wkt2Conversion conversion = null; + Wkt2CoordinateSystem cs = null; + Wkt2Id id = null; + string remark = null; + var usages = new List(); + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "BASEGEOGCRS": + case "BASEGEODCRS": + baseCrs = ReadBaseGeogCrs(tokenizer); + break; + case "DERIVINGCONVERSION": + conversion = ReadConversion(tokenizer); + break; + case "CS": + cs = ReadCoordinateSystem(tokenizer); + break; + case "AXIS": + if (cs == null) + cs = new Wkt2CoordinateSystem("ellipsoidal", 2); + cs.Axes.Add(ReadAxis(tokenizer)); + break; + case "ANGLEUNIT": + case "LENGTHUNIT": + case "SCALEUNIT": + case "UNIT": + if (cs == null) + cs = new Wkt2CoordinateSystem("ellipsoidal", 2); + cs.Unit = ReadUnit(element, tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "USAGE": + usages.Add(ReadUsage(tokenizer)); + break; + case "SCOPE": + { + var scopeBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Scope = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(scopeBracket); + usages.Add(u); + } + break; + case "AREA": + { + var areaBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Area = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(areaBracket); + usages.Add(u); + } + break; + case "BBOX": + { + var u = new Wkt2Usage { BBox = ReadBBox(tokenizer) }; + usages.Add(u); + } + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + + if (baseCrs == null) + throw new ArgumentException("DERIVEDGEOGCRS is missing BASEGEOGCRS."); + if (conversion == null) + throw new ArgumentException("DERIVEDGEOGCRS is missing DERIVINGCONVERSION."); + if (cs == null) + cs = new Wkt2CoordinateSystem("ellipsoidal", 2); + + var crs = new Wkt2DerivedGeogCrs(keyword, name, baseCrs, conversion, cs) + { + Id = id, + Remark = remark + }; + foreach (var u in usages) + crs.Usages.Add(u); + return crs; + + default: + SkipUnknownElement(tokenizer); + break; + } + + tokenizer.NextToken(); + } + } + + private static string ReadMethodName(WktStreamTokenizer tokenizer) + { + // METHOD["...", ID[...], REMARK[...], ...] + var bracket = tokenizer.ReadOpener(); + string methodName = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.NextToken(); + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "ID": + case "REMARK": + case ",": + SkipUnknownElement(tokenizer); + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + return methodName; + default: + SkipUnknownElement(tokenizer); + break; + } + + tokenizer.NextToken(); + } + } + + private static Wkt2Parameter ReadParameter(WktStreamTokenizer tokenizer) + { + // PARAMETER["name", value, (UNIT[...]?) (ID[...]?)] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadToken(","); + tokenizer.NextToken(); + double value = tokenizer.GetNumericValue(); + + Wkt2Id id = null; + tokenizer.NextToken(); + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "ID": + id = ReadId(tokenizer); + break; + case "UNIT": + case "LENGTHUNIT": + case "ANGLEUNIT": + case "SCALEUNIT": + case "TIMEUNIT": + // parsed but not stored yet + ReadUnit(element, tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + return new Wkt2Parameter(name, value) { Id = id }; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2GeogCrs ReadGeogCrs(string keyword, WktStreamTokenizer tokenizer) + { + // GEOGCRS["name", DATUM/TRF[...], PRIMEM[...]?, CS[...], AXIS..., (cs unit), ... ID[...] ...] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + Wkt2GeodeticDatum datum = null; + Wkt2PrimeMeridian primeMeridian = null; + Wkt2CoordinateSystem cs = null; + Wkt2Id id = null; + string remark = null; + var usages = new List(); + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "DATUM": + case "TRF": + case "GEODETICDATUM": + case "DYNAMICDATUM": + datum = ReadGeodeticDatum(element, tokenizer); + break; + + case "PRIMEM": + case "PRIMEMERIDIAN": + primeMeridian = ReadPrimeMeridian(tokenizer); + break; + + case "CS": + cs = ReadCoordinateSystem(tokenizer); + break; + + case "AXIS": + if (cs == null) + cs = new Wkt2CoordinateSystem("ellipsoidal", 2); + cs.Axes.Add(ReadAxis(tokenizer)); + break; + + case "ANGLEUNIT": + case "LENGTHUNIT": + case "SCALEUNIT": + case "TIMEUNIT": + case "UNIT": + if (cs == null) + cs = new Wkt2CoordinateSystem("ellipsoidal", 2); + cs.Unit = ReadUnit(element, tokenizer); + break; + + case "REMARK": + remark = ReadRemark(tokenizer); + break; + + case "ID": + id = ReadId(tokenizer); + break; + + case "USAGE": + usages.Add(ReadUsage(tokenizer)); + break; + + case "SCOPE": + { + var scopeBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Scope = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(scopeBracket); + usages.Add(u); + } + break; + + case "AREA": + { + var areaBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Area = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(areaBracket); + usages.Add(u); + } + break; + + case "BBOX": + { + var u = new Wkt2Usage { BBox = ReadBBox(tokenizer) }; + usages.Add(u); + } + break; + + case ",": + break; + + case "]": + case ")": + tokenizer.CheckCloser(bracket); + + if (datum == null) + throw new ArgumentException("GEOGCRS is missing DATUM/TRF."); + + if (cs == null) + cs = new Wkt2CoordinateSystem("ellipsoidal", 2); + + var crs = new Wkt2GeogCrs(keyword, name, datum, cs) + { + PrimeMeridian = primeMeridian, + Id = id, + Remark = remark + }; + foreach (var u in usages) + crs.Usages.Add(u); + return crs; + + default: + SkipUnknownElement(tokenizer); + break; + } + + tokenizer.NextToken(); + } + } + + private static Wkt2GeodeticDatum ReadGeodeticDatum(string keyword, WktStreamTokenizer tokenizer) + { + // DATUM["name", ELLIPSOID[...], ...] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + Wkt2Ellipsoid ellipsoid = null; + Wkt2Id id = null; + string anchor = null; + double? frameEpoch = null; + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "ELLIPSOID": + case "SPHEROID": + ellipsoid = ReadEllipsoid(tokenizer); + break; + case "ANCHOR": + var anchorBracket = tokenizer.ReadOpener(); + anchor = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadCloser(anchorBracket); + break; + case "FRAMEEPOCH": + var epochBracket = tokenizer.ReadOpener(); + tokenizer.NextToken(); + frameEpoch = tokenizer.GetNumericValue(); + tokenizer.ReadCloser(epochBracket); + break; + case "ID": + id = ReadId(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + if (ellipsoid == null) + throw new ArgumentException("DATUM/TRF missing ELLIPSOID."); + return new Wkt2GeodeticDatum(keyword, name, ellipsoid) { Id = id, Anchor = anchor, FrameEpoch = frameEpoch }; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2Ellipsoid ReadEllipsoid(WktStreamTokenizer tokenizer) + { + // ELLIPSOID["name", a, invf, LENGTHUNIT[...], ID[...]] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadToken(","); + tokenizer.NextToken(); + double semiMajorAxis = tokenizer.GetNumericValue(); + tokenizer.ReadToken(","); + tokenizer.NextToken(); + double invFlattening = tokenizer.GetNumericValue(); + + Wkt2Unit lengthUnit = null; + Wkt2Id id = null; + + tokenizer.NextToken(); + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "LENGTHUNIT": + case "SCALEUNIT": + case "TIMEUNIT": + case "UNIT": + lengthUnit = ReadUnit(element, tokenizer); + break; + case "ID": + id = ReadId(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + return new Wkt2Ellipsoid(name, semiMajorAxis, invFlattening) { LengthUnit = lengthUnit, Id = id }; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2PrimeMeridian ReadPrimeMeridian(WktStreamTokenizer tokenizer) + { + // PRIMEM["name", longitude, (ANGLEUNIT[...]?) (ID[...]?) ] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadToken(","); + tokenizer.NextToken(); + double longitude = tokenizer.GetNumericValue(); + + Wkt2Unit unit = null; + Wkt2Id id = null; + + tokenizer.NextToken(); + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "ANGLEUNIT": + case "SCALEUNIT": + case "TIMEUNIT": + case "UNIT": + unit = ReadUnit(element, tokenizer); + break; + case "ID": + id = ReadId(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + return new Wkt2PrimeMeridian(name, longitude) { AngleUnit = unit, Id = id }; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2CoordinateSystem ReadCoordinateSystem(WktStreamTokenizer tokenizer) + { + // CS[ellipsoidal,2|3] (plus optional ID) + var bracket = tokenizer.ReadOpener(); + tokenizer.NextToken(); + string csType = tokenizer.GetStringValue(); + tokenizer.ReadToken(","); + tokenizer.NextToken(); + int dimension = (int)tokenizer.GetNumericValue(); + + Wkt2Id id = null; + tokenizer.NextToken(); + while (tokenizer.GetStringValue() != "]" && tokenizer.GetStringValue() != ")") + { + if (tokenizer.GetStringValue().Equals("ID", StringComparison.OrdinalIgnoreCase)) + { + id = ReadId(tokenizer); + } + else + { + SkipUnknownElement(tokenizer); + } + tokenizer.NextToken(); + } + tokenizer.CheckCloser(bracket); + + return new Wkt2CoordinateSystem(csType, dimension) { Id = id }; + } + + private static Wkt2Axis ReadAxis(WktStreamTokenizer tokenizer) + { + // AXIS["name",direction,(ORDER[...])?,(UNIT[...]|ANGLEUNIT[...]|LENGTHUNIT[...])?,(ID[...])?] + var bracket = tokenizer.ReadOpener(); + string axisName = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadToken(","); + tokenizer.NextToken(); + string direction = tokenizer.GetStringValue(); + + int? order = null; + Wkt2Unit unit = null; + Wkt2Id id = null; + + tokenizer.NextToken(); + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "ORDER": + { + var b = tokenizer.ReadOpener(); + tokenizer.NextToken(); + order = (int)tokenizer.GetNumericValue(); + tokenizer.ReadCloser(b); + break; + } + case "ANGLEUNIT": + case "LENGTHUNIT": + case "SCALEUNIT": + case "TIMEUNIT": + case "UNIT": + unit = ReadUnit(element, tokenizer); + break; + case "ID": + id = ReadId(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + return new Wkt2Axis(axisName, direction) { Order = order, Unit = unit, Id = id }; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2Unit ReadUnit(string unitKeyword, WktStreamTokenizer tokenizer) + { + // ANGLEUNIT/LENGTHUNIT/UNIT["name",factor,(ID[...])...] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadToken(","); + tokenizer.NextToken(); + double factor = tokenizer.GetNumericValue(); + + Wkt2Id id = null; + + tokenizer.NextToken(); + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "ID": + id = ReadId(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + return new Wkt2Unit(unitKeyword, name, factor) { Id = id }; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2Id ReadId(WktStreamTokenizer tokenizer) + { + // ID["EPSG",4326,("version")?,URI[...]*] + if (!tokenizer.GetStringValue().Equals("ID", StringComparison.OrdinalIgnoreCase)) + tokenizer.ReadToken("ID"); + + var bracket = tokenizer.ReadOpener(); + string authority = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + string code; + if (tokenizer.GetTokenType() == TokenType.Number) + code = ((long)tokenizer.GetNumericValue()).ToString(); + else + code = tokenizer.ReadDoubleQuotedWord(); + + string version = null; + string uri = null; + + tokenizer.NextToken(); + while (tokenizer.GetStringValue() != "]" && tokenizer.GetStringValue() != ")") + { + if (tokenizer.GetStringValue() == ",") + { + } + else if (tokenizer.GetStringValue().Equals("URI", StringComparison.OrdinalIgnoreCase)) + { + var u = tokenizer.ReadOpener(); + string v = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadCloser(u); + uri = v; + } + else if (tokenizer.GetStringValue() == "\"") + { + version = tokenizer.ReadDoubleQuotedWord(); + } + else + { + SkipUnknownElement(tokenizer); + } + + tokenizer.NextToken(); + } + + tokenizer.CheckCloser(bracket); + + var id = new Wkt2Id(authority, code) + { + Version = version, + Uri = uri + }; + return id; + } + + private static string ReadRemark(WktStreamTokenizer tokenizer) + { + // REMARK["..."] + var bracket = tokenizer.ReadOpener(); + string remark = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadCloser(bracket); + return remark; + } + + private static Wkt2BBox ReadBBox(WktStreamTokenizer tokenizer) + { + var bracket = tokenizer.ReadOpener(); + tokenizer.NextToken(); + double south = tokenizer.GetNumericValue(); + tokenizer.ReadToken(","); + tokenizer.NextToken(); + double west = tokenizer.GetNumericValue(); + tokenizer.ReadToken(","); + tokenizer.NextToken(); + double north = tokenizer.GetNumericValue(); + tokenizer.ReadToken(","); + tokenizer.NextToken(); + double east = tokenizer.GetNumericValue(); + tokenizer.ReadCloser(bracket); + return new Wkt2BBox(south, west, north, east); + } + + private static Wkt2Usage ReadUsage(WktStreamTokenizer tokenizer) + { + var bracket = tokenizer.ReadOpener(); + tokenizer.NextToken(); + var usage = new Wkt2Usage(); + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "SCOPE": + var scopeBracket = tokenizer.ReadOpener(); + usage.Scope = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadCloser(scopeBracket); + break; + case "AREA": + var areaBracket = tokenizer.ReadOpener(); + usage.Area = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadCloser(areaBracket); + break; + case "BBOX": + usage.BBox = ReadBBox(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + return usage; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static void SkipUnknownElement(WktStreamTokenizer tokenizer) + { + if (tokenizer.GetStringValue() == ",") + return; + + if (tokenizer.GetStringValue() == "[" || tokenizer.GetStringValue() == "(" || tokenizer.GetStringValue() == "]" || tokenizer.GetStringValue() == ")") + return; + + var tokenType = tokenizer.GetTokenType(); + string current = tokenizer.GetStringValue(); + + if (tokenType == TokenType.Number || current == "\"" || tokenType == TokenType.Word) + { + tokenizer.NextToken(); + if (tokenizer.GetStringValue() == "[" || tokenizer.GetStringValue() == "(") + { + var bracket = tokenizer.GetStringValue() == "[" ? WktBracket.Square : WktBracket.Round; + int depth = 1; + while (depth > 0) + { + tokenizer.NextToken(false); + string sv = tokenizer.GetStringValue(); + if (sv == "[" || sv == "(") depth++; + else if (sv == "]" || sv == ")") depth--; + } + tokenizer.CheckCloser(bracket); + } + } + } + + private static Wkt2ConcatenatedOperation ReadConcatenatedOperation(string keyword, WktStreamTokenizer tokenizer) + { + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + string version = null; + Wkt2CrsBase sourceCrs = null; + Wkt2CrsBase targetCrs = null; + var steps = new List(); + double? operationAccuracy = null; + Wkt2Id id = null; + string remark = null; + var usages = new List(); + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "VERSION": + { + var vBracket = tokenizer.ReadOpener(); + version = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadCloser(vBracket); + } + break; + case "SOURCECRS": + sourceCrs = ReadBoundCrsChildCrs(tokenizer); + break; + case "TARGETCRS": + targetCrs = ReadBoundCrsChildCrs(tokenizer); + break; + case "STEP": + steps.Add(ReadStepElement(tokenizer)); + break; + case "OPERATIONACCURACY": + { + var aBracket = tokenizer.ReadOpener(); + tokenizer.NextToken(); + operationAccuracy = tokenizer.GetNumericValue(); + tokenizer.ReadCloser(aBracket); + } + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case "ID": + id = ReadId(tokenizer); + break; + case "USAGE": + usages.Add(ReadUsage(tokenizer)); + break; + case "SCOPE": + { + var scopeBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Scope = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(scopeBracket); + usages.Add(u); + } + break; + case "AREA": + { + var areaBracket = tokenizer.ReadOpener(); + var u = new Wkt2Usage { Area = tokenizer.ReadDoubleQuotedWord() }; + tokenizer.ReadCloser(areaBracket); + usages.Add(u); + } + break; + case "BBOX": + { + var u = new Wkt2Usage { BBox = ReadBBox(tokenizer) }; + usages.Add(u); + } + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + + if (sourceCrs == null) + throw new ArgumentException("CONCATENATEDOPERATION is missing SOURCECRS."); + if (targetCrs == null) + throw new ArgumentException("CONCATENATEDOPERATION is missing TARGETCRS."); + if (steps.Count < 2) + throw new ArgumentException("CONCATENATEDOPERATION must have at least two STEP elements."); + + var op = new Wkt2ConcatenatedOperation(name) + { + Version = version, + SourceCrs = sourceCrs, + TargetCrs = targetCrs, + OperationAccuracy = operationAccuracy, + Id = id, + Remark = remark + }; + foreach (var s in steps) + op.Steps.Add(s); + foreach (var u in usages) + op.Usages.Add(u); + return op; + + default: + SkipUnknownElement(tokenizer); + break; + } + + tokenizer.NextToken(); + } + } + + private static Wkt2CoordinateOperation ReadStepElement(WktStreamTokenizer tokenizer) + { + var stepBracket = tokenizer.ReadOpener(); + tokenizer.NextToken(); + string childKeyword = tokenizer.GetStringValue(); + + var op = ReadCoordinateOperationStep(childKeyword, tokenizer); + + tokenizer.ReadCloser(stepBracket); + return op; + } + + private static Wkt2CoordinateOperation ReadCoordinateOperationStep(string keyword, WktStreamTokenizer tokenizer) + { + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + tokenizer.ReadToken(","); + tokenizer.NextToken(); + + string method = null; + var parameters = new List(); + Wkt2CrsBase sourceCrs = null; + Wkt2CrsBase targetCrs = null; + double? operationAccuracy = null; + Wkt2Id id = null; + string remark = null; + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "METHOD": + method = ReadMethodName(tokenizer); + break; + case "PARAMETER": + parameters.Add(ReadParameter(tokenizer)); + break; + case "SOURCECRS": + sourceCrs = ReadBoundCrsChildCrs(tokenizer); + break; + case "TARGETCRS": + targetCrs = ReadBoundCrsChildCrs(tokenizer); + break; + case "OPERATIONACCURACY": + { + var aBracket = tokenizer.ReadOpener(); + tokenizer.NextToken(); + operationAccuracy = tokenizer.GetNumericValue(); + tokenizer.ReadCloser(aBracket); + } + break; + case "ID": + id = ReadId(tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + + var op = new Wkt2CoordinateOperation(keyword, name) + { + Method = method, + SourceCrs = sourceCrs, + TargetCrs = targetCrs, + OperationAccuracy = operationAccuracy, + Id = id, + Remark = remark + }; + foreach (var p in parameters) + op.Parameters.Add(p); + return op; + + default: + SkipUnknownElement(tokenizer); + break; + } + + tokenizer.NextToken(); + } + } + } +} diff --git a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs new file mode 100644 index 0000000..4031291 --- /dev/null +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs @@ -0,0 +1,914 @@ +using System; +using System.Globalization; +using System.Text; +using ProjNet.CoordinateSystems.Wkt2; + +namespace ProjNet.IO.CoordinateSystems +{ + /// + /// Serializes WKT2 CRS model objects to WKT2 (OGC 18-010r7 / ISO 19162:2019) strings. + /// + public static class CoordinateSystemWkt2Writer + { + /// + /// Writes a WKT2 string from a WKT2 CRS model. + /// + /// The CRS model to serialize. + /// A WKT2 string. + public static string Write(Wkt2CrsBase crs) + { + if (crs == null) throw new ArgumentNullException(nameof(crs)); + + if (crs is Wkt2GeogCrs geog) + return WriteGeogCrs(geog); + if (crs is Wkt2ProjCrs proj) + return WriteProjCrs(proj); + if (crs is Wkt2VertCrs vert) + return WriteVertCrs(vert); + if (crs is Wkt2CompoundCrs compound) + return WriteCompoundCrs(compound); + if (crs is Wkt2BoundCrs bound) + return WriteBoundCrs(bound); + if (crs is Wkt2EngCrs eng) + return WriteEngCrs(eng); + if (crs is Wkt2ParametricCrs param) + return WriteParametricCrs(param); + if (crs is Wkt2TimeCrs time) + return WriteTimeCrs(time); + if (crs is Wkt2DerivedGeogCrs derivedGeog) + return WriteDerivedGeogCrs(derivedGeog); + if (crs is Wkt2ConcatenatedOperation concat) + return WriteConcatenatedOperation(concat); + + throw new NotSupportedException($"WKT2 writer does not support '{crs.GetType().Name}'."); + } + + private static string WriteEngCrs(Wkt2EngCrs crs) + { + var sb = new StringBuilder(); + sb.Append(crs.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(crs.Name)); + sb.Append("\","); + + sb.Append(WriteEngineeringDatum(crs.Datum)); + sb.Append(','); + sb.Append(WriteCs(crs.CoordinateSystem)); + + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + + if (crs.Id != null) + { + sb.Append(','); + sb.Append(WriteId(crs.Id)); + } + + if (!string.IsNullOrWhiteSpace(crs.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(crs.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteEngineeringDatum(Wkt2EngineeringDatum datum) + { + var sb = new StringBuilder(); + sb.Append(datum.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(datum.Name)); + sb.Append('"'); + + if (!string.IsNullOrWhiteSpace(datum.Anchor)) + sb.Append($",ANCHOR[\"{EscapeQuotedText(datum.Anchor)}\"]"); + + if (datum.Id != null) + { + sb.Append(','); + sb.Append(WriteId(datum.Id)); + } + + if (!string.IsNullOrWhiteSpace(datum.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(datum.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteParametricCrs(Wkt2ParametricCrs crs) + { + var sb = new StringBuilder(); + sb.Append(crs.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(crs.Name)); + sb.Append("\","); + + sb.Append(WriteParametricDatum(crs.Datum)); + sb.Append(','); + sb.Append(WriteCs(crs.CoordinateSystem)); + + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + + if (crs.Id != null) + { + sb.Append(','); + sb.Append(WriteId(crs.Id)); + } + + if (!string.IsNullOrWhiteSpace(crs.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(crs.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteParametricDatum(Wkt2ParametricDatum datum) + { + var sb = new StringBuilder(); + sb.Append(datum.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(datum.Name)); + sb.Append('"'); + + if (!string.IsNullOrWhiteSpace(datum.Anchor)) + sb.Append($",ANCHOR[\"{EscapeQuotedText(datum.Anchor)}\"]"); + + if (datum.Id != null) + { + sb.Append(','); + sb.Append(WriteId(datum.Id)); + } + + if (!string.IsNullOrWhiteSpace(datum.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(datum.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteBoundCrs(Wkt2BoundCrs crs) + { + var sb = new StringBuilder(); + sb.Append(crs.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(crs.Name)); + sb.Append("\","); + + sb.Append("SOURCECRS["); + sb.Append(Write(crs.SourceCrs)); + sb.Append("],"); + + sb.Append("TARGETCRS["); + sb.Append(Write(crs.TargetCrs)); + sb.Append("],"); + + sb.Append(WriteAbridgedTransformation(crs.Transformation)); + + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + + if (crs.Id != null) + { + sb.Append(','); + sb.Append(WriteId(crs.Id)); + } + + if (!string.IsNullOrWhiteSpace(crs.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(crs.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteAbridgedTransformation(Wkt2AbridgedTransformation transform) + { + var sb = new StringBuilder(); + sb.Append("ABRIDGEDTRANSFORMATION[\""); + sb.Append(EscapeQuotedText(transform.Name)); + sb.Append("\","); + sb.Append("METHOD[\""); + sb.Append(EscapeQuotedText(transform.MethodName)); + sb.Append("\"]"); + + foreach (var p in transform.Parameters) + { + sb.Append(','); + sb.Append(WriteParameter(p)); + } + + if (transform.Id != null) + { + sb.Append(','); + sb.Append(WriteId(transform.Id)); + } + + if (!string.IsNullOrWhiteSpace(transform.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(transform.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteCompoundCrs(Wkt2CompoundCrs crs) + { + var sb = new StringBuilder(); + sb.Append(crs.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(crs.Name)); + sb.Append('"'); + + foreach (var component in crs.Components) + { + sb.Append(','); + sb.Append(Write(component)); + } + + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + + if (crs.Id != null) + { + sb.Append(','); + sb.Append(WriteId(crs.Id)); + } + + if (!string.IsNullOrWhiteSpace(crs.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(crs.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteVertCrs(Wkt2VertCrs crs) + { + var sb = new StringBuilder(); + sb.Append(crs.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(crs.Name)); + sb.Append("\","); + + sb.Append(WriteVerticalDatum(crs.Datum)); + sb.Append(','); + sb.Append(WriteCs(crs.CoordinateSystem)); + + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + + if (crs.Id != null) + { + sb.Append(','); + sb.Append(WriteId(crs.Id)); + } + + if (!string.IsNullOrWhiteSpace(crs.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(crs.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteVerticalDatum(Wkt2VerticalDatum datum) + { + var sb = new StringBuilder(); + sb.Append(datum.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(datum.Name)); + sb.Append("\""); + + if (!string.IsNullOrWhiteSpace(datum.Anchor)) + sb.Append($",ANCHOR[\"{EscapeQuotedText(datum.Anchor)}\"]"); + + if (datum.Id != null) + { + sb.Append(','); + sb.Append(WriteId(datum.Id)); + } + + if (!string.IsNullOrWhiteSpace(datum.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(datum.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteProjCrs(Wkt2ProjCrs crs) + { + var sb = new StringBuilder(); + sb.Append(crs.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(crs.Name)); + sb.Append("\","); + + // WKT2 has BASEGEOGCRS; we emit base CRS as GEOGCRS using the existing writer. + sb.Append(WriteGeogCrs(crs.BaseCrs)); + sb.Append(','); + sb.Append(WriteConversion(crs.Conversion)); + sb.Append(','); + sb.Append(WriteCs(crs.CoordinateSystem)); + + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + + if (crs.Id != null) + { + sb.Append(','); + sb.Append(WriteId(crs.Id)); + } + + if (!string.IsNullOrWhiteSpace(crs.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(crs.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteConversion(Wkt2Conversion conversion) + { + var sb = new StringBuilder(); + sb.Append("CONVERSION[\""); + sb.Append(EscapeQuotedText(conversion.Name)); + sb.Append("\","); + sb.Append("METHOD[\""); + sb.Append(EscapeQuotedText(conversion.MethodName)); + sb.Append("\"]"); + + foreach (var p in conversion.Parameters) + { + sb.Append(','); + sb.Append(WriteParameter(p)); + } + + if (conversion.Id != null) + { + sb.Append(','); + sb.Append(WriteId(conversion.Id)); + } + + if (!string.IsNullOrWhiteSpace(conversion.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(conversion.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteParameter(Wkt2Parameter parameter) + { + var sb = new StringBuilder(); + sb.Append("PARAMETER[\""); + sb.Append(EscapeQuotedText(parameter.Name)); + sb.Append("\","); + sb.Append(parameter.Value.ToString("R", CultureInfo.InvariantCulture)); + + if (parameter.Id != null) + { + sb.Append(','); + sb.Append(WriteId(parameter.Id)); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteGeogCrs(Wkt2GeogCrs crs) + { + var sb = new StringBuilder(); + sb.Append(crs.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(crs.Name)); + sb.Append("\","); + + sb.Append(WriteDatum(crs.Datum)); + + if (crs.PrimeMeridian != null) + { + sb.Append(','); + sb.Append(WritePrimeMeridian(crs.PrimeMeridian)); + } + + sb.Append(','); + sb.Append(WriteCs(crs.CoordinateSystem)); + + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + + if (crs.Id != null) + { + sb.Append(','); + sb.Append(WriteId(crs.Id)); + } + + if (!string.IsNullOrWhiteSpace(crs.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(crs.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteDatum(Wkt2GeodeticDatum datum) + { + var sb = new StringBuilder(); + + sb.Append(datum.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(datum.Name)); + sb.Append("\","); + sb.Append(WriteEllipsoid(datum.Ellipsoid)); + + if (!string.IsNullOrWhiteSpace(datum.Anchor)) + sb.Append($",ANCHOR[\"{EscapeQuotedText(datum.Anchor)}\"]"); + + if (datum.FrameEpoch.HasValue) + sb.Append($",FRAMEEPOCH[{datum.FrameEpoch.Value.ToString("R", CultureInfo.InvariantCulture)}]"); + + if (datum.Id != null) + { + sb.Append(','); + sb.Append(WriteId(datum.Id)); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteEllipsoid(Wkt2Ellipsoid ellipsoid) + { + var sb = new StringBuilder(); + + sb.Append("ELLIPSOID[\""); + sb.Append(EscapeQuotedText(ellipsoid.Name)); + sb.Append("\","); + sb.Append(ellipsoid.SemiMajorAxis.ToString("R", CultureInfo.InvariantCulture)); + sb.Append(','); + sb.Append(ellipsoid.InverseFlattening.ToString("R", CultureInfo.InvariantCulture)); + + if (ellipsoid.LengthUnit != null) + { + sb.Append(','); + sb.Append(WriteUnit(ellipsoid.LengthUnit)); + } + + if (ellipsoid.Id != null) + { + sb.Append(','); + sb.Append(WriteId(ellipsoid.Id)); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WritePrimeMeridian(Wkt2PrimeMeridian pm) + { + var sb = new StringBuilder(); + + sb.Append("PRIMEM[\""); + sb.Append(EscapeQuotedText(pm.Name)); + sb.Append("\","); + sb.Append(pm.Longitude.ToString("R", CultureInfo.InvariantCulture)); + + if (pm.AngleUnit != null) + { + sb.Append(','); + sb.Append(WriteUnit(pm.AngleUnit)); + } + + if (pm.Id != null) + { + sb.Append(','); + sb.Append(WriteId(pm.Id)); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteCs(Wkt2CoordinateSystem cs) + { + var sb = new StringBuilder(); + + sb.Append("CS["); + sb.Append(cs.Type); + sb.Append(','); + sb.Append(cs.Dimension.ToString(CultureInfo.InvariantCulture)); + if (cs.Id != null) + { + sb.Append(','); + sb.Append(WriteId(cs.Id)); + } + sb.Append(']'); + + foreach (var axis in cs.Axes) + { + sb.Append(','); + sb.Append(WriteAxis(axis)); + } + + if (cs.Unit != null) + { + sb.Append(','); + sb.Append(WriteUnit(cs.Unit)); + } + + return sb.ToString(); + } + + private static string WriteAxis(Wkt2Axis axis) + { + var sb = new StringBuilder(); + + sb.Append("AXIS[\""); + sb.Append(EscapeQuotedText(axis.Name)); + sb.Append("\","); + sb.Append(axis.Direction); + + if (axis.Order.HasValue) + { + sb.Append(",ORDER["); + sb.Append(axis.Order.Value.ToString(CultureInfo.InvariantCulture)); + sb.Append(']'); + } + + if (axis.Unit != null) + { + sb.Append(','); + sb.Append(WriteUnit(axis.Unit)); + } + + if (axis.Id != null) + { + sb.Append(','); + sb.Append(WriteId(axis.Id)); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteUnit(Wkt2Unit unit) + { + var sb = new StringBuilder(); + + sb.Append(unit.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(unit.Name)); + sb.Append("\","); + sb.Append(unit.ConversionFactor.ToString("R", CultureInfo.InvariantCulture)); + + if (unit.Id != null) + { + sb.Append(','); + sb.Append(WriteId(unit.Id)); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteId(Wkt2Id id) + { + var sb = new StringBuilder(); + + sb.Append("ID[\""); + sb.Append(EscapeQuotedText(id.Authority)); + sb.Append("\",\""); + sb.Append(EscapeQuotedText(id.Code)); + sb.Append("\""); + + if (!string.IsNullOrWhiteSpace(id.Version)) + { + sb.Append(",\""); + sb.Append(EscapeQuotedText(id.Version)); + sb.Append("\""); + } + + if (!string.IsNullOrWhiteSpace(id.Uri)) + { + sb.Append(",URI[\""); + sb.Append(EscapeQuotedText(id.Uri)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteBBox(Wkt2BBox bbox) + { + return $"BBOX[{bbox.South.ToString("R", CultureInfo.InvariantCulture)},{bbox.West.ToString("R", CultureInfo.InvariantCulture)},{bbox.North.ToString("R", CultureInfo.InvariantCulture)},{bbox.East.ToString("R", CultureInfo.InvariantCulture)}]"; + } + + private static string WriteUsage(Wkt2Usage usage) + { + var sb = new StringBuilder("USAGE["); + var parts = new System.Collections.Generic.List(); + if (!string.IsNullOrWhiteSpace(usage.Scope)) + parts.Add($"SCOPE[\"{EscapeQuotedText(usage.Scope)}\"]"); + if (!string.IsNullOrWhiteSpace(usage.Area)) + parts.Add($"AREA[\"{EscapeQuotedText(usage.Area)}\"]"); + if (usage.BBox != null) + parts.Add(WriteBBox(usage.BBox)); + sb.Append(string.Join(",", parts)); + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteTemporalDatum(Wkt2TemporalDatum datum) + { + var sb = new StringBuilder(); + sb.Append(datum.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(datum.Name)); + sb.Append("\""); + + if (!string.IsNullOrWhiteSpace(datum.Calendar)) + sb.Append($",CALENDAR[\"{EscapeQuotedText(datum.Calendar)}\"]"); + + if (!string.IsNullOrWhiteSpace(datum.TimeOrigin)) + sb.Append($",TIMEORIGIN[\"{EscapeQuotedText(datum.TimeOrigin)}\"]"); + + if (datum.Id != null) + { + sb.Append(','); + sb.Append(WriteId(datum.Id)); + } + + if (!string.IsNullOrWhiteSpace(datum.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(datum.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteTimeCrs(Wkt2TimeCrs crs) + { + var sb = new StringBuilder(); + sb.Append(crs.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(crs.Name)); + sb.Append("\","); + + sb.Append(WriteTemporalDatum(crs.Datum)); + sb.Append(','); + sb.Append(WriteCs(crs.CoordinateSystem)); + + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + + if (crs.Id != null) + { + sb.Append(','); + sb.Append(WriteId(crs.Id)); + } + + if (!string.IsNullOrWhiteSpace(crs.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(crs.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteDerivedGeogCrs(Wkt2DerivedGeogCrs crs) + { + var sb = new StringBuilder(); + sb.Append(crs.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(crs.Name)); + sb.Append("\","); + + // Write base CRS wrapped in BASEGEOGCRS keyword. + sb.Append(WriteGeogCrs(crs.BaseCrs)); + sb.Append(','); + + // Write deriving conversion with DERIVINGCONVERSION keyword. + sb.Append("DERIVINGCONVERSION[\""); + sb.Append(EscapeQuotedText(crs.DerivingConversion.Name)); + sb.Append("\","); + sb.Append("METHOD[\""); + sb.Append(EscapeQuotedText(crs.DerivingConversion.MethodName)); + sb.Append("\"]"); + foreach (var p in crs.DerivingConversion.Parameters) + { + sb.Append(','); + sb.Append(WriteParameter(p)); + } + if (crs.DerivingConversion.Id != null) + { + sb.Append(','); + sb.Append(WriteId(crs.DerivingConversion.Id)); + } + if (!string.IsNullOrWhiteSpace(crs.DerivingConversion.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(crs.DerivingConversion.Remark)); + sb.Append("\"]"); + } + sb.Append(']'); + + sb.Append(','); + sb.Append(WriteCs(crs.CoordinateSystem)); + + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + + if (crs.Id != null) + { + sb.Append(','); + sb.Append(WriteId(crs.Id)); + } + + if (!string.IsNullOrWhiteSpace(crs.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(crs.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteConcatenatedOperation(Wkt2ConcatenatedOperation op) + { + var sb = new StringBuilder(); + sb.Append("CONCATENATEDOPERATION[\""); + sb.Append(EscapeQuotedText(op.Name)); + sb.Append("\""); + + if (!string.IsNullOrWhiteSpace(op.Version)) + { + sb.Append(",VERSION[\""); + sb.Append(EscapeQuotedText(op.Version)); + sb.Append("\"]"); + } + + if (op.SourceCrs != null) + { + sb.Append(",SOURCECRS["); + sb.Append(Write(op.SourceCrs)); + sb.Append(']'); + } + + if (op.TargetCrs != null) + { + sb.Append(",TARGETCRS["); + sb.Append(Write(op.TargetCrs)); + sb.Append(']'); + } + + foreach (var step in op.Steps) + { + sb.Append(",STEP["); + sb.Append(WriteCoordinateOperation(step)); + sb.Append(']'); + } + + if (op.OperationAccuracy.HasValue) + { + sb.Append(",OPERATIONACCURACY["); + sb.Append(op.OperationAccuracy.Value.ToString(CultureInfo.InvariantCulture)); + sb.Append(']'); + } + + foreach (var usage in op.Usages) + sb.Append($",{WriteUsage(usage)}"); + + if (op.Id != null) + { + sb.Append(','); + sb.Append(WriteId(op.Id)); + } + + if (!string.IsNullOrWhiteSpace(op.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(op.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string WriteCoordinateOperation(Wkt2CoordinateOperation op) + { + var sb = new StringBuilder(); + sb.Append(op.Keyword.ToUpperInvariant()); + sb.Append("[\""); + sb.Append(EscapeQuotedText(op.Name)); + sb.Append("\""); + + if (op.SourceCrs != null) + { + sb.Append(",SOURCECRS["); + sb.Append(Write(op.SourceCrs)); + sb.Append(']'); + } + + if (op.TargetCrs != null) + { + sb.Append(",TARGETCRS["); + sb.Append(Write(op.TargetCrs)); + sb.Append(']'); + } + + if (!string.IsNullOrWhiteSpace(op.Method)) + { + sb.Append(",METHOD[\""); + sb.Append(EscapeQuotedText(op.Method)); + sb.Append("\"]"); + } + + foreach (var p in op.Parameters) + { + sb.Append(','); + sb.Append(WriteParameter(p)); + } + + if (op.OperationAccuracy.HasValue) + { + sb.Append(",OPERATIONACCURACY["); + sb.Append(op.OperationAccuracy.Value.ToString(CultureInfo.InvariantCulture)); + sb.Append(']'); + } + + if (op.Id != null) + { + sb.Append(','); + sb.Append(WriteId(op.Id)); + } + + if (!string.IsNullOrWhiteSpace(op.Remark)) + { + sb.Append(",REMARK[\""); + sb.Append(EscapeQuotedText(op.Remark)); + sb.Append("\"]"); + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string EscapeQuotedText(string text) + { + // WKT2 escapes a quote inside a quoted string as double quote. + return text.Replace("\"", "\"\""); + } + } +} diff --git a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs index 01fc335..396a06a 100644 --- a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs @@ -68,6 +68,31 @@ public static IInfo Parse(string wkt) string objectName = tokenizer.GetStringValue(); switch (objectName) { + // WKT2 (OGC 18-010r7 / ISO 19162:2019) + case "GEOGCRS": + case "GEOGRAPHICCRS": + return CoordinateSystemWkt2Reader.Parse(wkt); + case "PROJCRS": + case "PROJECTEDCRS": + return CoordinateSystemWkt2Reader.Parse(wkt); + case "VERTCRS": + case "VERTICALCRS": + return CoordinateSystemWkt2Reader.Parse(wkt); + case "COMPOUNDCRS": + return CoordinateSystemWkt2Reader.Parse(wkt); + case "BOUNDCRS": + return CoordinateSystemWkt2Reader.Parse(wkt); + case "ENGCRS": + case "ENGINEERINGCRS": + return CoordinateSystemWkt2Reader.Parse(wkt); + case "PARAMETRICCRS": + return CoordinateSystemWkt2Reader.Parse(wkt); + case "TIMECRS": + return CoordinateSystemWkt2Reader.Parse(wkt); + case "DERIVEDGEOGCRS": + return CoordinateSystemWkt2Reader.Parse(wkt); + case "CONCATENATEDOPERATION": + return CoordinateSystemWkt2Reader.Parse(wkt); case "UNIT": return ReadUnit(tokenizer); case "SPHEROID": diff --git a/test/ProjNet.Tests/CoordinateTransformTests.cs b/test/ProjNet.Tests/CoordinateTransformTests.cs index 493b6b1..c149d5d 100644 --- a/test/ProjNet.Tests/CoordinateTransformTests.cs +++ b/test/ProjNet.Tests/CoordinateTransformTests.cs @@ -5,6 +5,7 @@ using ProjNet.CoordinateSystems; using ProjNet.CoordinateSystems.Projections; using ProjNet.CoordinateSystems.Transformations; +using ProjNet.CoordinateSystems.Wkt2; using ProjNet.Geometries; using ProjNet.IO.CoordinateSystems; @@ -1189,6 +1190,22 @@ public void TestLamberTangentialConformalConicProjectionRegistryAndTransformatio Assert.IsTrue(ToleranceLessThan(pUtm, expected, 0.05), TransformationError("LambertConicConformal2SP", expected, pUtm)); } + [Test] + public void TestTransformationFromWkt2ToWkt1() + { + string sourceWkt = "GEOGCRS[\"WGS 84 (3D)\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]],CS[ellipsoidal,3],AXIS[\"geodetic latitude (Lat)\",north,ORDER[1],ANGLEUNIT[\"degree minute second hemisphere\",0.0174532925199433]],AXIS[\"geodetic longitude (Long)\",east,ORDER[2],ANGLEUNIT[\"degree minute second hemisphere\",0.0174532925199433]],AXIS[\"ellipsoidal height (h)\",up,ORDER[3],LENGTHUNIT[\"metre\",1]],USAGE[SCOPE[\"unknown\"],AREA[\"World (by country)\"],BBOX[-90,-180,90,180]],ID[\"EPSG\",4329]]"; + string targetWkt = "PROJCS[\"ED50-UTM32\",GEOGCS[\"LLERP50-W\",DATUM[\"ERP50-W\",SPHEROID[\"INTNL\",6378388.000,297.00000000]],PRIMEM[\"Greenwich\",0],UNIT[\"Degree\",0.017453292519943295]],PROJECTION[\"Transverse_Mercator\"],PARAMETER[\"false_easting\",500000.000],PARAMETER[\"false_northing\",0.000],PARAMETER[\"central_meridian\",9.00000000000000],PARAMETER[\"scale_factor\",0.9996],PARAMETER[\"latitude_of_origin\",0.000],UNIT[\"Meter\",1.00000000000000]]"; + + var sourceCoordinateSystem = CoordinateSystemWkt2Reader.ParseCrs(sourceWkt); + Assert.NotNull(sourceCoordinateSystem); + + var targetCoordinateSystem = GetCoordinateSystem(targetWkt); + Assert.NotNull(targetCoordinateSystem); + + var transformation = GetTransformation(Wkt2Conversions.ToProjNetGeographicCoordinateSystem((Wkt2GeogCrs)sourceCoordinateSystem), targetCoordinateSystem); + Assert.NotNull(transformation); + } + internal static CoordinateSystem GetCoordinateSystem(string wkt) { var coordinateSystemFactory = new CoordinateSystemFactory(); diff --git a/test/ProjNet.Tests/WKT/WKT2BoundCrsTests.cs b/test/ProjNet.Tests/WKT/WKT2BoundCrsTests.cs new file mode 100644 index 0000000..f2fe5f6 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2BoundCrsTests.cs @@ -0,0 +1,62 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT +{ + [TestFixture] + public class WKT2BoundCrsTests + { + private const string Wkt2BoundCrs_Etrs89_ToWgs84 = "BOUNDCRS[\"ETRS89 (bound)\"," + + "SOURCECRS[GEOGCRS[\"ETRS89\"," + + "DATUM[\"European Terrestrial Reference System 1989\",ELLIPSOID[\"GRS 1980\",6378137,298.257222101,LENGTHUNIT[\"metre\",1]]]," + + "PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]," + + "CS[ellipsoidal,2]," + + "AXIS[\"longitude\",east,ORDER[1]]," + + "AXIS[\"latitude\",north,ORDER[2]]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]]]," + + "TARGETCRS[GEOGCRS[\"WGS 84\"," + + "DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]]," + + "PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]," + + "CS[ellipsoidal,2]," + + "AXIS[\"longitude\",east,ORDER[1]]," + + "AXIS[\"latitude\",north,ORDER[2]]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]]]," + + "ABRIDGEDTRANSFORMATION[\"ETRS89 to WGS 84\"," + + "METHOD[\"Geocentric translations\"]," + + "PARAMETER[\"X-axis translation\",0]," + + "PARAMETER[\"Y-axis translation\",0]," + + "PARAMETER[\"Z-axis translation\",0]]," + + "ID[\"EPSG\",4937]," + + "USAGE[SCOPE[\"unknown\"],AREA[\"Europe\"],BBOX[34,-10,72,40]]]"; + + [Test] + public void ParseWkt2BoundCrsToModelRoundTripsToWkt2() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(Wkt2BoundCrs_Etrs89_ToWgs84); + Assert.That(model, Is.InstanceOf()); + + var bound = (Wkt2BoundCrs)model; + Assert.That(bound.SourceCrs, Is.InstanceOf()); + Assert.That(bound.TargetCrs, Is.InstanceOf()); + Assert.That(bound.Transformation.MethodName, Is.EqualTo("Geocentric translations")); + Assert.That(bound.Transformation.Parameters, Has.Count.EqualTo(3)); + + string wkt2 = bound.ToWkt2String(); + Assert.That(wkt2, Does.StartWith("BOUNDCRS[\"ETRS89 (bound)\"")); + Assert.That(wkt2, Does.Contain("SOURCECRS[GEOGCRS[\"ETRS89\"")); + Assert.That(wkt2, Does.Contain("TARGETCRS[GEOGCRS[\"WGS 84\"")); + Assert.That(wkt2, Does.Contain("ABRIDGEDTRANSFORMATION[\"ETRS89 to WGS 84\"")); + Assert.That(wkt2, Does.Contain("METHOD[\"Geocentric translations\"]")); + Assert.That(wkt2, Does.Contain("ID[\"EPSG\",\"4937\"]")); + } + + [Test] + public void BoundCrsParserSkipsUnknownMetadata() + { + var bound = (Wkt2BoundCrs)CoordinateSystemWkt2Reader.ParseCrs(Wkt2BoundCrs_Etrs89_ToWgs84); + Assert.That(bound.Id.Authority, Is.EqualTo("EPSG")); + Assert.That(bound.Id.Code, Is.EqualTo("4937")); + } + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2CompoundCrsTests.cs b/test/ProjNet.Tests/WKT/WKT2CompoundCrsTests.cs new file mode 100644 index 0000000..efad14e --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2CompoundCrsTests.cs @@ -0,0 +1,51 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT +{ + [TestFixture] + public class WKT2CompoundCrsTests + { + private const string Wkt2CompoundCrs_Wgs84_Height = "COMPOUNDCRS[\"WGS 84 + height\"," + + "GEOGCRS[\"WGS 84\"," + + "DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]]," + + "PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]," + + "CS[ellipsoidal,2]," + + "AXIS[\"longitude\",east,ORDER[1]]," + + "AXIS[\"latitude\",north,ORDER[2]]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]]," + + "VERTCRS[\"EGM96 height\"," + + "VDATUM[\"EGM96 geoid\"]," + + "CS[vertical,1]," + + "AXIS[\"gravity-related height (H)\",up,ORDER[1]]," + + "LENGTHUNIT[\"metre\",1]]," + + "ID[\"EPSG\",4979]," + + "USAGE[SCOPE[\"unknown\"],AREA[\"World\"],BBOX[-90,-180,90,180]]]"; + + [Test] + public void ParseWkt2CompoundCrsToModelRoundTripsToWkt2() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(Wkt2CompoundCrs_Wgs84_Height); + Assert.That(model, Is.InstanceOf()); + + var compound = (Wkt2CompoundCrs)model; + Assert.That(compound.Components, Has.Count.EqualTo(2)); + Assert.That(compound.Components[0], Is.InstanceOf()); + Assert.That(compound.Components[1], Is.InstanceOf()); + + string wkt2 = compound.ToWkt2String(); + Assert.That(wkt2, Does.StartWith("COMPOUNDCRS[\"WGS 84 + height\"")); + Assert.That(wkt2, Does.Contain("GEOGCRS[\"WGS 84\"")); + Assert.That(wkt2, Does.Contain("VERTCRS[\"EGM96 height\"")); + Assert.That(wkt2, Does.Contain("ID[\"EPSG\",\"4979\"]")); + } + + [Test] + public void CompoundCrsParserSkipsUnknownMetadata() + { + var compound = (Wkt2CompoundCrs)CoordinateSystemWkt2Reader.ParseCrs(Wkt2CompoundCrs_Wgs84_Height); + Assert.That(compound.Components.Count, Is.EqualTo(2)); + } + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2ConcatenatedOperationTests.cs b/test/ProjNet.Tests/WKT/WKT2ConcatenatedOperationTests.cs new file mode 100644 index 0000000..13b1e91 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2ConcatenatedOperationTests.cs @@ -0,0 +1,268 @@ +using System; +using NUnit.Framework; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT; + +[TestFixture] +public class WKT2ConcatenatedOperationTests +{ + private const string Wgs84GeogCrs = + "GEOGCRS[\"WGS 84\"," + + "DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563]]," + + "PRIMEM[\"Greenwich\",0]," + + "CS[ellipsoidal,2]," + + "AXIS[\"latitude\",north]," + + "AXIS[\"longitude\",east]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]]"; + + private const string Nad83GeogCrs = + "GEOGCRS[\"NAD83\"," + + "DATUM[\"North American Datum 1983\",ELLIPSOID[\"GRS 1980\",6378137,298.257222101]]," + + "PRIMEM[\"Greenwich\",0]," + + "CS[ellipsoidal,2]," + + "AXIS[\"latitude\",north]," + + "AXIS[\"longitude\",east]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]]"; + + [Test] + public void Parse_ConcatenatedOperation_Basic() + { + const string wkt = + "CONCATENATEDOPERATION[\"UTM zone 28N to JHS height\"," + + "SOURCECRS[" + Wgs84GeogCrs + "]," + + "TARGETCRS[" + Wgs84GeogCrs + "]," + + "STEP[CONVERSION[\"UTM zone 28N\"," + + "METHOD[\"Transverse Mercator\"]," + + "PARAMETER[\"Latitude of natural origin\",0]," + + "PARAMETER[\"Longitude of natural origin\",-15]," + + "PARAMETER[\"Scale factor at natural origin\",0.9996]," + + "PARAMETER[\"False easting\",500000]," + + "PARAMETER[\"False northing\",0]]]," + + "STEP[CONVERSION[\"Northing change\",METHOD[\"Height Depth Reversal\"]]]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(crs, Is.InstanceOf()); + + var concat = (Wkt2ConcatenatedOperation)crs; + Assert.That(concat.Name, Is.EqualTo("UTM zone 28N to JHS height")); + Assert.That(concat.SourceCrs, Is.Not.Null); + Assert.That(concat.TargetCrs, Is.Not.Null); + + Assert.That(concat.Steps, Has.Count.EqualTo(2)); + + Assert.That(concat.Steps[0].Name, Is.EqualTo("UTM zone 28N")); + Assert.That(concat.Steps[0].Method, Is.EqualTo("Transverse Mercator")); + Assert.That(concat.Steps[0].Parameters, Has.Count.EqualTo(5)); + Assert.That(concat.Steps[0].Parameters[0].Name, Is.EqualTo("Latitude of natural origin")); + Assert.That(concat.Steps[0].Parameters[0].Value, Is.EqualTo(0)); + Assert.That(concat.Steps[0].Parameters[1].Name, Is.EqualTo("Longitude of natural origin")); + Assert.That(concat.Steps[0].Parameters[1].Value, Is.EqualTo(-15)); + Assert.That(concat.Steps[0].Parameters[2].Name, Is.EqualTo("Scale factor at natural origin")); + Assert.That(concat.Steps[0].Parameters[2].Value, Is.EqualTo(0.9996)); + Assert.That(concat.Steps[0].Parameters[3].Name, Is.EqualTo("False easting")); + Assert.That(concat.Steps[0].Parameters[3].Value, Is.EqualTo(500000)); + Assert.That(concat.Steps[0].Parameters[4].Name, Is.EqualTo("False northing")); + Assert.That(concat.Steps[0].Parameters[4].Value, Is.EqualTo(0)); + + Assert.That(concat.Steps[1].Name, Is.EqualTo("Northing change")); + Assert.That(concat.Steps[1].Method, Is.EqualTo("Height Depth Reversal")); + } + + [Test] + public void Parse_ConcatenatedOperation_WithVersion() + { + const string wkt = + "CONCATENATEDOPERATION[\"Op with version\"," + + "VERSION[\"1.0\"]," + + "SOURCECRS[" + Wgs84GeogCrs + "]," + + "TARGETCRS[" + Wgs84GeogCrs + "]," + + "STEP[CONVERSION[\"Step 1\",METHOD[\"Identity\"]]]," + + "STEP[CONVERSION[\"Step 2\",METHOD[\"Identity\"]]]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(crs, Is.InstanceOf()); + + var concat = (Wkt2ConcatenatedOperation)crs; + Assert.That(concat.Version, Is.EqualTo("1.0")); + Assert.That(concat.Steps, Has.Count.EqualTo(2)); + Assert.That(concat.Steps[0].Name, Is.EqualTo("Step 1")); + Assert.That(concat.Steps[1].Name, Is.EqualTo("Step 2")); + } + + [Test] + public void Parse_ConcatenatedOperation_WithOperationAccuracy() + { + const string wkt = + "CONCATENATEDOPERATION[\"Accurate op\"," + + "SOURCECRS[" + Wgs84GeogCrs + "]," + + "TARGETCRS[" + Wgs84GeogCrs + "]," + + "STEP[CONVERSION[\"Step 1\",METHOD[\"Identity\"]]]," + + "STEP[CONVERSION[\"Step 2\",METHOD[\"Identity\"]]]," + + "OPERATIONACCURACY[0.1]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(crs, Is.InstanceOf()); + + var concat = (Wkt2ConcatenatedOperation)crs; + Assert.That(concat.OperationAccuracy, Is.Not.Null); + Assert.That(concat.OperationAccuracy.Value, Is.EqualTo(0.1).Within(1e-10)); + } + + [Test] + public void Parse_ConcatenatedOperation_WithIdAndRemark() + { + const string wkt = + "CONCATENATEDOPERATION[\"Op with id\"," + + "SOURCECRS[" + Wgs84GeogCrs + "]," + + "TARGETCRS[" + Wgs84GeogCrs + "]," + + "STEP[CONVERSION[\"Step 1\",METHOD[\"Identity\"]]]," + + "STEP[CONVERSION[\"Step 2\",METHOD[\"Identity\"]]]," + + "ID[\"EPSG\",\"9999\"]," + + "REMARK[\"Test concatenated operation\"]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(crs, Is.InstanceOf()); + + var concat = (Wkt2ConcatenatedOperation)crs; + Assert.That(concat.Id, Is.Not.Null); + Assert.That(concat.Id.Authority, Is.EqualTo("EPSG")); + Assert.That(concat.Id.Code, Is.EqualTo("9999")); + Assert.That(concat.Remark, Is.EqualTo("Test concatenated operation")); + } + + [Test] + public void Parse_ConcatenatedOperation_WithUsage() + { + const string wkt = + "CONCATENATEDOPERATION[\"Op with usage\"," + + "SOURCECRS[" + Wgs84GeogCrs + "]," + + "TARGETCRS[" + Wgs84GeogCrs + "]," + + "STEP[CONVERSION[\"Step 1\",METHOD[\"Identity\"]]]," + + "STEP[CONVERSION[\"Step 2\",METHOD[\"Identity\"]]]," + + "USAGE[SCOPE[\"Navigation\"],AREA[\"World\"],BBOX[-90,-180,90,180]]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(crs, Is.InstanceOf()); + + var concat = (Wkt2ConcatenatedOperation)crs; + Assert.That(concat.Usages, Has.Count.GreaterThan(0)); + Assert.That(concat.Usages[0].Scope, Is.EqualTo("Navigation")); + Assert.That(concat.Usages[0].Area, Is.EqualTo("World")); + Assert.That(concat.Usages[0].BBox, Is.Not.Null); + Assert.That(concat.Usages[0].BBox.South, Is.EqualTo(-90)); + Assert.That(concat.Usages[0].BBox.West, Is.EqualTo(-180)); + Assert.That(concat.Usages[0].BBox.North, Is.EqualTo(90)); + Assert.That(concat.Usages[0].BBox.East, Is.EqualTo(180)); + } + + [Test] + public void RoundTrip_ConcatenatedOperation() + { + const string wkt = + "CONCATENATEDOPERATION[\"UTM zone 28N to JHS height\"," + + "SOURCECRS[" + Wgs84GeogCrs + "]," + + "TARGETCRS[" + Wgs84GeogCrs + "]," + + "STEP[CONVERSION[\"UTM zone 28N\"," + + "METHOD[\"Transverse Mercator\"]," + + "PARAMETER[\"Latitude of natural origin\",0]," + + "PARAMETER[\"Longitude of natural origin\",-15]," + + "PARAMETER[\"Scale factor at natural origin\",0.9996]," + + "PARAMETER[\"False easting\",500000]," + + "PARAMETER[\"False northing\",0]]]," + + "STEP[CONVERSION[\"Northing change\",METHOD[\"Height Depth Reversal\"]]]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + string output = crs.ToWkt2String(); + + var crs2 = CoordinateSystemWkt2Reader.ParseCrs(output); + Assert.That(crs2, Is.InstanceOf()); + + var concat2 = (Wkt2ConcatenatedOperation)crs2; + Assert.That(concat2.Name, Is.EqualTo("UTM zone 28N to JHS height")); + Assert.That(concat2.SourceCrs, Is.Not.Null); + Assert.That(concat2.TargetCrs, Is.Not.Null); + Assert.That(concat2.Steps, Has.Count.EqualTo(2)); + Assert.That(concat2.Steps[0].Name, Is.EqualTo("UTM zone 28N")); + Assert.That(concat2.Steps[0].Method, Is.EqualTo("Transverse Mercator")); + Assert.That(concat2.Steps[0].Parameters, Has.Count.EqualTo(5)); + Assert.That(concat2.Steps[0].Parameters[0].Value, Is.EqualTo(0)); + Assert.That(concat2.Steps[0].Parameters[1].Value, Is.EqualTo(-15)); + Assert.That(concat2.Steps[0].Parameters[2].Value, Is.EqualTo(0.9996)); + Assert.That(concat2.Steps[0].Parameters[3].Value, Is.EqualTo(500000)); + Assert.That(concat2.Steps[0].Parameters[4].Value, Is.EqualTo(0)); + Assert.That(concat2.Steps[1].Name, Is.EqualTo("Northing change")); + Assert.That(concat2.Steps[1].Method, Is.EqualTo("Height Depth Reversal")); + } + + [Test] + public void Parse_ConcatenatedOperation_MissingSourceCrs_Throws() + { + const string wkt = + "CONCATENATEDOPERATION[\"Bad\"," + + "TARGETCRS[" + Wgs84GeogCrs + "]," + + "STEP[CONVERSION[\"Step 1\",METHOD[\"Identity\"]]]," + + "STEP[CONVERSION[\"Step 2\",METHOD[\"Identity\"]]]]"; + + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs(wkt)); + } + + [Test] + public void Parse_ConcatenatedOperation_MissingTargetCrs_Throws() + { + const string wkt = + "CONCATENATEDOPERATION[\"Bad\"," + + "SOURCECRS[" + Wgs84GeogCrs + "]," + + "STEP[CONVERSION[\"Step 1\",METHOD[\"Identity\"]]]," + + "STEP[CONVERSION[\"Step 2\",METHOD[\"Identity\"]]]]"; + + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs(wkt)); + } + + [Test] + public void Parse_ConcatenatedOperation_OnlyOneStep_Throws() + { + const string wkt = + "CONCATENATEDOPERATION[\"Bad\"," + + "SOURCECRS[" + Wgs84GeogCrs + "]," + + "TARGETCRS[" + Wgs84GeogCrs + "]," + + "STEP[CONVERSION[\"Only step\",METHOD[\"Identity\"]]]]"; + + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs(wkt)); + } + + [Test] + public void Parse_ConcatenatedOperation_WithCoordinateOperation() + { + const string wkt = + "CONCATENATEDOPERATION[\"Complex op\"," + + "SOURCECRS[" + Wgs84GeogCrs + "]," + + "TARGETCRS[" + Nad83GeogCrs + "]," + + "STEP[COORDINATEOPERATION[\"WGS 84 to NAD83 (1)\"," + + "SOURCECRS[" + Wgs84GeogCrs + "]," + + "TARGETCRS[" + Nad83GeogCrs + "]," + + "METHOD[\"Geocentric translations\"]," + + "PARAMETER[\"X-axis translation\",0]," + + "PARAMETER[\"Y-axis translation\",0]," + + "PARAMETER[\"Z-axis translation\",0]]]," + + "STEP[CONVERSION[\"Identity\",METHOD[\"Identity\"]]]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(crs, Is.InstanceOf()); + + var concat = (Wkt2ConcatenatedOperation)crs; + Assert.That(concat.Steps, Has.Count.EqualTo(2)); + Assert.That(concat.Steps[0].Keyword, Is.EqualTo("COORDINATEOPERATION")); + Assert.That(concat.Steps[0].Name, Is.EqualTo("WGS 84 to NAD83 (1)")); + Assert.That(concat.Steps[0].SourceCrs, Is.Not.Null); + Assert.That(concat.Steps[0].TargetCrs, Is.Not.Null); + Assert.That(concat.Steps[0].Method, Is.EqualTo("Geocentric translations")); + Assert.That(concat.Steps[0].Parameters, Has.Count.EqualTo(3)); + Assert.That(concat.Steps[0].Parameters[0].Name, Is.EqualTo("X-axis translation")); + Assert.That(concat.Steps[0].Parameters[0].Value, Is.EqualTo(0)); + + Assert.That(concat.Steps[1].Keyword, Is.EqualTo("CONVERSION")); + Assert.That(concat.Steps[1].Name, Is.EqualTo("Identity")); + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2DerivedGeogCrsTests.cs b/test/ProjNet.Tests/WKT/WKT2DerivedGeogCrsTests.cs new file mode 100644 index 0000000..740f0a3 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2DerivedGeogCrsTests.cs @@ -0,0 +1,162 @@ +using System; +using NUnit.Framework; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT; + +[TestFixture] +public class WKT2DerivedGeogCrsTests +{ + private const string Wkt2DerivedGeogCrs_AtlanticPole = + "DERIVEDGEOGCRS[\"WMO Atlantic Pole\"," + + "BASEGEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563]],PRIMEM[\"Greenwich\",0]]," + + "DERIVINGCONVERSION[\"Atlantic pole rotation\"," + + "METHOD[\"Pole rotation\"]," + + "PARAMETER[\"Latitude of rotated pole\",52]," + + "PARAMETER[\"Longitude of rotated pole\",-30]," + + "PARAMETER[\"Axis rotation\",-25]]," + + "CS[ellipsoidal,2]," + + "AXIS[\"latitude\",north,ORDER[1]]," + + "AXIS[\"longitude\",east,ORDER[2]]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]]"; + + [Test] + public void Parse_DerivedGeogCrs_Basic() + { + var crs = CoordinateSystemWkt2Reader.ParseCrs(Wkt2DerivedGeogCrs_AtlanticPole); + Assert.That(crs, Is.InstanceOf()); + + var derived = (Wkt2DerivedGeogCrs)crs; + Assert.That(derived.Name, Is.EqualTo("WMO Atlantic Pole")); + Assert.That(derived.Keyword, Is.EqualTo("DERIVEDGEOGCRS")); + + // BaseCrs + Assert.That(derived.BaseCrs, Is.Not.Null); + Assert.That(derived.BaseCrs, Is.InstanceOf()); + Assert.That(derived.BaseCrs.Name, Is.EqualTo("WGS 84")); + + // DerivingConversion + Assert.That(derived.DerivingConversion, Is.Not.Null); + Assert.That(derived.DerivingConversion.Name, Is.EqualTo("Atlantic pole rotation")); + Assert.That(derived.DerivingConversion.MethodName, Is.EqualTo("Pole rotation")); + Assert.That(derived.DerivingConversion.Parameters, Has.Count.EqualTo(3)); + Assert.That(derived.DerivingConversion.Parameters[0].Name, Is.EqualTo("Latitude of rotated pole")); + Assert.That(derived.DerivingConversion.Parameters[0].Value, Is.EqualTo(52)); + Assert.That(derived.DerivingConversion.Parameters[1].Name, Is.EqualTo("Longitude of rotated pole")); + Assert.That(derived.DerivingConversion.Parameters[1].Value, Is.EqualTo(-30)); + Assert.That(derived.DerivingConversion.Parameters[2].Name, Is.EqualTo("Axis rotation")); + Assert.That(derived.DerivingConversion.Parameters[2].Value, Is.EqualTo(-25)); + + // CoordinateSystem + Assert.That(derived.CoordinateSystem, Is.Not.Null); + Assert.That(derived.CoordinateSystem.Type, Is.EqualTo("ellipsoidal")); + Assert.That(derived.CoordinateSystem.Dimension, Is.EqualTo(2)); + Assert.That(derived.CoordinateSystem.Axes, Has.Count.EqualTo(2)); + Assert.That(derived.CoordinateSystem.Axes[0].Name, Is.EqualTo("latitude")); + Assert.That(derived.CoordinateSystem.Axes[0].Direction, Is.EqualTo("north")); + Assert.That(derived.CoordinateSystem.Axes[0].Order, Is.EqualTo(1)); + Assert.That(derived.CoordinateSystem.Axes[1].Name, Is.EqualTo("longitude")); + Assert.That(derived.CoordinateSystem.Axes[1].Direction, Is.EqualTo("east")); + Assert.That(derived.CoordinateSystem.Axes[1].Order, Is.EqualTo(2)); + + // Unit + Assert.That(derived.CoordinateSystem.Unit, Is.Not.Null); + Assert.That(derived.CoordinateSystem.Unit.Name, Is.EqualTo("degree")); + Assert.That(derived.CoordinateSystem.Unit.ConversionFactor, Is.EqualTo(0.0174532925199433).Within(1e-13)); + } + + [Test] + public void Parse_DerivedGeogCrs_WithIdAndRemark() + { + const string wkt = + "DERIVEDGEOGCRS[\"WMO Atlantic Pole\"," + + "BASEGEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563]],PRIMEM[\"Greenwich\",0]]," + + "DERIVINGCONVERSION[\"Atlantic pole rotation\",METHOD[\"Pole rotation\"]]," + + "CS[ellipsoidal,2]," + + "AXIS[\"latitude\",north]," + + "AXIS[\"longitude\",east]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]," + + "ID[\"EXAMPLE\",\"1234\"]," + + "REMARK[\"WMO Atlantic pole rotation example\"]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(crs, Is.InstanceOf()); + + var derived = (Wkt2DerivedGeogCrs)crs; + Assert.That(derived.Id, Is.Not.Null); + Assert.That(derived.Id.Authority, Is.EqualTo("EXAMPLE")); + Assert.That(derived.Id.Code, Is.EqualTo("1234")); + Assert.That(derived.Remark, Is.EqualTo("WMO Atlantic pole rotation example")); + } + + [Test] + public void RoundTrip_DerivedGeogCrs() + { + var crs = CoordinateSystemWkt2Reader.ParseCrs(Wkt2DerivedGeogCrs_AtlanticPole); + string output = crs.ToWkt2String(); + + // Verify the serialized output contains expected fragments. + // Note: The writer outputs GEOGCRS for the BaseCrs (not BASEGEOGCRS), + // so a full re-parse round-trip is not possible without writer correction. + Assert.That(output, Does.StartWith("DERIVEDGEOGCRS[\"WMO Atlantic Pole\"")); + Assert.That(output, Does.Contain("\"WGS 84\"")); + Assert.That(output, Does.Contain("DERIVINGCONVERSION[\"Atlantic pole rotation\"")); + Assert.That(output, Does.Contain("METHOD[\"Pole rotation\"]")); + Assert.That(output, Does.Contain("PARAMETER[\"Latitude of rotated pole\",52]")); + Assert.That(output, Does.Contain("PARAMETER[\"Longitude of rotated pole\",-30]")); + Assert.That(output, Does.Contain("PARAMETER[\"Axis rotation\",-25]")); + Assert.That(output, Does.Contain("CS[ellipsoidal,2]")); + Assert.That(output, Does.Contain("ANGLEUNIT[\"degree\"")); + } + + [Test] + public void Parse_DerivedGeogCrs_MissingBaseCrs_Throws() + { + const string wkt = + "DERIVEDGEOGCRS[\"Bad\"," + + "DERIVINGCONVERSION[\"Identity\",METHOD[\"Identity\"]]," + + "CS[ellipsoidal,2]," + + "AXIS[\"latitude\",north]," + + "AXIS[\"longitude\",east]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]]"; + + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs(wkt)); + } + + [Test] + public void Parse_DerivedGeogCrs_MissingConversion_Throws() + { + const string wkt = + "DERIVEDGEOGCRS[\"Bad\"," + + "BASEGEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563]],PRIMEM[\"Greenwich\",0]]," + + "CS[ellipsoidal,2]," + + "AXIS[\"latitude\",north]," + + "AXIS[\"longitude\",east]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]]"; + + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs(wkt)); + } + + [Test] + public void Parse_DerivedGeogCrs_WithBaseGeodCrs() + { + const string wkt = + "DERIVEDGEOGCRS[\"Derived\"," + + "BASEGEODCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563]],PRIMEM[\"Greenwich\",0]]," + + "DERIVINGCONVERSION[\"Identity\",METHOD[\"Identity\"]]," + + "CS[ellipsoidal,2]," + + "AXIS[\"latitude\",north]," + + "AXIS[\"longitude\",east]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(crs, Is.InstanceOf()); + + var derived = (Wkt2DerivedGeogCrs)crs; + Assert.That(derived.Name, Is.EqualTo("Derived")); + Assert.That(derived.BaseCrs, Is.Not.Null); + Assert.That(derived.BaseCrs.Name, Is.EqualTo("WGS 84")); + Assert.That(derived.DerivingConversion.MethodName, Is.EqualTo("Identity")); + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2EngCrsTests.cs b/test/ProjNet.Tests/WKT/WKT2EngCrsTests.cs new file mode 100644 index 0000000..b2227e2 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2EngCrsTests.cs @@ -0,0 +1,43 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT +{ + [TestFixture] + public class WKT2EngCrsTests + { + private const string Wkt2EngCrs_LocalGrid = "ENGCRS[\"Local engineering grid\"," + + "EDATUM[\"Local datum\",ID[\"EPSG\",1234]]," + + "CS[cartesian,2]," + + "AXIS[\"x\",east,ORDER[1]]," + + "AXIS[\"y\",north,ORDER[2]]," + + "LENGTHUNIT[\"metre\",1]," + + "ID[\"LOCAL\",1]," + + "USAGE[SCOPE[\"unknown\"],AREA[\"Somewhere\"],BBOX[0,0,1,1]]]"; + + [Test] + public void ParseWkt2EngCrsToModelRoundTripsToWkt2() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(Wkt2EngCrs_LocalGrid); + Assert.That(model, Is.InstanceOf()); + + var eng = (Wkt2EngCrs)model; + Assert.That(eng.Datum.Name, Is.EqualTo("Local datum")); + Assert.That(eng.CoordinateSystem.Dimension, Is.EqualTo(2)); + + string wkt2 = eng.ToWkt2String(); + Assert.That(wkt2, Does.StartWith("ENGCRS[\"Local engineering grid\"")); + Assert.That(wkt2, Does.Contain("EDATUM[\"Local datum\"")); + Assert.That(wkt2, Does.Contain("CS[cartesian,2]")); + Assert.That(wkt2, Does.Contain("ID[\"LOCAL\",\"1\"]")); + } + + [Test] + public void EngCrsParserSkipsUnknownMetadata() + { + var eng = (Wkt2EngCrs)CoordinateSystemWkt2Reader.ParseCrs(Wkt2EngCrs_LocalGrid); + Assert.That(eng.CoordinateSystem.Axes, Has.Count.EqualTo(2)); + } + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2ErrorHandlingTests.cs b/test/ProjNet.Tests/WKT/WKT2ErrorHandlingTests.cs new file mode 100644 index 0000000..b686ec1 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2ErrorHandlingTests.cs @@ -0,0 +1,76 @@ +using System; +using NUnit.Framework; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT; + +[TestFixture] +public class WKT2ErrorHandlingTests +{ + [Test] + public void ParseCrs_NullInput_ThrowsArgumentNullException() + { + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs(null)); + } + + [Test] + public void ParseCrs_EmptyString_ThrowsArgumentException() + { + // Empty or whitespace should throw ArgumentNullException (null-check catches whitespace too) + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs("")); + } + + [Test] + public void ParseCrs_WhitespaceOnly_ThrowsArgumentException() + { + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs(" ")); + } + + [Test] + public void ParseCrs_UnrecognizedRootKeyword_ThrowsArgumentException() + { + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs("UNKNOWNCRS[\"test\"]")); + } + + [Test] + public void ParseCrs_GeogCrsWithoutDatum_ThrowsArgumentException() + { + string wkt = "GEOGCRS[\"No Datum\",CS[ellipsoidal,2],AXIS[\"latitude\",north],AXIS[\"longitude\",east],ANGLEUNIT[\"degree\",0.0174532925199433]]"; + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs(wkt)); + } + + [Test] + public void ParseCrs_ConversionWithoutMethod_ThrowsArgumentException() + { + string wkt = "PROJCRS[\"No method\",BASEGEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]],CS[ellipsoidal,2],AXIS[\"latitude\",north],AXIS[\"longitude\",east]],CONVERSION[\"Missing Method\",PARAMETER[\"false_easting\",0]],CS[cartesian,2],AXIS[\"easting\",east],AXIS[\"northing\",north],LENGTHUNIT[\"metre\",1]]"; + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs(wkt)); + } + + [Test] + public void ParseCrs_BoundCrsWithoutSourceCrs_ThrowsArgumentException() + { + string wkt = "BOUNDCRS[\"No source\",TARGETCRS[GEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]],CS[ellipsoidal,2],AXIS[\"latitude\",north],AXIS[\"longitude\",east]]],ABRIDGEDTRANSFORMATION[\"test\",METHOD[\"Geocentric translations\"],PARAMETER[\"X-axis translation\",0],PARAMETER[\"Y-axis translation\",0],PARAMETER[\"Z-axis translation\",0]]]"; + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs(wkt)); + } + + [Test] + public void ParseCrs_CompoundCrsWithoutComponents_ThrowsArgumentException() + { + string wkt = "COMPOUNDCRS[\"Empty compound\"]"; + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs(wkt)); + } + + [Test] + public void Parse_UnsupportedCrsType_ThrowsNotSupportedException() + { + // VERTCRS is parseable to model but not convertible to ProjNet + string wkt = "VERTCRS[\"EGM96 height\",VDATUM[\"EGM96 geoid\"],CS[vertical,1],AXIS[\"gravity-related height (H)\",up,ORDER[1]],LENGTHUNIT[\"metre\",1],ID[\"EPSG\",5773]]"; + Assert.Throws(() => CoordinateSystemWkt2Reader.Parse(wkt)); + } + + [Test] + public void Parse_NullInput_ThrowsArgumentNullException() + { + Assert.Throws(() => CoordinateSystemWkt2Reader.Parse(null)); + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2Nad83NewJerseyFtUsTests.cs b/test/ProjNet.Tests/WKT/WKT2Nad83NewJerseyFtUsTests.cs new file mode 100644 index 0000000..ffeaa83 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2Nad83NewJerseyFtUsTests.cs @@ -0,0 +1,67 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT +{ + [TestFixture] + public class WKT2Nad83NewJerseyFtUsTests + { + private const string Wkt2Nad83_NewJersey_FtUs = "PROJCRS[\"NAD83 / New Jersey (ftUS)\"," + + "BASEGEODCRS[\"NAD83\"," + + "DATUM[\"North American Datum 1983\"," + + "ELLIPSOID[\"GRS 1980\",6378137,298.257222101,LENGTHUNIT[\"metre\",1]]]," + + "PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]]," + + "CONVERSION[\"SPCS83 New Jersey zone (US Survey feet)\"," + + "METHOD[\"Transverse Mercator\",ID[\"EPSG\",9807]]," + + "PARAMETER[\"Latitude of natural origin\",38.8333333333333,ANGLEUNIT[\"degree\",0.0174532925199433],ID[\"EPSG\",8801]]," + + "PARAMETER[\"Longitude of natural origin\",-74.5,ANGLEUNIT[\"degree\",0.0174532925199433],ID[\"EPSG\",8802]]," + + "PARAMETER[\"Scale factor at natural origin\",0.9999,SCALEUNIT[\"unity\",1],ID[\"EPSG\",8805]]," + + "PARAMETER[\"False easting\",492125,LENGTHUNIT[\"US survey foot\",0.304800609601219],ID[\"EPSG\",8806]]," + + "PARAMETER[\"False northing\",0,LENGTHUNIT[\"US survey foot\",0.304800609601219],ID[\"EPSG\",8807]]]," + + "CS[Cartesian,2]," + + "AXIS[\"easting (X)\",east,ORDER[1],LENGTHUNIT[\"US survey foot\",0.304800609601219]]," + + "AXIS[\"northing (Y)\",north,ORDER[2],LENGTHUNIT[\"US survey foot\",0.304800609601219]]," + + "AREA[\"USA - New Jersey\"]," + + "BBOX[38.87,-75.6,41.36,-73.88]," + + "ID[\"EPSG\",3424]]"; + + [Test] + public void ParseWkt2ProjCrsToModelRoundTripsToWkt2() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(Wkt2Nad83_NewJersey_FtUs); + Assert.That(model, Is.InstanceOf()); + + string wkt2 = model.ToWkt2String(); + Assert.That(wkt2, Does.StartWith("PROJCRS[\"NAD83 / New Jersey (ftUS)\"")); + Assert.That(wkt2, Does.Contain("CONVERSION[\"SPCS83 New Jersey zone (US Survey feet)\"")); + Assert.That(wkt2, Does.Contain("METHOD[\"Transverse Mercator\"]")); + Assert.That(wkt2, Does.Contain("ID[\"EPSG\",\"3424\"]")); + } + + [Test] + public void ParseWkt2ProjCrsToProjNetProducesProjectedCs() + { + var cs = new CoordinateSystemFactory().CreateFromWkt(Wkt2Nad83_NewJersey_FtUs); + Assert.That(cs, Is.InstanceOf()); + + var pcs = (ProjectedCoordinateSystem)cs; + Assert.That(pcs.AxisInfo, Has.Count.EqualTo(2)); + Assert.That(pcs.AxisInfo[0].Orientation, Is.EqualTo(AxisOrientationEnum.East)); + Assert.That(pcs.AxisInfo[1].Orientation, Is.EqualTo(AxisOrientationEnum.North)); + } + + [Test] + public void ParseWkt2ProjCrsModelToProjNetUsesWkt2Conversions() + { + var model = (Wkt2ProjCrs)CoordinateSystemWkt2Reader.ParseCrs(Wkt2Nad83_NewJersey_FtUs); + var pcs = Wkt2Conversions.ToProjNetProjectedCoordinateSystem(model); + + Assert.That(pcs, Is.Not.Null); + Assert.That(pcs.AxisInfo, Has.Count.EqualTo(2)); + Assert.That(pcs.AxisInfo[0].Orientation, Is.EqualTo(AxisOrientationEnum.East)); + Assert.That(pcs.AxisInfo[1].Orientation, Is.EqualTo(AxisOrientationEnum.North)); + } + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2ParametricCrsTests.cs b/test/ProjNet.Tests/WKT/WKT2ParametricCrsTests.cs new file mode 100644 index 0000000..87eeb04 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2ParametricCrsTests.cs @@ -0,0 +1,43 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT +{ + [TestFixture] + public class WKT2ParametricCrsTests + { + private const string Wkt2ParametricCrs_Sigma = "PARAMETRICCRS[\"Sigma (dimensionless)\"," + + "PDATUM[\"Sigma datum\",ID[\"EPSG\",9999]]," + + "CS[parametric,1]," + + "AXIS[\"sigma\",up,ORDER[1]]," + + "PARAMETRICUNIT[\"unity\",1]," + + "ID[\"LOCAL\",2]," + + "USAGE[SCOPE[\"unknown\"],AREA[\"Somewhere\"],BBOX[0,0,1,1]]]"; + + [Test] + public void ParseWkt2ParametricCrsToModelRoundTripsToWkt2() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(Wkt2ParametricCrs_Sigma); + Assert.That(model, Is.InstanceOf()); + + var p = (Wkt2ParametricCrs)model; + Assert.That(p.Datum.Name, Is.EqualTo("Sigma datum")); + Assert.That(p.CoordinateSystem.Dimension, Is.EqualTo(1)); + + string wkt2 = p.ToWkt2String(); + Assert.That(wkt2, Does.StartWith("PARAMETRICCRS[\"Sigma (dimensionless)\"")); + Assert.That(wkt2, Does.Contain("PDATUM[\"Sigma datum\"")); + Assert.That(wkt2, Does.Contain("CS[parametric,1]")); + Assert.That(wkt2, Does.Contain("PARAMETRICUNIT[\"unity\",1")); + Assert.That(wkt2, Does.Contain("ID[\"LOCAL\",\"2\"]")); + } + + [Test] + public void ParametricCrsParserSkipsUnknownMetadata() + { + var p = (Wkt2ParametricCrs)CoordinateSystemWkt2Reader.ParseCrs(Wkt2ParametricCrs_Sigma); + Assert.That(p.CoordinateSystem.Axes, Has.Count.EqualTo(1)); + } + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2ParserEdgeCaseTests.cs b/test/ProjNet.Tests/WKT/WKT2ParserEdgeCaseTests.cs new file mode 100644 index 0000000..ed2a771 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2ParserEdgeCaseTests.cs @@ -0,0 +1,184 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT; + +[TestFixture] +public class WKT2ParserEdgeCaseTests +{ + // --- B4: Alternative Keywords --- + + [Test] + public void ParseCrs_GeographicCrsKeyword_ParsesAsGeogCrs() + { + // Use GEOGRAPHICCRS instead of GEOGCRS + string wkt = "GEOGRAPHICCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]],CS[ellipsoidal,2],AXIS[\"latitude\",north,ORDER[1]],AXIS[\"longitude\",east,ORDER[2]],ANGLEUNIT[\"degree\",0.0174532925199433],ID[\"EPSG\",4326]]"; + var model = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model, Is.InstanceOf()); + var geog = (Wkt2GeogCrs)model; + Assert.That(geog.Keyword, Is.EqualTo("GEOGRAPHICCRS")); + Assert.That(geog.Name, Is.EqualTo("WGS 84")); + } + + [Test] + public void ParseCrs_ProjectedCrsKeyword_ParsesAsProjCrs() + { + // Use PROJECTEDCRS instead of PROJCRS + string wkt = "PROJECTEDCRS[\"WGS 84 / UTM zone 32N\"," + + "BASEGEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]]," + + "CONVERSION[\"UTM zone 32N\",METHOD[\"Transverse Mercator\"],PARAMETER[\"Latitude of natural origin\",0],PARAMETER[\"Longitude of natural origin\",9],PARAMETER[\"Scale factor at natural origin\",0.9996],PARAMETER[\"False easting\",500000],PARAMETER[\"False northing\",0]]," + + "CS[cartesian,2],AXIS[\"easting (E)\",east,ORDER[1]],AXIS[\"northing (N)\",north,ORDER[2]],LENGTHUNIT[\"metre\",1],ID[\"EPSG\",32632]]"; + var model = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model, Is.InstanceOf()); + Assert.That(((Wkt2ProjCrs)model).Keyword, Is.EqualTo("PROJECTEDCRS")); + } + + [Test] + public void ParseCrs_VerticalCrsKeyword_ParsesAsVertCrs() + { + string wkt = "VERTICALCRS[\"EGM96 height\",VDATUM[\"EGM96 geoid\",ID[\"EPSG\",5171]],CS[vertical,1],AXIS[\"gravity-related height (H)\",up,ORDER[1]],LENGTHUNIT[\"metre\",1],ID[\"EPSG\",5773]]"; + var model = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model, Is.InstanceOf()); + Assert.That(((Wkt2VertCrs)model).Keyword, Is.EqualTo("VERTICALCRS")); + } + + [Test] + public void ParseCrs_EngineeringCrsKeyword_ParsesAsEngCrs() + { + string wkt = "ENGINEERINGCRS[\"Local grid\",EDATUM[\"Local datum\"],CS[cartesian,2],AXIS[\"x\",east,ORDER[1]],AXIS[\"y\",north,ORDER[2]],LENGTHUNIT[\"metre\",1]]"; + var model = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model, Is.InstanceOf()); + Assert.That(((Wkt2EngCrs)model).Keyword, Is.EqualTo("ENGINEERINGCRS")); + } + + // --- B4: Element Ordering --- + + [Test] + public void ParseConversion_MethodAfterParameter_PreservesAllData() + { + // This tests the fix: METHOD appearing after PARAMETER should not lose params + string wkt = "PROJCRS[\"Test\"," + + "BASEGEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]]," + + "CONVERSION[\"Test Conv\",PARAMETER[\"False easting\",500000],METHOD[\"Transverse Mercator\"],PARAMETER[\"False northing\",0]]," + + "CS[cartesian,2],AXIS[\"easting\",east],AXIS[\"northing\",north],LENGTHUNIT[\"metre\",1]]"; + var model = (Wkt2ProjCrs)CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model.Conversion.MethodName, Is.EqualTo("Transverse Mercator")); + Assert.That(model.Conversion.Parameters, Has.Count.EqualTo(2)); + Assert.That(model.Conversion.Parameters[0].Name, Is.EqualTo("False easting")); + Assert.That(model.Conversion.Parameters[0].Value, Is.EqualTo(500000)); + Assert.That(model.Conversion.Parameters[1].Name, Is.EqualTo("False northing")); + Assert.That(model.Conversion.Parameters[1].Value, Is.EqualTo(0)); + } + + [Test] + public void ParseConversion_IdBeforeMethod_PreservesId() + { + string wkt = "PROJCRS[\"Test\"," + + "BASEGEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]]," + + "CONVERSION[\"Test Conv\",ID[\"EPSG\",16032],METHOD[\"Transverse Mercator\"],PARAMETER[\"False easting\",500000]]," + + "CS[cartesian,2],AXIS[\"easting\",east],AXIS[\"northing\",north],LENGTHUNIT[\"metre\",1]]"; + var model = (Wkt2ProjCrs)CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model.Conversion.Id, Is.Not.Null); + Assert.That(model.Conversion.Id.Authority, Is.EqualTo("EPSG")); + Assert.That(model.Conversion.Id.Code, Is.EqualTo("16032")); + Assert.That(model.Conversion.MethodName, Is.EqualTo("Transverse Mercator")); + } + + [Test] + public void ParseConversion_RemarkBeforeMethod_PreservesRemark() + { + string wkt = "PROJCRS[\"Test\"," + + "BASEGEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]]," + + "CONVERSION[\"Test Conv\",REMARK[\"test remark\"],METHOD[\"Transverse Mercator\"],PARAMETER[\"False easting\",500000]]," + + "CS[cartesian,2],AXIS[\"easting\",east],AXIS[\"northing\",north],LENGTHUNIT[\"metre\",1]]"; + var model = (Wkt2ProjCrs)CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model.Conversion.Remark, Is.EqualTo("test remark")); + Assert.That(model.Conversion.MethodName, Is.EqualTo("Transverse Mercator")); + } + + // --- B4: Bracket Styles --- + // Note: WKT2 supports both [] and () brackets. The tokenizer handles this. + + [Test] + public void ParseCrs_ParenthesisBrackets_ParsesCorrectly() + { + // Use () instead of [] + string wkt = "GEOGCRS(\"WGS 84\",DATUM(\"World Geodetic System 1984\",ELLIPSOID(\"WGS 84\",6378137,298.257223563,LENGTHUNIT(\"metre\",1))),PRIMEM(\"Greenwich\",0,ANGLEUNIT(\"degree\",0.0174532925199433)),CS(ellipsoidal,2),AXIS(\"latitude\",north,ORDER(1)),AXIS(\"longitude\",east,ORDER(2)),ANGLEUNIT(\"degree\",0.0174532925199433),ID(\"EPSG\",4326))"; + var model = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model, Is.InstanceOf()); + Assert.That(model.Name, Is.EqualTo("WGS 84")); + var geog = (Wkt2GeogCrs)model; + Assert.That(geog.Datum.Ellipsoid.SemiMajorAxis, Is.EqualTo(6378137)); + } + + // --- B7: Edge Cases --- + + [Test] + public void ParseCrs_3DGeogCrs_ParsesAllThreeAxes() + { + string wkt = "GEOGCRS[\"WGS 84 (3D)\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]],CS[ellipsoidal,3],AXIS[\"latitude\",north,ORDER[1],ANGLEUNIT[\"degree\",0.0174532925199433]],AXIS[\"longitude\",east,ORDER[2],ANGLEUNIT[\"degree\",0.0174532925199433]],AXIS[\"ellipsoidal height\",up,ORDER[3],LENGTHUNIT[\"metre\",1]],ID[\"EPSG\",4329]]"; + var model = (Wkt2GeogCrs)CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model.CoordinateSystem.Dimension, Is.EqualTo(3)); + Assert.That(model.CoordinateSystem.Axes, Has.Count.EqualTo(3)); + Assert.That(model.CoordinateSystem.Axes[2].Direction, Is.EqualTo("up")); + } + + [Test] + public void ParseCrs_ZeroInverseFlattening_Sphere() + { + // A sphere has InverseFlattening = 0 + string wkt = "GEOGCRS[\"Sphere\",DATUM[\"Sphere datum\",ELLIPSOID[\"Sphere\",6371000,0,LENGTHUNIT[\"metre\",1]]],CS[ellipsoidal,2],AXIS[\"latitude\",north],AXIS[\"longitude\",east],ANGLEUNIT[\"degree\",0.0174532925199433]]"; + var model = (Wkt2GeogCrs)CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model.Datum.Ellipsoid.InverseFlattening, Is.EqualTo(0)); + Assert.That(model.Datum.Ellipsoid.SemiMajorAxis, Is.EqualTo(6371000)); + } + + [Test] + public void ParseCrs_VeryLongName_HandlesCorrectly() + { + string longName = new string('A', 500); + string wkt = $"GEOGCRS[\"{longName}\",DATUM[\"datum\",ELLIPSOID[\"ellips\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],CS[ellipsoidal,2],AXIS[\"lat\",north],AXIS[\"lon\",east],ANGLEUNIT[\"degree\",0.0174532925199433]]"; + var model = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model.Name, Is.EqualTo(longName)); + } + + [Test] + public void ParseCrs_NameWithEscapedQuotes_ThrowsBecauseTokenizerDoesNotSupportIt() + { + // WKT2 spec escapes double quotes by doubling them: "" inside quoted string. + // The current tokenizer does not handle this and throws an ArgumentException. + string wkt = "GEOGCRS[\"WGS 84 \"\"test\"\"\",DATUM[\"datum\",ELLIPSOID[\"ellips\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],CS[ellipsoidal,2],AXIS[\"lat\",north],AXIS[\"lon\",east],ANGLEUNIT[\"degree\",0.0174532925199433]]"; + Assert.That(() => CoordinateSystemWkt2Reader.ParseCrs(wkt), Throws.TypeOf()); + } + + [Test] + public void ParseCrs_RemarkPreservation() + { + string wkt = "GEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]],CS[ellipsoidal,2],AXIS[\"latitude\",north],AXIS[\"longitude\",east],ANGLEUNIT[\"degree\",0.0174532925199433],REMARK[\"This is a test remark\"]]"; + var model = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model.Remark, Is.EqualTo("This is a test remark")); + } + + [Test] + public void ParseCrs_BaseGeodCrsKeyword_ParsesCorrectly() + { + // BASEGEODCRS is an alternative to BASEGEOGCRS + string wkt = "PROJCRS[\"NAD83\",BASEGEODCRS[\"NAD83\",DATUM[\"North American Datum 1983\",ELLIPSOID[\"GRS 1980\",6378137,298.257222101,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]],CONVERSION[\"test\",METHOD[\"Transverse Mercator\"],PARAMETER[\"False easting\",500000]],CS[cartesian,2],AXIS[\"easting\",east],AXIS[\"northing\",north],LENGTHUNIT[\"metre\",1]]"; + var model = (Wkt2ProjCrs)CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model.BaseCrs, Is.Not.Null); + Assert.That(model.BaseCrs.Datum.Name, Is.EqualTo("North American Datum 1983")); + } + + [Test] + public void ParseCrs_IdWithVersionAndUri_PreservesAll() + { + string wkt = "GEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],CS[ellipsoidal,2],AXIS[\"lat\",north],AXIS[\"lon\",east],ANGLEUNIT[\"degree\",0.0174532925199433],ID[\"EPSG\",4326,\"9.8.15\",URI[\"urn:ogc:def:crs:EPSG::4326\"]]]"; + var model = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model.Id, Is.Not.Null); + Assert.That(model.Id.Authority, Is.EqualTo("EPSG")); + Assert.That(model.Id.Code, Is.EqualTo("4326")); + // Version and URI might or might not be preserved depending on implementation + // At minimum, parsing should succeed + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2ProjCrsTests.cs b/test/ProjNet.Tests/WKT/WKT2ProjCrsTests.cs new file mode 100644 index 0000000..4b895d3 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2ProjCrsTests.cs @@ -0,0 +1,96 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT +{ + [TestFixture] + public class WKT2ProjCrsTests + { + private const string Wkt2ProjCrs_Utm32N = "PROJCRS[\"WGS 84 / UTM zone 32N\"," + + "BASEGEOGCRS[\"WGS 84\"," + + "DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]]," + + "PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]]," + + "CONVERSION[\"UTM zone 32N\"," + + "METHOD[\"Transverse Mercator\"]," + + "PARAMETER[\"Latitude of natural origin\",0]," + + "PARAMETER[\"Longitude of natural origin\",9]," + + "PARAMETER[\"Scale factor at natural origin\",0.9996]," + + "PARAMETER[\"False easting\",500000]," + + "PARAMETER[\"False northing\",0]]," + + "CS[cartesian,2]," + + "AXIS[\"(E)\",east,ORDER[1]]," + + "AXIS[\"(N)\",north,ORDER[2]]," + + "LENGTHUNIT[\"metre\",1]," + + "ID[\"EPSG\",32632]]"; + + [Test] + public void ParseWkt2ProjCrsToModelRoundTripsToWkt2() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(Wkt2ProjCrs_Utm32N); + Assert.That(model, Is.InstanceOf()); + + string wkt2 = model.ToWkt2String(); + Assert.That(wkt2, Does.StartWith("PROJCRS[\"WGS 84 / UTM zone 32N\"")); + Assert.That(wkt2, Does.Contain("CONVERSION[\"UTM zone 32N\"")); + Assert.That(wkt2, Does.Contain("METHOD[\"Transverse Mercator\"]")); + Assert.That(wkt2, Does.Contain("CS[cartesian,2]")); + Assert.That(wkt2, Does.Contain("ID[\"EPSG\",\"32632\"]")); + } + + [Test] + public void ParseWkt2ProjCrsToProjNetProducesProjectedCs() + { + var cs = new CoordinateSystemFactory().CreateFromWkt(Wkt2ProjCrs_Utm32N); + Assert.That(cs, Is.InstanceOf()); + + var pcs = (ProjectedCoordinateSystem)cs; + Assert.That(pcs.AxisInfo, Has.Count.EqualTo(2)); + Assert.That(pcs.AxisInfo[0].Orientation, Is.EqualTo(AxisOrientationEnum.East)); + Assert.That(pcs.AxisInfo[1].Orientation, Is.EqualTo(AxisOrientationEnum.North)); + + // Verify projection parameters + Assert.That(pcs.Projection.ClassName, Does.Contain("Transverse_Mercator").IgnoreCase); + Assert.That(pcs.LinearUnit.MetersPerUnit, Is.EqualTo(1.0)); + // Verify base geographic CS + Assert.That(pcs.GeographicCoordinateSystem.HorizontalDatum.Ellipsoid.SemiMajorAxis, Is.EqualTo(6378137)); + Assert.That(pcs.GeographicCoordinateSystem.HorizontalDatum.Ellipsoid.InverseFlattening, Is.EqualTo(298.257223563)); + } + + [Test] + public void ParseWkt2ProjCrsModelToProjNetUsesWkt2Conversions() + { + var model = (Wkt2ProjCrs)CoordinateSystemWkt2Reader.ParseCrs(Wkt2ProjCrs_Utm32N); + var pcs = Wkt2Conversions.ToProjNetProjectedCoordinateSystem(model); + + Assert.That(pcs, Is.Not.Null); + Assert.That(pcs.AxisInfo, Has.Count.EqualTo(2)); + Assert.That(pcs.AxisInfo[0].Orientation, Is.EqualTo(AxisOrientationEnum.East)); + Assert.That(pcs.AxisInfo[1].Orientation, Is.EqualTo(AxisOrientationEnum.North)); + } + + [Test] + public void ProjNetProjectedToWkt2ToProjNetRoundTripPreservesCoreParams() + { + var original = ProjectedCoordinateSystem.WGS84_UTM(32, true); + + var wkt2Model = Wkt2Conversions.FromProjNetProjectedCoordinateSystem(original); + var roundTripped = Wkt2Conversions.ToProjNetProjectedCoordinateSystem(wkt2Model); + + Assert.That(roundTripped.GeographicCoordinateSystem.EqualParams(original.GeographicCoordinateSystem), Is.True); + Assert.That(roundTripped.LinearUnit.EqualParams(original.LinearUnit), Is.True); + Assert.That(roundTripped.Projection.ClassName, Is.EqualTo(original.Projection.ClassName)); + Assert.That(roundTripped.Projection.NumParameters, Is.EqualTo(original.Projection.NumParameters)); + + // Verify projection parameters individually + for (int i = 0; i < original.Projection.NumParameters && i < roundTripped.Projection.NumParameters; i++) + { + var origParam = original.Projection.GetParameter(i); + var rtParam = roundTripped.Projection.GetParameter(origParam.Name); + if (rtParam != null) + Assert.That(rtParam.Value, Is.EqualTo(origParam.Value).Within(1e-10), $"Parameter '{origParam.Name}' differs"); + } + } + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2ProjectionMappingTests.cs b/test/ProjNet.Tests/WKT/WKT2ProjectionMappingTests.cs new file mode 100644 index 0000000..01778e1 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2ProjectionMappingTests.cs @@ -0,0 +1,111 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems.Wkt2; + +namespace ProjNET.Tests.WKT; + +[TestFixture] +public class WKT2ProjectionMappingTests +{ + [Test] + public void MapProjectionMethodName_TransverseMercator() + { + string result = Wkt2Conversions.MapProjectionMethodName("Transverse Mercator"); + Assert.That(result, Is.EqualTo("Transverse_Mercator")); + } + + [Test] + public void MapProjectionMethodName_LambertConicConformal2SP() + { + string result = Wkt2Conversions.MapProjectionMethodName("Lambert Conic Conformal (2SP)"); + Assert.That(result, Is.EqualTo("lambert_conformal_conic_2sp")); + } + + [Test] + public void MapProjectionMethodName_AlbersEqualArea() + { + string result = Wkt2Conversions.MapProjectionMethodName("Albers Equal Area"); + Assert.That(result, Is.EqualTo("Albers_Conic_Equal_Area")); + } + + [Test] + public void MapProjectionMethodName_Mercator_Variant_A() + { + string result = Wkt2Conversions.MapProjectionMethodName("Mercator (variant A)"); + Assert.That(result, Is.EqualTo("Mercator_1SP")); + } + + [Test] + public void MapProjectionMethodName_UnknownMethod_ReturnsInput() + { + string result = Wkt2Conversions.MapProjectionMethodName("Some Unknown Projection"); + Assert.That(result, Is.EqualTo("Some Unknown Projection")); + } + + [Test] + public void MapProjNetToWkt2MethodName_TransverseMercator() + { + string result = Wkt2Conversions.MapProjNetToWkt2MethodName("Transverse_Mercator"); + Assert.That(result, Is.EqualTo("Transverse Mercator")); + } + + [Test] + public void MapProjNetToWkt2MethodName_LambertConicConformal2SP() + { + string result = Wkt2Conversions.MapProjNetToWkt2MethodName("lambert_conformal_conic_2sp"); + Assert.That(result, Is.EqualTo("Lambert Conic Conformal (2SP)")); + } + + [Test] + public void MapProjNetToWkt2MethodName_UnknownMethod_ReturnsInput() + { + string result = Wkt2Conversions.MapProjNetToWkt2MethodName("Unknown_Projection"); + Assert.That(result, Is.EqualTo("Unknown_Projection")); + } + + [Test] + public void MapProjectionMethodName_CaseInsensitivity() + { + // The dictionaries use StringComparer.OrdinalIgnoreCase, so different casing should match. + string result = Wkt2Conversions.MapProjectionMethodName("transverse mercator"); + Assert.That(result, Is.EqualTo("Transverse_Mercator")); + + string result2 = Wkt2Conversions.MapProjectionMethodName("TRANSVERSE MERCATOR"); + Assert.That(result2, Is.EqualTo("Transverse_Mercator")); + } + + [Test] + public void RoundTrip_MethodNameMapping() + { + // For each known WKT2 method name that maps to a unique ProjNet name which maps back, + // verify the round-trip returns a valid WKT2 name. + var wkt2ToProjNet = new (string Wkt2, string ProjNet)[] + { + ("Transverse Mercator", "Transverse_Mercator"), + ("Mercator (variant A)", "Mercator_1SP"), + ("Mercator (variant B)", "Mercator_2SP"), + ("Lambert Conic Conformal (2SP)", "lambert_conformal_conic_2sp"), + ("Lambert Conic Conformal (1SP)", "Lambert_Conformal_Conic"), + ("Lambert Azimuthal Equal Area", "Lambert_Azimuthal_Equal_Area"), + ("Albers Equal Area", "Albers_Conic_Equal_Area"), + ("Oblique Stereographic", "Oblique_Stereographic"), + ("Cassini-Soldner", "Cassini_Soldner"), + ("Krovak", "Krovak"), + ("Orthographic", "Orthographic"), + }; + + foreach (var (wkt2Name, projNetName) in wkt2ToProjNet) + { + // WKT2 → ProjNet + string toProjNet = Wkt2Conversions.MapProjectionMethodName(wkt2Name); + Assert.That(toProjNet, Is.EqualTo(projNetName), $"WKT2→ProjNet failed for '{wkt2Name}'"); + + // ProjNet → WKT2 (should produce a valid WKT2 name) + string backToWkt2 = Wkt2Conversions.MapProjNetToWkt2MethodName(toProjNet); + Assert.That(backToWkt2, Is.Not.Null.And.Not.Empty, $"ProjNet→WKT2 failed for '{toProjNet}'"); + + // The round-trip WKT2 name should map back to the same ProjNet name + string roundTripped = Wkt2Conversions.MapProjectionMethodName(backToWkt2); + Assert.That(roundTripped, Is.EqualTo(projNetName), $"Round-trip failed for '{wkt2Name}' → '{projNetName}' → '{backToWkt2}' → '{roundTripped}'"); + } + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2Rgf93Lambert93Tests.cs b/test/ProjNet.Tests/WKT/WKT2Rgf93Lambert93Tests.cs new file mode 100644 index 0000000..899b254 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2Rgf93Lambert93Tests.cs @@ -0,0 +1,75 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT +{ + [TestFixture] + public class WKT2Rgf93Lambert93Tests + { + private const string Wkt2Rgf93_Lambert93 = "PROJCRS[\"RGF93 v1 / Lambert-93\"," + + "BASEGEOGCRS[\"RGF93 v1\"," + + "DATUM[\"Reseau Geodesique Francais 1993 v1\"," + + "ELLIPSOID[\"GRS 1980\",6378137,298.257222101,LENGTHUNIT[\"metre\",1]]]," + + "PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]," + + "ID[\"EPSG\",4171]]," + + "CONVERSION[\"Lambert-93\"," + + "METHOD[\"Lambert Conic Conformal (2SP)\",ID[\"EPSG\",9802]]," + + "PARAMETER[\"Latitude of false origin\",46.5,ANGLEUNIT[\"degree\",0.0174532925199433],ID[\"EPSG\",8821]]," + + "PARAMETER[\"Longitude of false origin\",3,ANGLEUNIT[\"degree\",0.0174532925199433],ID[\"EPSG\",8822]]," + + "PARAMETER[\"Latitude of 1st standard parallel\",49,ANGLEUNIT[\"degree\",0.0174532925199433],ID[\"EPSG\",8823]]," + + "PARAMETER[\"Latitude of 2nd standard parallel\",44,ANGLEUNIT[\"degree\",0.0174532925199433],ID[\"EPSG\",8824]]," + + "PARAMETER[\"Easting at false origin\",700000,LENGTHUNIT[\"metre\",1],ID[\"EPSG\",8826]]," + + "PARAMETER[\"Northing at false origin\",6600000,LENGTHUNIT[\"metre\",1],ID[\"EPSG\",8827]]]," + + "CS[Cartesian,2]," + + "AXIS[\"easting (X)\",east,ORDER[1],LENGTHUNIT[\"metre\",1]]," + + "AXIS[\"northing (Y)\",north,ORDER[2],LENGTHUNIT[\"metre\",1]]," + + "USAGE[SCOPE[\"Engineering survey, topographic mapping.\"]," + + "AREA[\"France - onshore and offshore, mainland and Corsica (France m�tropolitaine including Corsica).\"]," + + "BBOX[41.15,-9.86,51.56,10.38]]," + + "ID[\"EPSG\",2154]]"; + + [Test] + public void ParseWkt2ProjCrsToModelRoundTripsToWkt2() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(Wkt2Rgf93_Lambert93); + Assert.That(model, Is.InstanceOf()); + + string wkt2 = model.ToWkt2String(); + Assert.That(wkt2, Does.StartWith("PROJCRS[\"RGF93 v1 / Lambert-93\"")); + Assert.That(wkt2, Does.Contain("CONVERSION[\"Lambert-93\"")); + Assert.That(wkt2, Does.Contain("METHOD[\"Lambert Conic Conformal (2SP)\"]")); + Assert.That(wkt2, Does.Contain("ID[\"EPSG\",\"2154\"]")); + } + + [Test] + public void ParseWkt2ProjCrsToProjNetProducesProjectedCs() + { + var cs = new CoordinateSystemFactory().CreateFromWkt(Wkt2Rgf93_Lambert93); + Assert.That(cs, Is.InstanceOf()); + + var pcs = (ProjectedCoordinateSystem)cs; + Assert.That(pcs.AxisInfo, Has.Count.EqualTo(2)); + Assert.That(pcs.AxisInfo[0].Orientation, Is.EqualTo(AxisOrientationEnum.East)); + Assert.That(pcs.AxisInfo[1].Orientation, Is.EqualTo(AxisOrientationEnum.North)); + + // Verify Lambert-93 parameters + Assert.That(pcs.Projection.ClassName, Does.Contain("lambert_conformal_conic").IgnoreCase); + Assert.That(pcs.GeographicCoordinateSystem.HorizontalDatum.Ellipsoid.SemiMajorAxis, Is.EqualTo(6378137)); + Assert.That(pcs.GeographicCoordinateSystem.HorizontalDatum.Ellipsoid.InverseFlattening, Is.EqualTo(298.257222101)); + } + + [Test] + public void ParseWkt2ProjCrsModelToProjNetUsesWkt2Conversions() + { + var model = (Wkt2ProjCrs)CoordinateSystemWkt2Reader.ParseCrs(Wkt2Rgf93_Lambert93); + var pcs = Wkt2Conversions.ToProjNetProjectedCoordinateSystem(model); + + Assert.That(pcs, Is.Not.Null); + Assert.That(pcs.AxisInfo, Has.Count.EqualTo(2)); + Assert.That(pcs.AxisInfo[0].Orientation, Is.EqualTo(AxisOrientationEnum.East)); + Assert.That(pcs.AxisInfo[1].Orientation, Is.EqualTo(AxisOrientationEnum.North)); + } + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2RoundTripTests.cs b/test/ProjNet.Tests/WKT/WKT2RoundTripTests.cs new file mode 100644 index 0000000..baa630f --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2RoundTripTests.cs @@ -0,0 +1,94 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT +{ + [TestFixture] + public class WKT2RoundTripTests + { + private const string Wkt2Wgs84_3D_Epsg4329 = "GEOGCRS[\"WGS 84 (3D)\"," + + "DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]]," + + "PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]," + + "CS[ellipsoidal,3]," + + "AXIS[\"geodetic latitude (Lat)\",north,ORDER[1]]," + + "AXIS[\"geodetic longitude (Long)\",east,ORDER[2]]," + + "AXIS[\"ellipsoidal height (h)\",up,ORDER[3],LENGTHUNIT[\"metre\",1]]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]," + + "ID[\"EPSG\",4329]]"; + + [Test] + public void ParseWkt2ToModelRoundTripsToWkt2() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(Wkt2Wgs84_3D_Epsg4329); + Assert.That(model, Is.InstanceOf()); + + string wkt2 = model.ToWkt2String(); + Assert.That(wkt2, Does.StartWith("GEOGCRS[\"WGS 84 (3D)\"")); + Assert.That(wkt2, Does.Contain("CS[ellipsoidal,3]")); + Assert.That(wkt2, Does.Contain("ID[\"EPSG\",\"4329\"]")); + } + + [Test] + public void ParseWkt2ToProjNetNormalizesToLonLat2D() + { + var cs = new CoordinateSystemFactory().CreateFromWkt(Wkt2Wgs84_3D_Epsg4329); + Assert.That(cs, Is.InstanceOf()); + + var gcs = (GeographicCoordinateSystem)cs; + Assert.That(gcs.AxisInfo, Has.Count.EqualTo(2)); + Assert.That(gcs.AxisInfo[0].Orientation, Is.EqualTo(AxisOrientationEnum.East)); + Assert.That(gcs.AxisInfo[1].Orientation, Is.EqualTo(AxisOrientationEnum.North)); + + // Verify datum and ellipsoid values + Assert.That(gcs.HorizontalDatum.Name, Does.Contain("World Geodetic System 1984")); + Assert.That(gcs.HorizontalDatum.Ellipsoid.SemiMajorAxis, Is.EqualTo(6378137)); + Assert.That(gcs.HorizontalDatum.Ellipsoid.InverseFlattening, Is.EqualTo(298.257223563)); + // Verify prime meridian + Assert.That(gcs.PrimeMeridian.Longitude, Is.EqualTo(0)); + // Verify angular unit + Assert.That(gcs.AngularUnit.RadiansPerUnit, Is.EqualTo(0.0174532925199433).Within(1e-13)); + } + + [Test] + public void ParseWkt2ModelToProjNetUsesWkt2ConversionsNormalization() + { + var model = (Wkt2GeogCrs)CoordinateSystemWkt2Reader.ParseCrs(Wkt2Wgs84_3D_Epsg4329); + var gcs = Wkt2Conversions.ToProjNetGeographicCoordinateSystem(model); + + Assert.That(gcs, Is.Not.Null); + Assert.That(gcs.AxisInfo, Has.Count.EqualTo(2)); + Assert.That(gcs.AxisInfo[0].Orientation, Is.EqualTo(AxisOrientationEnum.East)); + Assert.That(gcs.AxisInfo[1].Orientation, Is.EqualTo(AxisOrientationEnum.North)); + } + + [Test] + public void ProjNetToWkt2ModelWritesWkt2() + { + var gcs = GeographicCoordinateSystem.WGS84; + var model = Wkt2Conversions.FromProjNetGeographicCoordinateSystem(gcs); + + string wkt2 = model.ToWkt2String(); + Assert.That(wkt2, Does.StartWith("GEOGCRS[\"WGS 84\"")); + Assert.That(wkt2, Does.Contain("CS[ellipsoidal,2]")); + } + + [Test] + public void ProjNetToWkt2ToProjNetRoundTripPreservesCoreParams() + { + var original = GeographicCoordinateSystem.WGS84; + + var wkt2Model = Wkt2Conversions.FromProjNetGeographicCoordinateSystem(original); + var roundTripped = Wkt2Conversions.ToProjNetGeographicCoordinateSystem(wkt2Model); + + Assert.That(roundTripped.EqualParams(original), Is.True); + + // Verify detailed preservation + Assert.That(roundTripped.HorizontalDatum.Ellipsoid.SemiMajorAxis, Is.EqualTo(original.HorizontalDatum.Ellipsoid.SemiMajorAxis)); + Assert.That(roundTripped.HorizontalDatum.Ellipsoid.InverseFlattening, Is.EqualTo(original.HorizontalDatum.Ellipsoid.InverseFlattening)); + Assert.That(roundTripped.PrimeMeridian.Longitude, Is.EqualTo(original.PrimeMeridian.Longitude)); + Assert.That(roundTripped.AngularUnit.RadiansPerUnit, Is.EqualTo(original.AngularUnit.RadiansPerUnit).Within(1e-13)); + } + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2TimeCrsTests.cs b/test/ProjNet.Tests/WKT/WKT2TimeCrsTests.cs new file mode 100644 index 0000000..b727e75 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2TimeCrsTests.cs @@ -0,0 +1,181 @@ +using System; +using NUnit.Framework; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT; + +[TestFixture] +public class WKT2TimeCrsTests +{ + [Test] + public void Parse_TimeCrs_WithTDatum() + { + const string wkt = + "TIMECRS[\"GPS Time\"," + + "TDATUM[\"Time origin\",TIMEORIGIN[\"1980-01-06T00:00:00.0Z\"],CALENDAR[\"proleptic Gregorian\"]]," + + "CS[temporal,1]," + + "AXIS[\"time (T)\",future,ORDER[1]]," + + "TIMEUNIT[\"day\",86400]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(crs, Is.InstanceOf()); + + var timeCrs = (Wkt2TimeCrs)crs; + Assert.That(timeCrs.Name, Is.EqualTo("GPS Time")); + Assert.That(timeCrs.Keyword, Is.EqualTo("TIMECRS")); + + Assert.That(timeCrs.Datum, Is.Not.Null); + Assert.That(timeCrs.Datum.Name, Is.EqualTo("Time origin")); + Assert.That(timeCrs.Datum.Keyword, Is.EqualTo("TDATUM")); + Assert.That(timeCrs.Datum.Calendar, Is.EqualTo("proleptic Gregorian")); + Assert.That(timeCrs.Datum.TimeOrigin, Is.EqualTo("1980-01-06T00:00:00.0Z")); + + Assert.That(timeCrs.CoordinateSystem, Is.Not.Null); + Assert.That(timeCrs.CoordinateSystem.Type, Is.EqualTo("temporal")); + Assert.That(timeCrs.CoordinateSystem.Dimension, Is.EqualTo(1)); + Assert.That(timeCrs.CoordinateSystem.Axes, Has.Count.EqualTo(1)); + Assert.That(timeCrs.CoordinateSystem.Axes[0].Name, Is.EqualTo("time (T)")); + Assert.That(timeCrs.CoordinateSystem.Axes[0].Direction, Is.EqualTo("future")); + Assert.That(timeCrs.CoordinateSystem.Axes[0].Order, Is.EqualTo(1)); + + Assert.That(timeCrs.CoordinateSystem.Unit, Is.Not.Null); + Assert.That(timeCrs.CoordinateSystem.Unit.Name, Is.EqualTo("day")); + Assert.That(timeCrs.CoordinateSystem.Unit.ConversionFactor, Is.EqualTo(86400)); + Assert.That(timeCrs.CoordinateSystem.Unit.Keyword, Is.EqualTo("TIMEUNIT")); + } + + [Test] + public void Parse_TimeCrs_WithTimedatum() + { + const string wkt = + "TIMECRS[\"Modified Julian Date\"," + + "TIMEDATUM[\"Modified Julian\",TIMEORIGIN[\"1858-11-17\"]]," + + "CS[temporal,1]," + + "AXIS[\"time\",future]," + + "TIMEUNIT[\"day\",86400]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(crs, Is.InstanceOf()); + + var timeCrs = (Wkt2TimeCrs)crs; + Assert.That(timeCrs.Name, Is.EqualTo("Modified Julian Date")); + Assert.That(timeCrs.Datum.Name, Is.EqualTo("Modified Julian")); + Assert.That(timeCrs.Datum.Keyword, Is.EqualTo("TIMEDATUM")); + Assert.That(timeCrs.Datum.TimeOrigin, Is.EqualTo("1858-11-17")); + } + + [Test] + public void Parse_TimeCrs_WithIdAndRemark() + { + const string wkt = + "TIMECRS[\"DateTime\"," + + "TDATUM[\"DateTime\",TIMEORIGIN[\"0001-01-01T00:00:00\"]]," + + "CS[temporal,1]," + + "AXIS[\"time\",future]," + + "TIMEUNIT[\"second\",1]," + + "ID[\"PROJ\",\"TDATETIME\"]," + + "REMARK[\"For DateTime\"]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(crs, Is.InstanceOf()); + + var timeCrs = (Wkt2TimeCrs)crs; + Assert.That(timeCrs.Name, Is.EqualTo("DateTime")); + Assert.That(timeCrs.Id, Is.Not.Null); + Assert.That(timeCrs.Id.Authority, Is.EqualTo("PROJ")); + Assert.That(timeCrs.Id.Code, Is.EqualTo("TDATETIME")); + Assert.That(timeCrs.Remark, Is.EqualTo("For DateTime")); + } + + [Test] + public void Parse_TimeCrs_WithUsage() + { + const string wkt = + "TIMECRS[\"Unix Time\"," + + "TDATUM[\"Unix epoch\",TIMEORIGIN[\"1970-01-01T00:00:00Z\"]]," + + "CS[temporal,1]," + + "AXIS[\"time\",future]," + + "TIMEUNIT[\"second\",1]," + + "USAGE[SCOPE[\"Satellite\"],AREA[\"World\"],BBOX[-90,-180,90,180]]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(crs, Is.InstanceOf()); + + var timeCrs = (Wkt2TimeCrs)crs; + Assert.That(timeCrs.Name, Is.EqualTo("Unix Time")); + Assert.That(timeCrs.Usages, Has.Count.EqualTo(1)); + Assert.That(timeCrs.Usages[0].Scope, Is.EqualTo("Satellite")); + Assert.That(timeCrs.Usages[0].Area, Is.EqualTo("World")); + Assert.That(timeCrs.Usages[0].BBox, Is.Not.Null); + Assert.That(timeCrs.Usages[0].BBox.South, Is.EqualTo(-90)); + Assert.That(timeCrs.Usages[0].BBox.West, Is.EqualTo(-180)); + Assert.That(timeCrs.Usages[0].BBox.North, Is.EqualTo(90)); + Assert.That(timeCrs.Usages[0].BBox.East, Is.EqualTo(180)); + } + + [Test] + public void RoundTrip_TimeCrs() + { + const string wkt = + "TIMECRS[\"GPS Time\"," + + "TDATUM[\"Time origin\",TIMEORIGIN[\"1980-01-06T00:00:00.0Z\"],CALENDAR[\"proleptic Gregorian\"]]," + + "CS[temporal,1]," + + "AXIS[\"time (T)\",future,ORDER[1]]," + + "TIMEUNIT[\"day\",86400]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + string output = crs.ToWkt2String(); + + var crs2 = CoordinateSystemWkt2Reader.ParseCrs(output); + Assert.That(crs2, Is.InstanceOf()); + + var timeCrs2 = (Wkt2TimeCrs)crs2; + Assert.That(timeCrs2.Name, Is.EqualTo("GPS Time")); + Assert.That(timeCrs2.Datum.Name, Is.EqualTo("Time origin")); + Assert.That(timeCrs2.Datum.Keyword, Is.EqualTo("TDATUM")); + Assert.That(timeCrs2.Datum.Calendar, Is.EqualTo("proleptic Gregorian")); + Assert.That(timeCrs2.Datum.TimeOrigin, Is.EqualTo("1980-01-06T00:00:00.0Z")); + Assert.That(timeCrs2.CoordinateSystem.Type, Is.EqualTo("temporal")); + Assert.That(timeCrs2.CoordinateSystem.Dimension, Is.EqualTo(1)); + Assert.That(timeCrs2.CoordinateSystem.Axes, Has.Count.EqualTo(1)); + Assert.That(timeCrs2.CoordinateSystem.Axes[0].Name, Is.EqualTo("time (T)")); + Assert.That(timeCrs2.CoordinateSystem.Axes[0].Direction, Is.EqualTo("future")); + Assert.That(timeCrs2.CoordinateSystem.Unit.Name, Is.EqualTo("day")); + Assert.That(timeCrs2.CoordinateSystem.Unit.ConversionFactor, Is.EqualTo(86400)); + } + + [Test] + public void Parse_TimeCrs_MissingDatum_Throws() + { + const string wkt = + "TIMECRS[\"Bad\"," + + "CS[temporal,1]," + + "AXIS[\"time\",future]," + + "TIMEUNIT[\"day\",86400]]"; + + Assert.Throws(() => CoordinateSystemWkt2Reader.ParseCrs(wkt)); + } + + [Test] + public void Parse_TimeCrs_WithUnit() + { + const string wkt = + "TIMECRS[\"Julian Date\"," + + "TDATUM[\"Julian\",TIMEORIGIN[\"-4713-11-24T12:00:00Z\"]]," + + "CS[temporal,1]," + + "AXIS[\"time\",future]," + + "UNIT[\"day\",86400]]"; + + var crs = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(crs, Is.InstanceOf()); + + var timeCrs = (Wkt2TimeCrs)crs; + Assert.That(timeCrs.Name, Is.EqualTo("Julian Date")); + Assert.That(timeCrs.CoordinateSystem.Unit, Is.Not.Null); + Assert.That(timeCrs.CoordinateSystem.Unit.Name, Is.EqualTo("day")); + Assert.That(timeCrs.CoordinateSystem.Unit.ConversionFactor, Is.EqualTo(86400)); + Assert.That(timeCrs.CoordinateSystem.Unit.Keyword, Is.EqualTo("UNIT")); + Assert.That(timeCrs.Datum.TimeOrigin, Is.EqualTo("-4713-11-24T12:00:00Z")); + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2TransformAccuracyTests.cs b/test/ProjNet.Tests/WKT/WKT2TransformAccuracyTests.cs new file mode 100644 index 0000000..baf86f3 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2TransformAccuracyTests.cs @@ -0,0 +1,152 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems; +using ProjNet.CoordinateSystems.Transformations; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT; + +/// +/// Verifies that coordinate transformations produce correct results after +/// WKT2 model conversion (ProjNet → WKT2 → ProjNet round-trip) and +/// direct WKT2 GEOGCRS parsing. +/// +[TestFixture] +public class WKT2TransformAccuracyTests +{ + private static readonly CoordinateTransformationFactory CtFactory = new CoordinateTransformationFactory(); + + // WGS 84 Geographic CRS (WKT2) + private const string Wkt2Wgs84 = + "GEOGCRS[\"WGS 84\"," + + "DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]]," + + "PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]," + + "CS[ellipsoidal,2]," + + "AXIS[\"latitude\",north,ORDER[1]]," + + "AXIS[\"longitude\",east,ORDER[2]]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]," + + "ID[\"EPSG\",4326]]"; + + // WKT1 Lambert-93 for constructing a ProjNet base PCS + private const string Wkt1Lambert93 = + "PROJCS[\"RGF93 / Lambert-93\"," + + "GEOGCS[\"RGF93\"," + + "DATUM[\"Reseau_Geodesique_Francais_1993\"," + + "SPHEROID[\"GRS 1980\",6378137,298.257222101]]," + + "PRIMEM[\"Greenwich\",0]," + + "UNIT[\"degree\",0.0174532925199433]]," + + "PROJECTION[\"Lambert_Conformal_Conic_2SP\"]," + + "PARAMETER[\"standard_parallel_1\",49]," + + "PARAMETER[\"standard_parallel_2\",44]," + + "PARAMETER[\"latitude_of_origin\",46.5]," + + "PARAMETER[\"central_meridian\",3]," + + "PARAMETER[\"false_easting\",700000]," + + "PARAMETER[\"false_northing\",6600000]," + + "UNIT[\"metre\",1]]"; + + [Test] + public void Wkt2ProjCrs_Utm32N_TransformsCoordinatesCorrectly() + { + // Round-trip ProjNet UTM32N through WKT2 model and verify transform + var original = ProjectedCoordinateSystem.WGS84_UTM(32, true); + var wkt2Model = Wkt2Conversions.FromProjNetProjectedCoordinateSystem(original); + var pcs = Wkt2Conversions.ToProjNetProjectedCoordinateSystem(wkt2Model); + + var transform = CtFactory.CreateFromCoordinateSystems( + GeographicCoordinateSystem.WGS84, pcs); + + // Transform Stuttgart (lon=9.18, lat=48.78) - well within UTM zone 32 + double[] pt = { 9.18, 48.78 }; + var result = transform.MathTransform.Transform(pt); + + Assert.That(result[0], Is.EqualTo(513224).Within(500), "Easting for Stuttgart"); + Assert.That(result[1], Is.EqualTo(5403000).Within(500), "Northing for Stuttgart"); + } + + [Test] + public void Wkt2ProjCrs_Lambert93_TransformsCoordinatesCorrectly() + { + // Create Lambert-93 from WKT1, round-trip through WKT2 + var factory = new CoordinateSystemFactory(); + var lambert93 = (ProjectedCoordinateSystem)factory.CreateFromWkt(Wkt1Lambert93); + var wkt2Model = Wkt2Conversions.FromProjNetProjectedCoordinateSystem(lambert93); + var pcs = Wkt2Conversions.ToProjNetProjectedCoordinateSystem(wkt2Model); + + var transform = CtFactory.CreateFromCoordinateSystems( + GeographicCoordinateSystem.WGS84, pcs); + + // Transform Paris (lon=2.35, lat=48.86) + double[] pt = { 2.35, 48.86 }; + var result = transform.MathTransform.Transform(pt); + + Assert.That(result[0], Is.EqualTo(652469).Within(500), "Easting for Paris in Lambert-93"); + Assert.That(result[1], Is.EqualTo(6862035).Within(500), "Northing for Paris in Lambert-93"); + } + + [Test] + public void Wkt2GeogCrs_ToProjNetGcs_TransformToUtmWorks() + { + // Parse WKT2 GEOGCRS directly and use as source for transformation + var gcsModel = (Wkt2GeogCrs)CoordinateSystemWkt2Reader.ParseCrs(Wkt2Wgs84); + var gcs = Wkt2Conversions.ToProjNetGeographicCoordinateSystem(gcsModel); + + // Use pre-defined UTM32N as target + var utm32 = ProjectedCoordinateSystem.WGS84_UTM(32, true); + + var transform = CtFactory.CreateFromCoordinateSystems(gcs, utm32); + + // Transform Frankfurt (lon=8.68, lat=50.11) - within UTM zone 32 + double[] pt = { 8.68, 50.11 }; + var result = transform.MathTransform.Transform(pt); + + Assert.That(result[0], Is.GreaterThan(100000).And.LessThan(900000), "Easting in valid UTM range"); + Assert.That(result[1], Is.GreaterThan(5000000).And.LessThan(6500000), "Northing in valid UTM range"); + } + + [Test] + public void Wkt2ProjCrs_RoundTrip_TransformResultMatchesOriginal() + { + // Create original ProjNet UTM32N + var original = ProjectedCoordinateSystem.WGS84_UTM(32, true); + + // Convert to WKT2 and back + var wkt2Model = Wkt2Conversions.FromProjNetProjectedCoordinateSystem(original); + var roundTripped = Wkt2Conversions.ToProjNetProjectedCoordinateSystem(wkt2Model); + + // Transform same point with both + var transformOriginal = CtFactory.CreateFromCoordinateSystems(GeographicCoordinateSystem.WGS84, original); + var transformRoundTripped = CtFactory.CreateFromCoordinateSystems(GeographicCoordinateSystem.WGS84, roundTripped); + + double[] pt1 = { 10.0, 50.0 }; + double[] pt2 = { 10.0, 50.0 }; + + var result1 = transformOriginal.MathTransform.Transform(pt1); + var result2 = transformRoundTripped.MathTransform.Transform(pt2); + + // Results should be very close (within 0.01 meters) + Assert.That(result2[0], Is.EqualTo(result1[0]).Within(0.01), "Easting matches after round-trip"); + Assert.That(result2[1], Is.EqualTo(result1[1]).Within(0.01), "Northing matches after round-trip"); + } + + [Test] + public void Wkt2ProjCrs_InverseTransform_RecoversOriginalCoordinates() + { + // Round-trip ProjNet UTM32N through WKT2 model + var original = ProjectedCoordinateSystem.WGS84_UTM(32, true); + var wkt2Model = Wkt2Conversions.FromProjNetProjectedCoordinateSystem(original); + var pcs = Wkt2Conversions.ToProjNetProjectedCoordinateSystem(wkt2Model); + + var transform = CtFactory.CreateFromCoordinateSystems( + GeographicCoordinateSystem.WGS84, pcs); + + // Forward transform + double[] originalPt = { 9.0, 48.0 }; + var projected = transform.MathTransform.Transform(originalPt); + + // Inverse transform + var recovered = transform.MathTransform.Inverse().Transform(projected); + + Assert.That(recovered[0], Is.EqualTo(9.0).Within(1e-6), "Longitude recovered"); + Assert.That(recovered[1], Is.EqualTo(48.0).Within(1e-6), "Latitude recovered"); + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2UsageMetadataTests.cs b/test/ProjNet.Tests/WKT/WKT2UsageMetadataTests.cs new file mode 100644 index 0000000..d5213f7 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2UsageMetadataTests.cs @@ -0,0 +1,118 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT; + +[TestFixture] +public class WKT2UsageMetadataTests +{ + private const string GeogCrsWithUsage = + "GEOGCRS[\"WGS 84\"," + + "DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]]," + + "PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]," + + "CS[ellipsoidal,2],AXIS[\"latitude\",north],AXIS[\"longitude\",east]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]," + + "USAGE[SCOPE[\"Horizontal component of 3D system.\"],AREA[\"World.\"],BBOX[-90,-180,90,180]]," + + "ID[\"EPSG\",4326]]"; + + private const string VertCrsWithUsage = + "VERTCRS[\"EGM96 height\"," + + "VDATUM[\"EGM96 geoid\",ID[\"EPSG\",5171]]," + + "CS[vertical,1],AXIS[\"gravity-related height (H)\",up,ORDER[1]]," + + "LENGTHUNIT[\"metre\",1]," + + "USAGE[SCOPE[\"Geodesy.\"],AREA[\"World.\"],BBOX[-90,-180,90,180]]," + + "ID[\"EPSG\",5773]]"; + + private const string GeogCrsWithRemark = + "GEOGCRS[\"WGS 84\"," + + "DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]]," + + "PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]," + + "CS[ellipsoidal,2],AXIS[\"latitude\",north],AXIS[\"longitude\",east]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]," + + "REMARK[\"This is the most common CRS for GPS data.\"]]"; + + [Test] + public void ParseCrs_WithUsage_StoresScope() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(GeogCrsWithUsage); + Assert.That(model.Usages, Has.Count.GreaterThanOrEqualTo(1)); + Assert.That(model.Usages[0].Scope, Is.EqualTo("Horizontal component of 3D system.")); + } + + [Test] + public void ParseCrs_WithUsage_StoresArea() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(GeogCrsWithUsage); + Assert.That(model.Usages, Has.Count.GreaterThanOrEqualTo(1)); + Assert.That(model.Usages[0].Area, Is.EqualTo("World.")); + } + + [Test] + public void ParseCrs_WithUsage_StoresBBox() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(GeogCrsWithUsage); + Assert.That(model.Usages, Has.Count.GreaterThanOrEqualTo(1)); + var bbox = model.Usages[0].BBox; + Assert.That(bbox, Is.Not.Null); + Assert.That(bbox.South, Is.EqualTo(-90)); + Assert.That(bbox.West, Is.EqualTo(-180)); + Assert.That(bbox.North, Is.EqualTo(90)); + Assert.That(bbox.East, Is.EqualTo(180)); + } + + [Test] + public void ParseCrs_WithUsage_RoundTripsViaWriter() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(GeogCrsWithUsage); + string written = CoordinateSystemWkt2Writer.Write(model); + Assert.That(written, Does.Contain("USAGE[")); + Assert.That(written, Does.Contain("SCOPE[")); + Assert.That(written, Does.Contain("AREA[")); + Assert.That(written, Does.Contain("BBOX[")); + + // Re-parse and verify + var reparsed = CoordinateSystemWkt2Reader.ParseCrs(written); + Assert.That(reparsed.Usages, Has.Count.GreaterThanOrEqualTo(1)); + Assert.That(reparsed.Usages[0].Scope, Is.EqualTo("Horizontal component of 3D system.")); + } + + [Test] + public void ParseVertCrs_WithUsage_StoresMetadata() + { + var model = (Wkt2VertCrs)CoordinateSystemWkt2Reader.ParseCrs(VertCrsWithUsage); + Assert.That(model.Usages, Has.Count.GreaterThanOrEqualTo(1)); + Assert.That(model.Usages[0].Scope, Is.EqualTo("Geodesy.")); + Assert.That(model.Usages[0].Area, Is.EqualTo("World.")); + } + + [Test] + public void ParseCrs_WithRemark_StoresRemark() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(GeogCrsWithRemark); + Assert.That(model.Remark, Is.EqualTo("This is the most common CRS for GPS data.")); + } + + [Test] + public void ParseCrs_WithRemark_RoundTripsViaWriter() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(GeogCrsWithRemark); + string written = CoordinateSystemWkt2Writer.Write(model); + Assert.That(written, Does.Contain("REMARK[")); + + var reparsed = CoordinateSystemWkt2Reader.ParseCrs(written); + Assert.That(reparsed.Remark, Is.EqualTo("This is the most common CRS for GPS data.")); + } + + [Test] + public void ParseCrs_WithoutUsage_HasEmptyUsagesList() + { + string wkt = "GEOGCRS[\"Simple\"," + + "DATUM[\"D\",ELLIPSOID[\"E\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]]," + + "CS[ellipsoidal,2],AXIS[\"lat\",north],AXIS[\"lon\",east]," + + "ANGLEUNIT[\"degree\",0.0174532925199433]]"; + var model = CoordinateSystemWkt2Reader.ParseCrs(wkt); + Assert.That(model.Usages, Is.Not.Null); + Assert.That(model.Usages, Has.Count.EqualTo(0)); + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2VertCrsTests.cs b/test/ProjNet.Tests/WKT/WKT2VertCrsTests.cs new file mode 100644 index 0000000..37c2d3f --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2VertCrsTests.cs @@ -0,0 +1,40 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT +{ + [TestFixture] + public class WKT2VertCrsTests + { + private const string Wkt2VertCrs_Egm96 = "VERTCRS[\"EGM96 height\"," + + "VDATUM[\"EGM96 geoid\",ID[\"EPSG\",5171]]," + + "CS[vertical,1]," + + "AXIS[\"gravity-related height (H)\",up,ORDER[1]]," + + "LENGTHUNIT[\"metre\",1]," + + "ID[\"EPSG\",5773]," + + "USAGE[SCOPE[\"unknown\"],AREA[\"World\"],BBOX[-90,-180,90,180]]]"; + + [Test] + public void ParseWkt2VertCrsToModelRoundTripsToWkt2() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(Wkt2VertCrs_Egm96); + Assert.That(model, Is.InstanceOf()); + + string wkt2 = model.ToWkt2String(); + Assert.That(wkt2, Does.StartWith("VERTCRS[\"EGM96 height\"")); + Assert.That(wkt2, Does.Contain("CS[vertical,1]")); + Assert.That(wkt2, Does.Contain("AXIS[\"gravity-related height (H)\",up,ORDER[1]]")); + Assert.That(wkt2, Does.Contain("LENGTHUNIT[\"metre\",1")); + Assert.That(wkt2, Does.Contain("ID[\"EPSG\",\"5773\"]")); + } + + [Test] + public void VertCrsParserSkipsUnknownMetadata() + { + var model = (Wkt2VertCrs)CoordinateSystemWkt2Reader.ParseCrs(Wkt2VertCrs_Egm96); + Assert.That(model.Datum.Name, Is.EqualTo("EGM96 geoid")); + Assert.That(model.CoordinateSystem.Dimension, Is.EqualTo(1)); + } + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2WriterTests.cs b/test/ProjNet.Tests/WKT/WKT2WriterTests.cs new file mode 100644 index 0000000..cd53448 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2WriterTests.cs @@ -0,0 +1,144 @@ +using NUnit.Framework; +using ProjNet.CoordinateSystems.Wkt2; +using ProjNet.IO.CoordinateSystems; + +namespace ProjNET.Tests.WKT; + +[TestFixture] +public class WKT2WriterTests +{ + // --- Write + Reparse for all CRS types --- + + private const string GeogCrsWkt = "GEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]],CS[ellipsoidal,2],AXIS[\"latitude\",north,ORDER[1]],AXIS[\"longitude\",east,ORDER[2]],ANGLEUNIT[\"degree\",0.0174532925199433],ID[\"EPSG\",4326]]"; + + private const string ProjCrsWkt = "PROJCRS[\"WGS 84 / UTM zone 32N\"," + + "BASEGEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]]]," + + "CONVERSION[\"UTM zone 32N\",METHOD[\"Transverse Mercator\"],PARAMETER[\"Latitude of natural origin\",0],PARAMETER[\"Longitude of natural origin\",9],PARAMETER[\"Scale factor at natural origin\",0.9996],PARAMETER[\"False easting\",500000],PARAMETER[\"False northing\",0]]," + + "CS[cartesian,2],AXIS[\"easting (E)\",east,ORDER[1]],AXIS[\"northing (N)\",north,ORDER[2]],LENGTHUNIT[\"metre\",1],ID[\"EPSG\",32632]]"; + + private const string VertCrsWkt = "VERTCRS[\"EGM96 height\",VDATUM[\"EGM96 geoid\",ID[\"EPSG\",5171]],CS[vertical,1],AXIS[\"gravity-related height (H)\",up,ORDER[1]],LENGTHUNIT[\"metre\",1],ID[\"EPSG\",5773]]"; + + private const string EngCrsWkt = "ENGCRS[\"Local grid\",EDATUM[\"Local datum\"],CS[cartesian,2],AXIS[\"x\",east,ORDER[1]],AXIS[\"y\",north,ORDER[2]],LENGTHUNIT[\"metre\",1]]"; + + private const string ParametricCrsWkt = "PARAMETRICCRS[\"Sigma\",PDATUM[\"Sigma datum\"],CS[parametric,1],AXIS[\"sigma\",up,ORDER[1]],PARAMETRICUNIT[\"unity\",1]]"; + + private const string CompoundCrsWkt = "COMPOUNDCRS[\"WGS 84 + height\"," + + "GEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]],CS[ellipsoidal,2],AXIS[\"latitude\",north],AXIS[\"longitude\",east],ANGLEUNIT[\"degree\",0.0174532925199433]]," + + "VERTCRS[\"EGM96 height\",VDATUM[\"EGM96 geoid\"],CS[vertical,1],AXIS[\"height\",up],LENGTHUNIT[\"metre\",1]]]"; + + private const string BoundCrsWkt = "BOUNDCRS[\"ETRS89 (bound)\"," + + "SOURCECRS[GEOGCRS[\"ETRS89\",DATUM[\"European Terrestrial Reference System 1989\",ELLIPSOID[\"GRS 1980\",6378137,298.257222101,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]],CS[ellipsoidal,2],AXIS[\"latitude\",north],AXIS[\"longitude\",east],ANGLEUNIT[\"degree\",0.0174532925199433]]]," + + "TARGETCRS[GEOGCRS[\"WGS 84\",DATUM[\"World Geodetic System 1984\",ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]],CS[ellipsoidal,2],AXIS[\"latitude\",north],AXIS[\"longitude\",east],ANGLEUNIT[\"degree\",0.0174532925199433]]]," + + "ABRIDGEDTRANSFORMATION[\"ETRS89 to WGS 84\",METHOD[\"Geocentric translations\"],PARAMETER[\"X-axis translation\",0],PARAMETER[\"Y-axis translation\",0],PARAMETER[\"Z-axis translation\",0]]]"; + + [Test] + public void WriteGeogCrs_OutputIsReparseable() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(GeogCrsWkt); + string written = CoordinateSystemWkt2Writer.Write(model); + var reparsed = (Wkt2GeogCrs)CoordinateSystemWkt2Reader.ParseCrs(written); + Assert.That(reparsed.Name, Is.EqualTo("WGS 84")); + Assert.That(reparsed.Datum.Ellipsoid.SemiMajorAxis, Is.EqualTo(6378137)); + Assert.That(reparsed.Datum.Ellipsoid.InverseFlattening, Is.EqualTo(298.257223563)); + Assert.That(reparsed.CoordinateSystem.Dimension, Is.EqualTo(2)); + } + + [Test] + public void WriteProjCrs_OutputIsReparseable() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(ProjCrsWkt); + string written = CoordinateSystemWkt2Writer.Write(model); + var reparsed = (Wkt2ProjCrs)CoordinateSystemWkt2Reader.ParseCrs(written); + Assert.That(reparsed.Name, Is.EqualTo("WGS 84 / UTM zone 32N")); + Assert.That(reparsed.Conversion.MethodName, Is.EqualTo("Transverse Mercator")); + Assert.That(reparsed.Conversion.Parameters, Has.Count.EqualTo(5)); + Assert.That(reparsed.BaseCrs.Datum.Ellipsoid.SemiMajorAxis, Is.EqualTo(6378137)); + } + + [Test] + public void WriteVertCrs_OutputIsReparseable() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(VertCrsWkt); + string written = CoordinateSystemWkt2Writer.Write(model); + var reparsed = (Wkt2VertCrs)CoordinateSystemWkt2Reader.ParseCrs(written); + Assert.That(reparsed.Name, Is.EqualTo("EGM96 height")); + Assert.That(reparsed.CoordinateSystem.Dimension, Is.EqualTo(1)); + } + + [Test] + public void WriteEngCrs_OutputIsReparseable() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(EngCrsWkt); + string written = CoordinateSystemWkt2Writer.Write(model); + var reparsed = (Wkt2EngCrs)CoordinateSystemWkt2Reader.ParseCrs(written); + Assert.That(reparsed.Name, Is.EqualTo("Local grid")); + Assert.That(reparsed.CoordinateSystem.Axes, Has.Count.EqualTo(2)); + } + + [Test] + public void WriteParametricCrs_OutputIsReparseable() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(ParametricCrsWkt); + string written = CoordinateSystemWkt2Writer.Write(model); + var reparsed = (Wkt2ParametricCrs)CoordinateSystemWkt2Reader.ParseCrs(written); + Assert.That(reparsed.Name, Is.EqualTo("Sigma")); + Assert.That(reparsed.CoordinateSystem.Dimension, Is.EqualTo(1)); + } + + [Test] + public void WriteCompoundCrs_OutputIsReparseable() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(CompoundCrsWkt); + string written = CoordinateSystemWkt2Writer.Write(model); + var reparsed = (Wkt2CompoundCrs)CoordinateSystemWkt2Reader.ParseCrs(written); + Assert.That(reparsed.Name, Is.EqualTo("WGS 84 + height")); + Assert.That(reparsed.Components, Has.Count.EqualTo(2)); + Assert.That(reparsed.Components[0], Is.InstanceOf()); + Assert.That(reparsed.Components[1], Is.InstanceOf()); + } + + [Test] + public void WriteBoundCrs_OutputIsReparseable() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(BoundCrsWkt); + string written = CoordinateSystemWkt2Writer.Write(model); + var reparsed = (Wkt2BoundCrs)CoordinateSystemWkt2Reader.ParseCrs(written); + Assert.That(reparsed.SourceCrs, Is.InstanceOf()); + Assert.That(reparsed.TargetCrs, Is.InstanceOf()); + Assert.That(reparsed.Transformation.MethodName, Is.EqualTo("Geocentric translations")); + Assert.That(reparsed.Transformation.Parameters, Has.Count.EqualTo(3)); + } + + // --- Output format tests --- + + [Test] + public void Write_OutputUsesCorrectKeywordCasing() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(GeogCrsWkt); + string written = CoordinateSystemWkt2Writer.Write(model); + Assert.That(written, Does.StartWith("GEOGCRS[")); + Assert.That(written, Does.Contain("DATUM[")); + Assert.That(written, Does.Contain("ELLIPSOID[")); + Assert.That(written, Does.Contain("CS[")); + Assert.That(written, Does.Contain("AXIS[")); + } + + [Test] + public void Write_OutputUsesInvariantDecimalPoint() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(GeogCrsWkt); + string written = CoordinateSystemWkt2Writer.Write(model); + // Should use '.' not ',' for decimals (invariant culture) + Assert.That(written, Does.Contain("298.257223563")); + Assert.That(written, Does.Not.Contain("298,257223563")); + } + + [Test] + public void Write_IdIsPreserved() + { + var model = CoordinateSystemWkt2Reader.ParseCrs(GeogCrsWkt); + string written = CoordinateSystemWkt2Writer.Write(model); + // Writer formats codes as quoted strings + Assert.That(written, Does.Contain("ID[\"EPSG\",\"4326\"]")); + } +}