Repository: GEOSwift/GEOSwift (swift)

Issue:
Add QoL improvements for deserializing geometry
While the changes in #296 don't add much generic friction in most places, decoding geometry is one notable exception. Currently you must specialize the generic geometry type explicitly in the function call e.g. `Geometry<XY>(wkb: ...)` or  `decoder.decode(Geometry<XY>.self)`. It would be nice to have a more ergonomic API or even provide a wrapper that can handle multiple different coordinate types if you don't know what a source is going to give you.

Pull request:
Closes #299 

In order to improve the developer experience in dealing with GeoJSON with unknown or mixed dimensionality, I evaluated 2 approaches:
1. The GeoJSON equivalent of #306 via adding `Codable` support to `AnyGeometry`
2. Adding a `userInfo` flag on the `Decoder` that allowed `XY` coordinates to be decoded as `XYZ` with a `nan` value for Z

I went with option 2 because:
* M coordinates aren't compatible with GeoJSON decoding, which would be confusing with `AnyGeometry`.
* The decoder flag allows you to handle nested JSON better by setting the flag at the level you need and using stricter typed decoding where you want that. The `AnyGeometry` approach would be `AnyGeometry` all the way down.
* This is more idiomatic for `Codable` usage

It looks like this:
```swift
var decoder = JSONDecoder()
let json = #"{"coordinates":[1,2],"type":"Point"}"#
let data = json.data(using: .utf8)!

let geometryXY = try decoder.decode(Geometry<XY>.self, from: data) // This works
let geometryXYZ = try decoder.decode(Geometry<XYZ>.self, from: data) // This throws an error because no Z value

decoder.userInfo[.geoJSONSetMissingZNan] = true
let geometryXYNan = try decoder.decode(Geometry<XYZ>.self, from: data) // This gives a NaN value for Z

let jsonWithZ = #"{"coordinates":[1,2,3],"type":"Point"}"#
let dataWithZ = jsonWithZ.data(using: .utf8)!

let geometryDropZ = try decoder.decode(Geometry<XY>.self, from: dataWithZ) // Works and drops the Z coordinate
```

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1856788..a90b8a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
 * Add documentation to core types.
 * Add `WKBReader.readAny(wkb:)` to read `AnyGeometry` types when the `CoordinateType` is unknown.
 * Add `WKTReader.readAny(wkt:)` to read `AnyGeometry` types when the `CoordinateType` is unknown.
+* Add `CodingUserInfo.geoJSONSetMissingZNan` to control the behavior or missing Z coordinates when decoding GeoJSON.
 
 ## 11.2.0
 
diff --git a/README.md b/README.md
index 5507da1..c4e315b 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,8 @@ In certain cases, you may also need to explicitly include
 
 ### Geometry creation
 
+#### From WKB/WKT
+
 ```swift
 // If you know the expected geometry coordinate types, you can decode directly from the Geometry<> type
 let geometryXY = try Geometry<XY>(wkb: wkb) // All valid geometries will successfully decode as XY
@@ -79,20 +81,41 @@ default:
 }
 ```
 
-#### From GeoJSON
+#### From GeoJSON (via `Codable`)
+
+When decoding from GeoJSON, you must specify the expected `CoordinateType` of the geometry.
+The two compatible `CoordinateType`s are `XY` and `XYZ`. GeoJSON does not support M coordinates. Decoding as
+`Geometry<XY>` will drop any Z values present. The behavior of decoding as `Geometry<XYZ>` depends on
+the `CodingUserInfo.geoJSONSetMissingZNan` value in the decoder's `userInfo`:
+* **`false`**: (default) Decoding will throw a `GEOSwiftError.invalidCoordinates` if no Z value exists.
+* **`true`**: Any missing Z values will be treated as `Double.nan`.
+
+This setting is useful if you are dealing with geometry with unknown dimensionality and you want to preserve Z
+where possible, or if you have geometry with mixed dimensionality.
+
+> [!NOTE]
+> Take special care working with `Double.nan` in Swift. It can often produce unexpected results such as 
+> `Double.nan != Double.nan` which may interfere with equality checks. Converting the geometry to `XY` using
+> the conversion initializers (e.g. `let geometryXY = Geometry<XY>(geometry)`) is a safe way to mitigate this
+> when Z is not needed. The underlying GEOS operations generally handle Z == NaN cases fine since Z is not
+> considered in topological operations.
 
 ```swift
 // When decoding GeoJSON, you must explicitly declare the expected geometry coordinate type (e.g. GeoJSON<XY>)
+var decoder = JSONDecoder()
+let json = #"{"coordinates":[1,2],"type":"Point"}"#
+let data = json.data(using: .utf8)!
 
-let decoder = JSONDecoder()
-if let geoJSONURL = Bundle.main.url(forResource: "multipolygon", withExtension: "geojson"),
-    let data = try? Data(contentsOf: geoJSONURL),
-    let geoJSON = try? decoder.decode(GeoJSON<XY>.self, from: data),
-    case let .feature(feature) = geoJSON,
-    let italy = feature.geometry
-{
-    italy
-}
+let geometryXY = try decoder.decode(Geometry<XY>.self, from: data) // This works
+let geometryXYZ = try decoder.decode(Geometry<XYZ>.self, from: data) // This throws an error because no Z value
+
+decoder.userInfo[.geoJSONSetMissingZNan] = true
+let geometryXYNan = try decoder.decode(Geometry<XYZ>.self, from: data) // This gives a NaN value for Z
+
+let jsonWithZ = #"{"coordinates":[1,2,3],"type":"Point"}"#
+let dataWithZ = jsonWithZ.data(using: .utf8)!
+
+let geometryDropZ = try decoder.decode(Geometry<XY>.self, from: dataWithZ) // Drops the Z coordinate
 ```
 
 #### Initializing Geometry types directly
diff --git a/Sources/GEOSwift/Codable/CodableGeometry.swift b/Sources/GEOSwift/Codable/CodableGeometry.swift
index 36ef26a..8ee29dc 100644
--- a/Sources/GEOSwift/Codable/CodableGeometry.swift
+++ b/Sources/GEOSwift/Codable/CodableGeometry.swift
@@ -39,3 +39,9 @@ extension KeyedDecodingContainer {
         return type
     }
 }
+
+public extension CodingUserInfoKey {
+    /// Set this key to `true` in your Decoder's `userInfo` to handle missing Z values as `nan`
+    /// instead of throwing an error.
+    static let geoJSONSetMissingZNan = CodingUserInfoKey(rawValue: "GEOSwift.geoJSONSetMissingZNan")!
+}
diff --git a/Sources/GEOSwift/Core Types/Coordinates/XYZ.swift b/Sources/GEOSwift/Core Types/Coordinates/XYZ.swift
index ee67357..32f74b6 100644
--- a/Sources/GEOSwift/Core Types/Coordinates/XYZ.swift
+++ b/Sources/GEOSwift/Core Types/Coordinates/XYZ.swift
@@ -48,14 +48,17 @@ public struct XYZ: CoordinateType, HasZ, GeoJSONCoordinate {
         let container = try decoder.singleValueContainer()
         let values = try container.decode([Double].self)
 
-        // Allow decoding from XYZ and XYZM coordinates
-        guard values.count == 3 || values.count == 4 else {
+        // Allow decoding from XYZ and XYZM coordinates or XY if setting geoJSONSetMissingZNan = tue
+        let setMissingZNan = decoder.userInfo[.geoJSONSetMissingZNan] as? Bool ?? false
+        let minimumCoordinateCount = setMissingZNan ? 2 : 3
+
+        guard values.count >= minimumCoordinateCount else {
             throw GEOSwiftError.invalidCoordinates
         }
 
         self.x = values[0]
         self.y = values[1]
-        self.z = values[2]
+        self.z = values.indices.contains(2) ? values[2] : .nan
     }
 
     public func encode(to encoder: Encoder) throws {


Test patch:
diff --git a/Tests/GEOSwiftTests/Codable/XYZ+MissingZTests.swift b/Tests/GEOSwiftTests/Codable/XYZ+MissingZTests.swift
new file mode 100644
index 0000000..be61a43
--- /dev/null
+++ b/Tests/GEOSwiftTests/Codable/XYZ+MissingZTests.swift
@@ -0,0 +1,275 @@
+import XCTest
+import GEOSwift
+
+/// Tests for the geoJSONSetMissingZNan functionality that controls whether decoding
+/// XYZ coordinates from GeoJSON without Z values throws an error or sets Z to NaN.
+final class XYZ_MissingZTests: CodableTestCase {
+
+    // MARK: - Point Tests
+
+    func testPointXYZDecodingFromXYThrowsErrorByDefault() {
+        let json = #"{"coordinates":[1,2],"type":"Point"}"#
+
+        verifyDecodable(
+            with: Point<XYZ>.self,
+            json: json,
+            expectedError: .invalidCoordinates)
+    }
+
+    func testPointXYZDecodingFromXYSetsNaNWhenFlagEnabled() {
+        decoder.userInfo[.geoJSONSetMissingZNan] = true
+
+        let json = #"{"coordinates":[1,2],"type":"Point"}"#
+        let data = json.data(using: .utf8)!
+
+        do {
+            let point = try decoder.decode(Point<XYZ>.self, from: data)
+            XCTAssertEqual(point.x, 1.0)
+            XCTAssertEqual(point.y, 2.0)
+            XCTAssertTrue(point.z.isNaN, "Z should be NaN when missing and flag is enabled")
+        } catch {
+            XCTFail("Decoding should succeed when geoJSONSetMissingZNan is true: \(error)")
+        }
+    }
+
+    func testPointXYZDecodingFromXYZWorksWithoutFlag() {
+        let json = #"{"coordinates":[1,2,3],"type":"Point"}"#
+        let data = json.data(using: .utf8)!
+
+        do {
+            let point = try decoder.decode(Point<XYZ>.self, from: data)
+            XCTAssertEqual(point.x, 1.0)
+            XCTAssertEqual(point.y, 2.0)
+            XCTAssertEqual(point.z, 3.0)
+        } catch {
+            XCTFail("Decoding XYZ coordinates should work without flag: \(error)")
+        }
+    }
+
+    func testPointXYZDecodingFromXYZWorksWithFlag() {
+        decoder.userInfo[.geoJSONSetMissingZNan] = true
+
+        let json = #"{"coordinates":[1,2,3],"type":"Point"}"#
+        let data = json.data(using: .utf8)!
+
+        do {
+            let point = try decoder.decode(Point<XYZ>.self, from: data)
+            XCTAssertEqual(point.x, 1.0)
+            XCTAssertEqual(point.y, 2.0)
+            XCTAssertEqual(point.z, 3.0)
+        } catch {
+            XCTFail("Decoding XYZ coordinates should work with flag: \(error)")
+        }
+    }
+
+    func testPointXYZDecodingFromXYZMWorksWithoutFlag() {
+        let json = #"{"coordinates":[1,2,3,4],"type":"Point"}"#
+        let data = json.data(using: .utf8)!
+
+        do {
+            let point = try decoder.decode(Point<XYZ>.self, from: data)
+            XCTAssertEqual(point.x, 1.0)
+            XCTAssertEqual(point.y, 2.0)
+            XCTAssertEqual(point.z, 3.0)
+        } catch {
+            XCTFail("Decoding XYZ from XYZM coordinates should work: \(error)")
+        }
+    }
+
+    func testPointXYZDecodingFromXYZMWorksWithFlag() {
+        decoder.userInfo[.geoJSONSetMissingZNan] = true
+
+        let json = #"{"coordinates":[1,2,3,4],"type":"Point"}"#
+        let data = json.data(using: .utf8)!
+
+        do {
+            let point = try decoder.decode(Point<XYZ>.self, from: data)
+            XCTAssertEqual(point.x, 1.0)
+            XCTAssertEqual(point.y, 2.0)
+            XCTAssertEqual(point.z, 3.0)
+        } catch {
+            XCTFail("Decoding XYZ from XYZM coordinates should work with flag: \(error)")
+        }
+    }
+
+    // MARK: - LineString Tests
+
+    func testLineStringXYZDecodingFromXYThrowsErrorByDefault() {
+        let json = #"{"coordinates":[[1,2],[3,4]],"type":"LineString"}"#
+
+        verifyDecodable(
+            with: LineString<XYZ>.self,
+            json: json,
+            expectedError: .invalidCoordinates)
+    }
+
+    func testLineStringXYZDecodingFromXYSetsNaNWhenFlagEnabled() {
+        decoder.userInfo[.geoJSONSetMissingZNan] = true
+
+        let json = #"{"coordinates":[[1,2],[3,4]],"type":"LineString"}"#
+        let data = json.data(using: .utf8)!
+
+        do {
+            let lineString = try decoder.decode(LineString<XYZ>.self, from: data)
+            XCTAssertEqual(lineString.coordinates.count, 2)
+
+            XCTAssertEqual(lineString.coordinates[0].x, 1.0)
+            XCTAssertEqual(lineString.coordinates[0].y, 2.0)
+            XCTAssertTrue(lineString.coordinates[0].z.isNaN, "Z should be NaN for first coordinate")
+
+            XCTAssertEqual(lineString.coordinates[1].x, 3.0)
+            XCTAssertEqual(lineString.coordinates[1].y, 4.0)
+            XCTAssertTrue(lineString.coordinates[1].z.isNaN, "Z should be NaN for second coordinate")
+        } catch {
+            XCTFail("Decoding should succeed when geoJSONSetMissingZNan is true: \(error)")
+        }
+    }
+
+    func testLineStringXYZDecodingFromXYZWorksWithoutFlag() {
+        let json = #"{"coordinates":[[1,2,3],[4,5,6]],"type":"LineString"}"#
+        let data = json.data(using: .utf8)!
+
+        do {
+            let lineString = try decoder.decode(LineString<XYZ>.self, from: data)
+            XCTAssertEqual(lineString.coordinates.count, 2)
+            XCTAssertEqual(lineString.coordinates[0].x, 1.0)
+            XCTAssertEqual(lineString.coordinates[0].y, 2.0)
+            XCTAssertEqual(lineString.coordinates[0].z, 3.0)
+            XCTAssertEqual(lineString.coordinates[1].x, 4.0)
+            XCTAssertEqual(lineString.coordinates[1].y, 5.0)
+            XCTAssertEqual(lineString.coordinates[1].z, 6.0)
+        } catch {
+            XCTFail("Decoding XYZ coordinates should work without flag: \(error)")
+        }
+    }
+
+    // MARK: - Polygon Tests
+
+    func testPolygonXYZDecodingFromXYThrowsErrorByDefault() {
+        let json = #"{"coordinates":[[[0,0],[4,0],[4,4],[0,4],[0,0]]],"type":"Polygon"}"#
+
+        verifyDecodable(
+            with: Polygon<XYZ>.self,
+            json: json,
+            expectedError: .invalidCoordinates)
+    }
+
+    func testPolygonXYZDecodingFromXYSetsNaNWhenFlagEnabled() {
+        decoder.userInfo[.geoJSONSetMissingZNan] = true
+
+        let json = #"{"coordinates":[[[0,0],[4,0],[4,4],[0,4],[0,0]]],"type":"Polygon"}"#
+        let data = json.data(using: .utf8)!
+
+        do {
+            let polygon = try decoder.decode(Polygon<XYZ>.self, from: data)
+            XCTAssertEqual(polygon.exterior.coordinates.count, 5)
+
+            for (index, coord) in polygon.exterior.coordinates.enumerated() {
+                XCTAssertTrue(coord.z.isNaN, "Z should be NaN for coordinate at index \(index)")
+            }
+        } catch {
+            XCTFail("Decoding should succeed when geoJSONSetMissingZNan is true: \(error)")
+        }
+    }
+
+    func testPolygonXYZDecodingFromXYZWorksWithoutFlag() {
+        let json = #"{"coordinates":[[[0,0,1],[4,0,1],[4,4,1],[0,4,1],[0,0,1]]],"type":"Polygon"}"#
+        let data = json.data(using: .utf8)!
+
+        do {
+            let polygon = try decoder.decode(Polygon<XYZ>.self, from: data)
+            XCTAssertEqual(polygon.exterior.coordinates.count, 5)
+
+            for coord in polygon.exterior.coordinates {
+                XCTAssertEqual(coord.z, 1.0)
+            }
+        } catch {
+            XCTFail("Decoding XYZ coordinates should work without flag: \(error)")
+        }
+    }
+
+    // MARK: - MultiPoint Tests
+
+    func testMultiPointXYZDecodingFromXYThrowsErrorByDefault() {
+        let json = #"{"coordinates":[[1,2],[3,4]],"type":"MultiPoint"}"#
+
+        verifyDecodable(
+            with: MultiPoint<XYZ>.self,
+            json: json,
+            expectedError: .invalidCoordinates)
+    }
+
+    func testMultiPointXYZDecodingFromXYSetsNaNWhenFlagEnabled() {
+        decoder.userInfo[.geoJSONSetMissingZNan] = true
+
+        let json = #"{"coordinates":[[1,2],[3,4]],"type":"MultiPoint"}"#
+        let data = json.data(using: .utf8)!
+
+        do {
+            let multiPoint = try decoder.decode(MultiPoint<XYZ>.self, from: data)
+            XCTAssertEqual(multiPoint.points.count, 2)
+
+            XCTAssertEqual(multiPoint.points[0].x, 1.0)
+            XCTAssertEqual(multiPoint.points[0].y, 2.0)
+            XCTAssertTrue(multiPoint.points[0].z.isNaN)
+
+            XCTAssertEqual(multiPoint.points[1].x, 3.0)
+            XCTAssertEqual(multiPoint.points[1].y, 4.0)
+            XCTAssertTrue(multiPoint.points[1].z.isNaN)
+        } catch {
+            XCTFail("Decoding should succeed when geoJSONSetMissingZNan is true: \(error)")
+        }
+    }
+
+    // MARK: - Flag Value Tests
+
+    func testFlagSetToFalseThrowsError() {
+        decoder.userInfo[.geoJSONSetMissingZNan] = false
+
+        let json = #"{"coordinates":[1,2],"type":"Point"}"#
+
+        verifyDecodable(
+            with: Point<XYZ>.self,
+            json: json,
+            expectedError: .invalidCoordinates)
+    }
+
+    func testFlagWithNonBoolValueUsesDefaultBehavior() {
+        decoder.userInfo[.geoJSONSetMissingZNan] = "true" // String instead of Bool
+
+        let json = #"{"coordinates":[1,2],"type":"Point"}"#
+
+        verifyDecodable(
+            with: Point<XYZ>.self,
+            json: json,
+            expectedError: .invalidCoordinates)
+    }
+
+    // MARK: - Mixed Coordinates Test
+
+    func testLineStringWithMixedXYAndXYZCoordinatesWhenFlagEnabled() {
+        decoder.userInfo[.geoJSONSetMissingZNan] = true
+
+        // This tests that we can handle a linestring where some points have Z and others don't
+        // when the flag is enabled
+        let json = #"{"coordinates":[[1,2],[3,4,5]],"type":"LineString"}"#
+        let data = json.data(using: .utf8)!
+
+        do {
+            let lineString = try decoder.decode(LineString<XYZ>.self, from: data)
+            XCTAssertEqual(lineString.coordinates.count, 2)
+
+            // First point should have NaN for Z
+            XCTAssertEqual(lineString.coordinates[0].x, 1.0)
+            XCTAssertEqual(lineString.coordinates[0].y, 2.0)
+            XCTAssertTrue(lineString.coordinates[0].z.isNaN)
+
+            // Second point should have actual Z value
+            XCTAssertEqual(lineString.coordinates[1].x, 3.0)
+            XCTAssertEqual(lineString.coordinates[1].y, 4.0)
+            XCTAssertEqual(lineString.coordinates[1].z, 5.0)
+        } catch {
+            XCTFail("Decoding mixed coordinates should work with flag: \(error)")
+        }
+    }
+}