From 551dfd3af6245647e360b92a55d1f32ffde4ebe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20M=C3=BCller?= Date: Wed, 21 Jan 2026 17:09:35 +0100 Subject: [PATCH 1/5] WKT2: Complete parsing/model/serialization implemented New object-oriented WKT2 CRS model introduced (including for GEOGCRS, PROJCRS, VERTCRS, COMPOUNDCRS, BOUNDCRS, ENGCRS, PARAMETRICCRS) with all components. The WKT2 reader has been fundamentally implemented and now supports parsing into the new model as well as conversion to/from ProjNet objects. A new writer enables serialization back to WKT2. Extensive unit tests for parsing, roundtrip, and model structure added. This enables complete, roundtrip-capable WKT2 parsing and serialization in ProjNet for the first time. References #83 and #86 --- .../Projections/ProjectionsRegistry.cs | 260 ++-- .../Wkt2/Wkt2AbridgedTransformation.cs | 48 + .../CoordinateSystems/Wkt2/Wkt2Axis.cs | 47 + .../CoordinateSystems/Wkt2/Wkt2BoundCrs.cs | 56 + .../CoordinateSystems/Wkt2/Wkt2CompoundCrs.cs | 43 + .../CoordinateSystems/Wkt2/Wkt2Conversion.cs | 48 + .../CoordinateSystems/Wkt2/Wkt2Conversions.cs | 224 +++ .../Wkt2/Wkt2CoordinateSystem.cs | 48 + .../CoordinateSystems/Wkt2/Wkt2CrsBase.cs | 40 + .../CoordinateSystems/Wkt2/Wkt2Ellipsoid.cs | 49 + .../CoordinateSystems/Wkt2/Wkt2EngCrs.cs | 49 + .../Wkt2/Wkt2EngineeringDatum.cs | 42 + .../Wkt2/Wkt2GeodeticDatum.cs | 44 + .../CoordinateSystems/Wkt2/Wkt2GeogCrs.cs | 54 + src/ProjNet/CoordinateSystems/Wkt2/Wkt2Id.cs | 42 + .../CoordinateSystems/Wkt2/Wkt2Parameter.cs | 37 + .../Wkt2/Wkt2ParametricCrs.cs | 49 + .../Wkt2/Wkt2ParametricDatum.cs | 42 + .../Wkt2/Wkt2PrimeMeridian.cs | 42 + .../CoordinateSystems/Wkt2/Wkt2ProjCrs.cs | 56 + .../CoordinateSystems/Wkt2/Wkt2Unit.cs | 44 + .../CoordinateSystems/Wkt2/Wkt2VertCrs.cs | 49 + .../Wkt2/Wkt2VerticalDatum.cs | 42 + .../CoordinateSystemWkt2Reader.cs | 1254 +++++++++++++++++ .../CoordinateSystemWkt2Writer.cs | 608 ++++++++ .../CoordinateSystemWktReader.cs | 19 + .../ProjNet.Tests/CoordinateTransformTests.cs | 17 + test/ProjNet.Tests/WKT/WKT2BoundCrsTests.cs | 62 + .../ProjNet.Tests/WKT/WKT2CompoundCrsTests.cs | 51 + test/ProjNet.Tests/WKT/WKT2EngCrsTests.cs | 43 + .../WKT/WKT2Nad83NewJerseyFtUsTests.cs | 67 + .../WKT/WKT2ParametricCrsTests.cs | 43 + test/ProjNet.Tests/WKT/WKT2ProjCrsTests.cs | 80 ++ .../WKT/WKT2Rgf93Lambert93Tests.cs | 70 + test/ProjNet.Tests/WKT/WKT2RoundTripTests.cs | 79 ++ test/ProjNet.Tests/WKT/WKT2VertCrsTests.cs | 40 + 36 files changed, 3758 insertions(+), 130 deletions(-) create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2AbridgedTransformation.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2Axis.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2BoundCrs.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2CompoundCrs.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversion.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversions.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2CoordinateSystem.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2CrsBase.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2Ellipsoid.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2EngCrs.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2EngineeringDatum.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2GeodeticDatum.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2GeogCrs.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2Id.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2Parameter.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2ParametricCrs.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2ParametricDatum.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2PrimeMeridian.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2ProjCrs.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2Unit.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2VertCrs.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2VerticalDatum.cs create mode 100644 src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs create mode 100644 src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2BoundCrsTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2CompoundCrsTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2EngCrsTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2Nad83NewJerseyFtUsTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2ParametricCrsTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2ProjCrsTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2Rgf93Lambert93Tests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2RoundTripTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2VertCrsTests.cs 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/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/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..40841ad --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversions.cs @@ -0,0 +1,224 @@ +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)); + + var baseCrs = pcs.GeographicCoordinateSystem.FromProjNetGeographicCoordinateSystem(); + + var conversion = new Wkt2Conversion(pcs.Projection.Name, 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)); + + 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 string MapProjectionMethodName(string wkt2Method) + { + if (string.IsNullOrWhiteSpace(wkt2Method)) + return ""; + + string m = wkt2Method.Trim(); + + // Most common mappings for EPSG exports. + if (m.Equals("Transverse Mercator", StringComparison.OrdinalIgnoreCase)) return "Transverse_Mercator"; + if (m.Equals("Mercator", StringComparison.OrdinalIgnoreCase)) return "Mercator_1SP"; + if (m.Equals("Lambert Conic Conformal (2SP)", StringComparison.OrdinalIgnoreCase)) return "lambert_conformal_conic_2sp"; + + // Fallback: keep original. + return m; + } + } +} 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..b900790 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CrsBase.cs @@ -0,0 +1,40 @@ +using System; + +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; } + + /// + /// Serializes the model back to a WKT2 string. + /// + public abstract string ToWkt2String(); + } +} 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..2c8386d --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2EngineeringDatum.cs @@ -0,0 +1,42 @@ +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 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..93a3aa4 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2GeodeticDatum.cs @@ -0,0 +1,44 @@ +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 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..9802cf0 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ParametricDatum.cs @@ -0,0 +1,42 @@ +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 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/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/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..59a4548 --- /dev/null +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2VerticalDatum.cs @@ -0,0 +1,42 @@ +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 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..ca0091a --- /dev/null +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs @@ -0,0 +1,1254 @@ +using System; +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); + 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; + + 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 "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 ",": + 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 + }; + return crs; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2EngineeringDatum ReadEngineeringDatum(string keyword, WktStreamTokenizer tokenizer) + { + // EDATUM/DATUM["name", ID[...], REMARK[...]] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + Wkt2Id id = null; + string remark = null; + + tokenizer.NextToken(); + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + 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 }; + 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; + + 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 "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 ",": + 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 + }; + return crs; + default: + SkipUnknownElement(tokenizer); + break; + } + tokenizer.NextToken(); + } + } + + private static Wkt2ParametricDatum ReadParametricDatum(string keyword, WktStreamTokenizer tokenizer) + { + // PDATUM/DATUM["name", ID[...], REMARK[...]] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + Wkt2Id id = null; + string remark = null; + + tokenizer.NextToken(); + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + 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 }; + 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; + + 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 ",": + 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 + }; + 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 ",": + 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 transform = new Wkt2AbridgedTransformation(name, string.Empty); + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "METHOD": + methodName = ReadMethodName(tokenizer); + transform = new Wkt2AbridgedTransformation(name, methodName); + break; + case "PARAMETER": + transform.Parameters.Add(ReadParameter(tokenizer)); + break; + case "ID": + transform.Id = ReadId(tokenizer); + break; + case "REMARK": + transform.Remark = ReadRemark(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + if (string.IsNullOrWhiteSpace(transform.MethodName)) + throw new ArgumentException("ABRIDGEDTRANSFORMATION is missing METHOD."); + 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; + + 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 "ID": + id = ReadId(tokenizer); + break; + case "REMARK": + remark = ReadRemark(tokenizer); + 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 + }; + 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; + + 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 "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 ",": + 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 + }; + return crs; + + default: + SkipUnknownElement(tokenizer); + break; + } + + tokenizer.NextToken(); + } + } + + private static Wkt2VerticalDatum ReadVerticalDatum(string keyword, WktStreamTokenizer tokenizer) + { + // VDATUM/DATUM["name", ID[...], REMARK[...]] + var bracket = tokenizer.ReadOpener(); + string name = tokenizer.ReadDoubleQuotedWord(); + + Wkt2Id id = null; + string remark = null; + + tokenizer.NextToken(); + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + 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 }; + 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; + + 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 "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 ",": + 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 + }; + 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": + 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 conversion = new Wkt2Conversion(name, string.Empty); + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "METHOD": + methodName = ReadMethodName(tokenizer); + conversion = new Wkt2Conversion(name, methodName); + break; + case "PARAMETER": + conversion.Parameters.Add(ReadParameter(tokenizer)); + break; + case "ID": + conversion.Id = ReadId(tokenizer); + break; + case "REMARK": + conversion.Remark = ReadRemark(tokenizer); + break; + case ",": + break; + case "]": + case ")": + tokenizer.CheckCloser(bracket); + if (string.IsNullOrWhiteSpace(conversion.MethodName)) + throw new ArgumentException("CONVERSION is missing METHOD."); + return conversion; + 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; + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "DATUM": + case "TRF": + case "GEODETICDATUM": + 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 "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 ",": + 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 + }; + 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; + + while (true) + { + string element = tokenizer.GetStringValue(); + switch (element.ToUpperInvariant()) + { + case "ELLIPSOID": + case "SPHEROID": + ellipsoid = ReadEllipsoid(tokenizer); + 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 }; + 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 "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 "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 "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 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); + } + } + } + } +} diff --git a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs new file mode 100644 index 0000000..245f64c --- /dev/null +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs @@ -0,0 +1,608 @@ +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); + + 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)); + + 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 (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)); + + 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 (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)); + + 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)); + } + + 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)); + + 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 (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)); + + 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)); + + 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 (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 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..e33f83b 100644 --- a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs @@ -68,6 +68,25 @@ 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 "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/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/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/WKT2ProjCrsTests.cs b/test/ProjNet.Tests/WKT/WKT2ProjCrsTests.cs new file mode 100644 index 0000000..f4076c4 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2ProjCrsTests.cs @@ -0,0 +1,80 @@ +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)); + } + + [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)); + } + } +} diff --git a/test/ProjNet.Tests/WKT/WKT2Rgf93Lambert93Tests.cs b/test/ProjNet.Tests/WKT/WKT2Rgf93Lambert93Tests.cs new file mode 100644 index 0000000..7a6dcd7 --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2Rgf93Lambert93Tests.cs @@ -0,0 +1,70 @@ +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)); + } + + [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..41468bc --- /dev/null +++ b/test/ProjNet.Tests/WKT/WKT2RoundTripTests.cs @@ -0,0 +1,79 @@ +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)); + } + + [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); + } + } +} 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)); + } + } +} From 18be43c41c29a53515e08843cb7b9da598494ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20M=C3=BCller?= Date: Fri, 27 Mar 2026 20:28:24 +0100 Subject: [PATCH 2/5] WKT2: Fix CRS object validation and parsing bugs - Add null checks in FromProjNetProjectedCoordinateSystem and FromProjNetGeographicCoordinateSystem to catch invalid inputs early - Add ENGCRS, ENGINEERINGCRS and PARAMETRICCRS support to WKT2 parser - Refactor ReadAbridgedTransformation and ReadConversion: collect parameters, IDs and remarks first, assign to object after full parse - Add explicit check for method name in transformations and conversions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CoordinateSystems/Wkt2/Wkt2Conversions.cs | 6 +++ .../CoordinateSystemWkt2Reader.cs | 43 +++++++++++++------ 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversions.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversions.cs index 40841ad..3902efa 100644 --- a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversions.cs +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversions.cs @@ -61,6 +61,10 @@ public static ProjectedCoordinateSystem ToProjNetProjectedCoordinateSystem(this 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(); @@ -177,6 +181,8 @@ public static GeographicCoordinateSystem ToProjNetGeographicCoordinateSystem(thi 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); diff --git a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs index ca0091a..6286ab0 100644 --- a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs @@ -360,6 +360,13 @@ private static Wkt2CrsBase ReadBoundCrsChildCrs(WktStreamTokenizer tokenizer) case "COMPOUNDCRS": crs = ReadCompoundCrs(element, tokenizer); break; + case "ENGCRS": + case "ENGINEERINGCRS": + crs = ReadEngCrs(element, tokenizer); + break; + case "PARAMETRICCRS": + crs = ReadParametricCrs(element, tokenizer); + break; case ",": break; case "]": @@ -386,7 +393,9 @@ private static Wkt2AbridgedTransformation ReadAbridgedTransformation(WktStreamTo tokenizer.NextToken(); string methodName = null; - var transform = new Wkt2AbridgedTransformation(name, string.Empty); + var parameters = new System.Collections.Generic.List(); + Wkt2Id id = null; + string remark = null; while (true) { @@ -395,24 +404,28 @@ private static Wkt2AbridgedTransformation ReadAbridgedTransformation(WktStreamTo { case "METHOD": methodName = ReadMethodName(tokenizer); - transform = new Wkt2AbridgedTransformation(name, methodName); break; case "PARAMETER": - transform.Parameters.Add(ReadParameter(tokenizer)); + parameters.Add(ReadParameter(tokenizer)); break; case "ID": - transform.Id = ReadId(tokenizer); + id = ReadId(tokenizer); break; case "REMARK": - transform.Remark = ReadRemark(tokenizer); + remark = ReadRemark(tokenizer); break; case ",": break; case "]": case ")": tokenizer.CheckCloser(bracket); - if (string.IsNullOrWhiteSpace(transform.MethodName)) + 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); @@ -731,7 +744,9 @@ private static Wkt2Conversion ReadConversion(WktStreamTokenizer tokenizer) tokenizer.NextToken(); string methodName = null; - var conversion = new Wkt2Conversion(name, string.Empty); + var parameters = new System.Collections.Generic.List(); + Wkt2Id id = null; + string remark = null; while (true) { @@ -740,24 +755,28 @@ private static Wkt2Conversion ReadConversion(WktStreamTokenizer tokenizer) { case "METHOD": methodName = ReadMethodName(tokenizer); - conversion = new Wkt2Conversion(name, methodName); break; case "PARAMETER": - conversion.Parameters.Add(ReadParameter(tokenizer)); + parameters.Add(ReadParameter(tokenizer)); break; case "ID": - conversion.Id = ReadId(tokenizer); + id = ReadId(tokenizer); break; case "REMARK": - conversion.Remark = ReadRemark(tokenizer); + remark = ReadRemark(tokenizer); break; case ",": break; case "]": case ")": tokenizer.CheckCloser(bracket); - if (string.IsNullOrWhiteSpace(conversion.MethodName)) + 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); From a817be3ca03d818c7050aa2717b12d81b13bdc4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20M=C3=BCller?= Date: Fri, 27 Mar 2026 20:28:34 +0100 Subject: [PATCH 3/5] WKT2: Add USAGE/SCOPE/AREA/BBOX metadata storage to model - Create Wkt2BBox class for bounding box data (south/west/north/east) - Create Wkt2Usage class for USAGE elements (scope/area/bbox) - Add Usages property to Wkt2CrsBase - Update reader to parse USAGE, SCOPE, AREA, BBOX in all CRS types - Update writer to serialize usage metadata before ID/REMARK Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CoordinateSystems/Wkt2/Wkt2BBox.cs | 46 +++ .../CoordinateSystems/Wkt2/Wkt2CrsBase.cs | 6 + .../CoordinateSystems/Wkt2/Wkt2Usage.cs | 26 ++ .../CoordinateSystemWkt2Reader.cs | 295 +++++++++++++++++- .../CoordinateSystemWkt2Writer.cs | 53 ++++ 5 files changed, 419 insertions(+), 7 deletions(-) create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2BBox.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2Usage.cs 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/Wkt2CrsBase.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CrsBase.cs index b900790..42a4675 100644 --- a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CrsBase.cs +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2CrsBase.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace ProjNet.CoordinateSystems.Wkt2 { @@ -32,6 +33,11 @@ protected Wkt2CrsBase(string name) /// 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. /// 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/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs index 6286ab0..65ab147 100644 --- a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using ProjNet.CoordinateSystems; using ProjNet.CoordinateSystems.Wkt2; @@ -81,6 +82,7 @@ private static Wkt2EngCrs ReadEngCrs(string keyword, WktStreamTokenizer tokenize Wkt2CoordinateSystem cs = null; Wkt2Id id = null; string remark = null; + var usages = new List(); while (true) { @@ -111,6 +113,31 @@ private static Wkt2EngCrs ReadEngCrs(string keyword, WktStreamTokenizer tokenize 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 "]": @@ -126,6 +153,8 @@ private static Wkt2EngCrs ReadEngCrs(string keyword, WktStreamTokenizer tokenize Id = id, Remark = remark }; + foreach (var u in usages) + crs.Usages.Add(u); return crs; default: SkipUnknownElement(tokenizer); @@ -137,12 +166,13 @@ private static Wkt2EngCrs ReadEngCrs(string keyword, WktStreamTokenizer tokenize private static Wkt2EngineeringDatum ReadEngineeringDatum(string keyword, WktStreamTokenizer tokenizer) { - // EDATUM/DATUM["name", ID[...], REMARK[...]] + // 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) @@ -150,6 +180,11 @@ private static Wkt2EngineeringDatum ReadEngineeringDatum(string keyword, WktStre 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; @@ -161,7 +196,7 @@ private static Wkt2EngineeringDatum ReadEngineeringDatum(string keyword, WktStre case "]": case ")": tokenizer.CheckCloser(bracket); - return new Wkt2EngineeringDatum(keyword, name) { Id = id, Remark = remark }; + return new Wkt2EngineeringDatum(keyword, name) { Id = id, Remark = remark, Anchor = anchor }; default: SkipUnknownElement(tokenizer); break; @@ -183,6 +218,7 @@ private static Wkt2ParametricCrs ReadParametricCrs(string keyword, WktStreamToke Wkt2CoordinateSystem cs = null; Wkt2Id id = null; string remark = null; + var usages = new List(); while (true) { @@ -213,6 +249,31 @@ private static Wkt2ParametricCrs ReadParametricCrs(string keyword, WktStreamToke 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 "]": @@ -228,6 +289,8 @@ private static Wkt2ParametricCrs ReadParametricCrs(string keyword, WktStreamToke Id = id, Remark = remark }; + foreach (var u in usages) + crs.Usages.Add(u); return crs; default: SkipUnknownElement(tokenizer); @@ -239,12 +302,13 @@ private static Wkt2ParametricCrs ReadParametricCrs(string keyword, WktStreamToke private static Wkt2ParametricDatum ReadParametricDatum(string keyword, WktStreamTokenizer tokenizer) { - // PDATUM/DATUM["name", ID[...], REMARK[...]] + // 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) @@ -252,6 +316,11 @@ private static Wkt2ParametricDatum ReadParametricDatum(string keyword, WktStream 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; @@ -263,7 +332,7 @@ private static Wkt2ParametricDatum ReadParametricDatum(string keyword, WktStream case "]": case ")": tokenizer.CheckCloser(bracket); - return new Wkt2ParametricDatum(keyword, name) { Id = id, Remark = remark }; + return new Wkt2ParametricDatum(keyword, name) { Id = id, Remark = remark, Anchor = anchor }; default: SkipUnknownElement(tokenizer); break; @@ -286,6 +355,7 @@ private static Wkt2BoundCrs ReadBoundCrs(string keyword, WktStreamTokenizer toke Wkt2AbridgedTransformation transformation = null; Wkt2Id id = null; string remark = null; + var usages = new List(); while (true) { @@ -307,6 +377,31 @@ private static Wkt2BoundCrs ReadBoundCrs(string keyword, WktStreamTokenizer toke 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 "]": @@ -324,6 +419,8 @@ private static Wkt2BoundCrs ReadBoundCrs(string keyword, WktStreamTokenizer toke Id = id, Remark = remark }; + foreach (var u in usages) + crs.Usages.Add(u); return crs; default: SkipUnknownElement(tokenizer); @@ -447,6 +544,7 @@ private static Wkt2CompoundCrs ReadCompoundCrs(string keyword, WktStreamTokenize var components = new System.Collections.Generic.List(); Wkt2Id id = null; string remark = null; + var usages = new List(); while (true) { @@ -471,6 +569,31 @@ private static Wkt2CompoundCrs ReadCompoundCrs(string keyword, WktStreamTokenize 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 "]": @@ -484,6 +607,8 @@ private static Wkt2CompoundCrs ReadCompoundCrs(string keyword, WktStreamTokenize Id = id, Remark = remark }; + foreach (var u in usages) + crs.Usages.Add(u); return crs; default: SkipUnknownElement(tokenizer); @@ -507,6 +632,7 @@ private static Wkt2VertCrs ReadVertCrs(string keyword, WktStreamTokenizer tokeni Wkt2CoordinateSystem cs = null; Wkt2Id id = null; string remark = null; + var usages = new List(); while (true) { @@ -537,6 +663,31 @@ private static Wkt2VertCrs ReadVertCrs(string keyword, WktStreamTokenizer tokeni 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 "]": @@ -553,6 +704,8 @@ private static Wkt2VertCrs ReadVertCrs(string keyword, WktStreamTokenizer tokeni Id = id, Remark = remark }; + foreach (var u in usages) + crs.Usages.Add(u); return crs; default: @@ -566,12 +719,13 @@ private static Wkt2VertCrs ReadVertCrs(string keyword, WktStreamTokenizer tokeni private static Wkt2VerticalDatum ReadVerticalDatum(string keyword, WktStreamTokenizer tokenizer) { - // VDATUM/DATUM["name", ID[...], REMARK[...]] + // 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) @@ -579,6 +733,11 @@ private static Wkt2VerticalDatum ReadVerticalDatum(string keyword, WktStreamToke 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; @@ -590,7 +749,7 @@ private static Wkt2VerticalDatum ReadVerticalDatum(string keyword, WktStreamToke case "]": case ")": tokenizer.CheckCloser(bracket); - return new Wkt2VerticalDatum(keyword, name) { Id = id, Remark = remark }; + return new Wkt2VerticalDatum(keyword, name) { Id = id, Remark = remark, Anchor = anchor }; default: SkipUnknownElement(tokenizer); break; @@ -613,6 +772,7 @@ private static Wkt2ProjCrs ReadProjCrs(string keyword, WktStreamTokenizer tokeni Wkt2CoordinateSystem cs = null; Wkt2Id id = null; string remark = null; + var usages = new List(); while (true) { @@ -650,6 +810,31 @@ private static Wkt2ProjCrs ReadProjCrs(string keyword, WktStreamTokenizer tokeni 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 "]": @@ -667,6 +852,8 @@ private static Wkt2ProjCrs ReadProjCrs(string keyword, WktStreamTokenizer tokeni Id = id, Remark = remark }; + foreach (var u in usages) + crs.Usages.Add(u); return crs; default: SkipUnknownElement(tokenizer); @@ -871,6 +1058,7 @@ private static Wkt2GeogCrs ReadGeogCrs(string keyword, WktStreamTokenizer tokeni Wkt2CoordinateSystem cs = null; Wkt2Id id = null; string remark = null; + var usages = new List(); while (true) { @@ -914,6 +1102,35 @@ private static Wkt2GeogCrs ReadGeogCrs(string keyword, WktStreamTokenizer tokeni 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; @@ -933,6 +1150,8 @@ private static Wkt2GeogCrs ReadGeogCrs(string keyword, WktStreamTokenizer tokeni Id = id, Remark = remark }; + foreach (var u in usages) + crs.Usages.Add(u); return crs; default: @@ -954,6 +1173,7 @@ private static Wkt2GeodeticDatum ReadGeodeticDatum(string keyword, WktStreamToke Wkt2Ellipsoid ellipsoid = null; Wkt2Id id = null; + string anchor = null; while (true) { @@ -964,6 +1184,11 @@ private static Wkt2GeodeticDatum ReadGeodeticDatum(string keyword, WktStreamToke case "SPHEROID": ellipsoid = ReadEllipsoid(tokenizer); break; + case "ANCHOR": + var anchorBracket = tokenizer.ReadOpener(); + anchor = tokenizer.ReadDoubleQuotedWord(); + tokenizer.ReadCloser(anchorBracket); + break; case "ID": id = ReadId(tokenizer); break; @@ -974,7 +1199,7 @@ private static Wkt2GeodeticDatum ReadGeodeticDatum(string keyword, WktStreamToke tokenizer.CheckCloser(bracket); if (ellipsoid == null) throw new ArgumentException("DATUM/TRF missing ELLIPSOID."); - return new Wkt2GeodeticDatum(keyword, name, ellipsoid) { Id = id }; + return new Wkt2GeodeticDatum(keyword, name, ellipsoid) { Id = id, Anchor = anchor }; default: SkipUnknownElement(tokenizer); break; @@ -1240,6 +1465,62 @@ private static string ReadRemark(WktStreamTokenizer tokenizer) 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() == ",") diff --git a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs index 245f64c..39d5252 100644 --- a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs @@ -49,6 +49,9 @@ private static string WriteEngCrs(Wkt2EngCrs crs) sb.Append(','); sb.Append(WriteCs(crs.CoordinateSystem)); + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + if (crs.Id != null) { sb.Append(','); @@ -74,6 +77,9 @@ private static string WriteEngineeringDatum(Wkt2EngineeringDatum datum) sb.Append(EscapeQuotedText(datum.Name)); sb.Append('"'); + if (!string.IsNullOrWhiteSpace(datum.Anchor)) + sb.Append($",ANCHOR[\"{EscapeQuotedText(datum.Anchor)}\"]"); + if (datum.Id != null) { sb.Append(','); @@ -103,6 +109,9 @@ private static string WriteParametricCrs(Wkt2ParametricCrs crs) sb.Append(','); sb.Append(WriteCs(crs.CoordinateSystem)); + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + if (crs.Id != null) { sb.Append(','); @@ -128,6 +137,9 @@ private static string WriteParametricDatum(Wkt2ParametricDatum datum) sb.Append(EscapeQuotedText(datum.Name)); sb.Append('"'); + if (!string.IsNullOrWhiteSpace(datum.Anchor)) + sb.Append($",ANCHOR[\"{EscapeQuotedText(datum.Anchor)}\"]"); + if (datum.Id != null) { sb.Append(','); @@ -163,6 +175,9 @@ private static string WriteBoundCrs(Wkt2BoundCrs crs) sb.Append(WriteAbridgedTransformation(crs.Transformation)); + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + if (crs.Id != null) { sb.Append(','); @@ -227,6 +242,9 @@ private static string WriteCompoundCrs(Wkt2CompoundCrs crs) sb.Append(Write(component)); } + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + if (crs.Id != null) { sb.Append(','); @@ -256,6 +274,9 @@ private static string WriteVertCrs(Wkt2VertCrs crs) sb.Append(','); sb.Append(WriteCs(crs.CoordinateSystem)); + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + if (crs.Id != null) { sb.Append(','); @@ -281,6 +302,9 @@ private static string WriteVerticalDatum(Wkt2VerticalDatum datum) sb.Append(EscapeQuotedText(datum.Name)); sb.Append("\""); + if (!string.IsNullOrWhiteSpace(datum.Anchor)) + sb.Append($",ANCHOR[\"{EscapeQuotedText(datum.Anchor)}\"]"); + if (datum.Id != null) { sb.Append(','); @@ -313,6 +337,9 @@ private static string WriteProjCrs(Wkt2ProjCrs crs) sb.Append(','); sb.Append(WriteCs(crs.CoordinateSystem)); + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + if (crs.Id != null) { sb.Append(','); @@ -400,6 +427,9 @@ private static string WriteGeogCrs(Wkt2GeogCrs crs) sb.Append(','); sb.Append(WriteCs(crs.CoordinateSystem)); + foreach (var usage in crs.Usages) + sb.Append($",{WriteUsage(usage)}"); + if (crs.Id != null) { sb.Append(','); @@ -427,6 +457,9 @@ private static string WriteDatum(Wkt2GeodeticDatum datum) sb.Append("\","); sb.Append(WriteEllipsoid(datum.Ellipsoid)); + if (!string.IsNullOrWhiteSpace(datum.Anchor)) + sb.Append($",ANCHOR[\"{EscapeQuotedText(datum.Anchor)}\"]"); + if (datum.Id != null) { sb.Append(','); @@ -599,6 +632,26 @@ private static string WriteId(Wkt2Id id) 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 EscapeQuotedText(string text) { // WKT2 escapes a quote inside a quoted string as double quote. From 896dbb66a1bf05bdbc9eadf926eeeabda4aca1fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20M=C3=BCller?= Date: Fri, 27 Mar 2026 20:28:45 +0100 Subject: [PATCH 4/5] WKT2: Add TIMECRS and DERIVEDGEOGCRS CRS types - Wkt2TemporalDatum: temporal datum model (TDATUM/TIMEDATUM) with Calendar, TimeOrigin, Id, and Remark support - Wkt2TimeCrs: temporal CRS model (TIMECRS) with datum and CS - Wkt2DerivedGeogCrs: derived geographic CRS model (DERIVEDGEOGCRS) with base GEOGCRS, deriving conversion, and CS - Reader: ParseCrs dispatch, ReadTemporalDatum, ReadTimeCrs, ReadDerivedGeogCrs, plus BoundCrs/CompoundCrs child support - Writer: WriteTemporalDatum, WriteTimeCrs, WriteDerivedGeogCrs - WktReader: TIMECRS/DERIVEDGEOGCRS keyword dispatch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Wkt2/Wkt2DerivedGeogCrs.cs | 56 ++++ .../Wkt2/Wkt2TemporalDatum.cs | 52 +++ .../CoordinateSystems/Wkt2/Wkt2TimeCrs.cs | 49 +++ .../CoordinateSystemWkt2Reader.cs | 307 +++++++++++++++++- .../CoordinateSystemWkt2Writer.cs | 130 ++++++++ .../CoordinateSystemWktReader.cs | 4 + 6 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2DerivedGeogCrs.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2TemporalDatum.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2TimeCrs.cs 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/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/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs index 65ab147..b55ed47 100644 --- a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs @@ -45,6 +45,10 @@ public static Wkt2CrsBase ParseCrs(string wkt) return ReadEngCrs(rootKeyword, tokenizer); case "PARAMETRICCRS": return ReadParametricCrs(rootKeyword, tokenizer); + case "TIMECRS": + return ReadTimeCrs(rootKeyword, tokenizer); + case "DERIVEDGEOGCRS": + return ReadDerivedGeogCrs(rootKeyword, tokenizer); default: throw new ArgumentException($"'{rootKeyword}' is not recognized as a supported WKT2 CRS."); } @@ -102,6 +106,8 @@ private static Wkt2EngCrs ReadEngCrs(string keyword, WktStreamTokenizer tokenize cs.Axes.Add(ReadAxis(tokenizer)); break; case "LENGTHUNIT": + case "SCALEUNIT": + case "TIMEUNIT": case "UNIT": if (cs == null) cs = new Wkt2CoordinateSystem("cartesian", 2); @@ -238,6 +244,8 @@ private static Wkt2ParametricCrs ReadParametricCrs(string keyword, WktStreamToke cs.Axes.Add(ReadAxis(tokenizer)); break; case "PARAMETRICUNIT": + case "SCALEUNIT": + case "TIMEUNIT": case "UNIT": if (cs == null) cs = new Wkt2CoordinateSystem("parametric", 1); @@ -464,6 +472,12 @@ private static Wkt2CrsBase ReadBoundCrsChildCrs(WktStreamTokenizer tokenizer) case "PARAMETRICCRS": crs = ReadParametricCrs(element, tokenizer); break; + case "TIMECRS": + crs = ReadTimeCrs(element, tokenizer); + break; + case "DERIVEDGEOGCRS": + crs = ReadDerivedGeogCrs(element, tokenizer); + break; case ",": break; case "]": @@ -563,6 +577,19 @@ private static Wkt2CompoundCrs ReadCompoundCrs(string keyword, WktStreamTokenize 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; @@ -652,6 +679,8 @@ private static Wkt2VertCrs ReadVertCrs(string keyword, WktStreamTokenizer tokeni cs.Axes.Add(ReadAxis(tokenizer)); break; case "LENGTHUNIT": + case "SCALEUNIT": + case "TIMEUNIT": case "UNIT": if (cs == null) cs = new Wkt2CoordinateSystem("vertical", 1); @@ -799,6 +828,8 @@ private static Wkt2ProjCrs ReadProjCrs(string keyword, WktStreamTokenizer tokeni cs.Axes.Add(ReadAxis(tokenizer)); break; case "LENGTHUNIT": + case "SCALEUNIT": + case "TIMEUNIT": case "UNIT": if (cs == null) cs = new Wkt2CoordinateSystem("cartesian", 2); @@ -885,6 +916,7 @@ private static Wkt2GeogCrs ReadBaseGeogCrs(WktStreamTokenizer tokenizer) case "DATUM": case "TRF": case "GEODETICDATUM": + case "DYNAMICDATUM": datum = ReadGeodeticDatum(element, tokenizer); break; case "PRIMEM": @@ -973,6 +1005,263 @@ private static Wkt2Conversion ReadConversion(WktStreamTokenizer tokenizer) } } + 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[...], ...] @@ -1068,6 +1357,7 @@ private static Wkt2GeogCrs ReadGeogCrs(string keyword, WktStreamTokenizer tokeni case "DATUM": case "TRF": case "GEODETICDATUM": + case "DYNAMICDATUM": datum = ReadGeodeticDatum(element, tokenizer); break; @@ -1088,6 +1378,8 @@ private static Wkt2GeogCrs ReadGeogCrs(string keyword, WktStreamTokenizer tokeni case "ANGLEUNIT": case "LENGTHUNIT": + case "SCALEUNIT": + case "TIMEUNIT": case "UNIT": if (cs == null) cs = new Wkt2CoordinateSystem("ellipsoidal", 2); @@ -1174,6 +1466,7 @@ private static Wkt2GeodeticDatum ReadGeodeticDatum(string keyword, WktStreamToke Wkt2Ellipsoid ellipsoid = null; Wkt2Id id = null; string anchor = null; + double? frameEpoch = null; while (true) { @@ -1189,6 +1482,12 @@ private static Wkt2GeodeticDatum ReadGeodeticDatum(string keyword, WktStreamToke 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; @@ -1199,7 +1498,7 @@ private static Wkt2GeodeticDatum ReadGeodeticDatum(string keyword, WktStreamToke tokenizer.CheckCloser(bracket); if (ellipsoid == null) throw new ArgumentException("DATUM/TRF missing ELLIPSOID."); - return new Wkt2GeodeticDatum(keyword, name, ellipsoid) { Id = id, Anchor = anchor }; + return new Wkt2GeodeticDatum(keyword, name, ellipsoid) { Id = id, Anchor = anchor, FrameEpoch = frameEpoch }; default: SkipUnknownElement(tokenizer); break; @@ -1230,6 +1529,8 @@ private static Wkt2Ellipsoid ReadEllipsoid(WktStreamTokenizer tokenizer) switch (element.ToUpperInvariant()) { case "LENGTHUNIT": + case "SCALEUNIT": + case "TIMEUNIT": case "UNIT": lengthUnit = ReadUnit(element, tokenizer); break; @@ -1269,6 +1570,8 @@ private static Wkt2PrimeMeridian ReadPrimeMeridian(WktStreamTokenizer tokenizer) switch (element.ToUpperInvariant()) { case "ANGLEUNIT": + case "SCALEUNIT": + case "TIMEUNIT": case "UNIT": unit = ReadUnit(element, tokenizer); break; @@ -1347,6 +1650,8 @@ private static Wkt2Axis ReadAxis(WktStreamTokenizer tokenizer) } case "ANGLEUNIT": case "LENGTHUNIT": + case "SCALEUNIT": + case "TIMEUNIT": case "UNIT": unit = ReadUnit(element, tokenizer); break; diff --git a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs index 39d5252..06afc1f 100644 --- a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs @@ -33,6 +33,10 @@ public static string Write(Wkt2CrsBase crs) 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); throw new NotSupportedException($"WKT2 writer does not support '{crs.GetType().Name}'."); } @@ -460,6 +464,9 @@ private static string WriteDatum(Wkt2GeodeticDatum datum) 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(','); @@ -652,6 +659,129 @@ private static string WriteUsage(Wkt2Usage usage) 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 EscapeQuotedText(string text) { // WKT2 escapes a quote inside a quoted string as double quote. diff --git a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs index e33f83b..96f3a38 100644 --- a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs @@ -87,6 +87,10 @@ public static IInfo Parse(string wkt) return CoordinateSystemWkt2Reader.Parse(wkt); case "PARAMETRICCRS": return CoordinateSystemWkt2Reader.Parse(wkt); + case "TIMECRS": + return CoordinateSystemWkt2Reader.Parse(wkt); + case "DERIVEDGEOGCRS": + return CoordinateSystemWkt2Reader.Parse(wkt); case "UNIT": return ReadUnit(tokenizer); case "SPHEROID": From 084175aeb73c94f8599bb6b599313c56b1448f07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20M=C3=BCller?= Date: Fri, 27 Mar 2026 20:28:55 +0100 Subject: [PATCH 5/5] WKT2: Add CONCATENATEDOPERATION, extended mapping and tests - Parser and writer support CONCATENATEDOPERATION including model classes - Projection method mapping extended to 30 WKT2-to-ProjNet and 25 ProjNet-to-WKT2 entries with round-trip capability - Datum models extended with Anchor and FrameEpoch properties - Parser recognizes alternative keywords and bracket styles - Comprehensive new tests: concatenated operations, DerivedGeogCRS, TimeCRS, projection mapping, usage/remark metadata, error handling, writer round-trips, transform accuracy, and parser edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Wkt2/Wkt2ConcatenatedOperation.cs | 52 ++++ .../CoordinateSystems/Wkt2/Wkt2Conversions.cs | 94 +++++- .../Wkt2/Wkt2CoordinateOperation.cs | 68 +++++ .../Wkt2/Wkt2EngineeringDatum.cs | 5 + .../Wkt2/Wkt2GeodeticDatum.cs | 10 + .../Wkt2/Wkt2ParametricDatum.cs | 5 + .../Wkt2/Wkt2VerticalDatum.cs | 5 + .../CoordinateSystemWkt2Reader.cs | 203 +++++++++++++ .../CoordinateSystemWkt2Writer.cs | 123 ++++++++ .../CoordinateSystemWktReader.cs | 2 + .../WKT/WKT2ConcatenatedOperationTests.cs | 268 ++++++++++++++++++ .../WKT/WKT2DerivedGeogCrsTests.cs | 162 +++++++++++ .../WKT/WKT2ErrorHandlingTests.cs | 76 +++++ .../WKT/WKT2ParserEdgeCaseTests.cs | 184 ++++++++++++ test/ProjNet.Tests/WKT/WKT2ProjCrsTests.cs | 16 ++ .../WKT/WKT2ProjectionMappingTests.cs | 111 ++++++++ .../WKT/WKT2Rgf93Lambert93Tests.cs | 7 +- test/ProjNet.Tests/WKT/WKT2RoundTripTests.cs | 15 + test/ProjNet.Tests/WKT/WKT2TimeCrsTests.cs | 181 ++++++++++++ .../WKT/WKT2TransformAccuracyTests.cs | 152 ++++++++++ .../WKT/WKT2UsageMetadataTests.cs | 118 ++++++++ test/ProjNet.Tests/WKT/WKT2WriterTests.cs | 144 ++++++++++ 22 files changed, 1992 insertions(+), 9 deletions(-) create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2ConcatenatedOperation.cs create mode 100644 src/ProjNet/CoordinateSystems/Wkt2/Wkt2CoordinateOperation.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2ConcatenatedOperationTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2DerivedGeogCrsTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2ErrorHandlingTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2ParserEdgeCaseTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2ProjectionMappingTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2TimeCrsTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2TransformAccuracyTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2UsageMetadataTests.cs create mode 100644 test/ProjNet.Tests/WKT/WKT2WriterTests.cs 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/Wkt2Conversions.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversions.cs index 3902efa..344ef65 100644 --- a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversions.cs +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2Conversions.cs @@ -68,7 +68,7 @@ public static Wkt2ProjCrs FromProjNetProjectedCoordinateSystem(this ProjectedCoo var baseCrs = pcs.GeographicCoordinateSystem.FromProjNetGeographicCoordinateSystem(); - var conversion = new Wkt2Conversion(pcs.Projection.Name, pcs.Projection.ClassName); + 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); @@ -211,20 +211,98 @@ public static Wkt2GeogCrs FromProjNetGeographicCoordinateSystem(this GeographicC return crs; } - private static string MapProjectionMethodName(string wkt2Method) + 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; + } - // Most common mappings for EPSG exports. - if (m.Equals("Transverse Mercator", StringComparison.OrdinalIgnoreCase)) return "Transverse_Mercator"; - if (m.Equals("Mercator", StringComparison.OrdinalIgnoreCase)) return "Mercator_1SP"; - if (m.Equals("Lambert Conic Conformal (2SP)", StringComparison.OrdinalIgnoreCase)) return "lambert_conformal_conic_2sp"; + 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 ""; - // Fallback: keep original. - return m; + 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/Wkt2EngineeringDatum.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2EngineeringDatum.cs index 2c8386d..add4667 100644 --- a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2EngineeringDatum.cs +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2EngineeringDatum.cs @@ -29,6 +29,11 @@ public Wkt2EngineeringDatum(string keyword, string 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. /// diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2GeodeticDatum.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2GeodeticDatum.cs index 93a3aa4..26ab2bd 100644 --- a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2GeodeticDatum.cs +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2GeodeticDatum.cs @@ -36,6 +36,16 @@ public Wkt2GeodeticDatum(string keyword, string name, Wkt2Ellipsoid 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. /// diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ParametricDatum.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ParametricDatum.cs index 9802cf0..0703136 100644 --- a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ParametricDatum.cs +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2ParametricDatum.cs @@ -29,6 +29,11 @@ public Wkt2ParametricDatum(string keyword, string 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. /// diff --git a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2VerticalDatum.cs b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2VerticalDatum.cs index 59a4548..07aed6a 100644 --- a/src/ProjNet/CoordinateSystems/Wkt2/Wkt2VerticalDatum.cs +++ b/src/ProjNet/CoordinateSystems/Wkt2/Wkt2VerticalDatum.cs @@ -29,6 +29,11 @@ public Wkt2VerticalDatum(string keyword, string 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. /// diff --git a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs index b55ed47..fd5c880 100644 --- a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Reader.cs @@ -49,6 +49,8 @@ public static Wkt2CrsBase ParseCrs(string wkt) 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."); } @@ -1855,5 +1857,206 @@ private static void SkipUnknownElement(WktStreamTokenizer tokenizer) } } } + + 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 index 06afc1f..4031291 100644 --- a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWkt2Writer.cs @@ -37,6 +37,8 @@ public static string Write(Wkt2CrsBase crs) 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}'."); } @@ -782,6 +784,127 @@ private static string WriteDerivedGeogCrs(Wkt2DerivedGeogCrs crs) 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. diff --git a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs index 96f3a38..396a06a 100644 --- a/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs +++ b/src/ProjNet/IO/CoordinateSystems/CoordinateSystemWktReader.cs @@ -91,6 +91,8 @@ public static IInfo Parse(string wkt) 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/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/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/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 index f4076c4..4b895d3 100644 --- a/test/ProjNet.Tests/WKT/WKT2ProjCrsTests.cs +++ b/test/ProjNet.Tests/WKT/WKT2ProjCrsTests.cs @@ -49,6 +49,13 @@ public void ParseWkt2ProjCrsToProjNetProducesProjectedCs() 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] @@ -75,6 +82,15 @@ public void ProjNetProjectedToWkt2ToProjNetRoundTripPreservesCoreParams() 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 index 7a6dcd7..899b254 100644 --- a/test/ProjNet.Tests/WKT/WKT2Rgf93Lambert93Tests.cs +++ b/test/ProjNet.Tests/WKT/WKT2Rgf93Lambert93Tests.cs @@ -26,7 +26,7 @@ public class WKT2Rgf93Lambert93Tests "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).\"]," + + "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]]"; @@ -53,6 +53,11 @@ public void ParseWkt2ProjCrsToProjNetProducesProjectedCs() 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] diff --git a/test/ProjNet.Tests/WKT/WKT2RoundTripTests.cs b/test/ProjNet.Tests/WKT/WKT2RoundTripTests.cs index 41468bc..baa630f 100644 --- a/test/ProjNet.Tests/WKT/WKT2RoundTripTests.cs +++ b/test/ProjNet.Tests/WKT/WKT2RoundTripTests.cs @@ -40,6 +40,15 @@ public void ParseWkt2ToProjNetNormalizesToLonLat2D() 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] @@ -74,6 +83,12 @@ public void ProjNetToWkt2ToProjNetRoundTripPreservesCoreParams() 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/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\"]")); + } +}