Repository: OSGeo/PROJ (C++)

Issue:
Issue with vertical unit transformation between 2 compound CRS, one of them with a TOWGS84 clause
### What is the bug?

In the repex below x and y are transformed as expected but z values are passed through from NAVD88 height (ftUS) (EPSG:6360) without conversion to NAVD88 height (EPSG:5703), which is in meters. Preferably conversion would be performed but, at the minimum, `CoordinateTransformation.TransformPoints()` should throw and `TransformPointWithErrorCode()` return an error if they can't perform the requested transform.

### Steps to reproduce the issue

```C#
SpatialReference sourceCrs = new("COMPD_CS[\"NAD83(HARN) / Washington South (ftUS) + NAVD88 height (ftUS) - Geoid18 (ftUS)\",PROJCS[\"NAD83(HARN) / Washington South (ftUS)\",GEOGCS[\"NAD83(HARN)\",DATUM[\"NAD83 (High Accuracy Reference Network)\",SPHEROID[\"GRS 1980\",6378137,298.257222101,AUTHORITY[\"EPSG\",\"7019\"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY[\"EPSG\",\"6152\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4152\"]],PROJECTION[\"Lambert_Conformal_Conic_2SP\"],PARAMETER[\"standard_parallel_1\",47.33333333333334],PARAMETER[\"standard_parallel_2\",45.83333333333334],PARAMETER[\"latitude_of_origin\",45.33333333333334],PARAMETER[\"central_meridian\",-120.5],PARAMETER[\"false_easting\",1640416.667],PARAMETER[\"false_northing\",0],UNIT[\"US survey foot\",0.3048006096012192,AUTHORITY[\"EPSG\",\"9003\"]],AXIS[\"X\",EAST],AXIS[\"Y\",NORTH],AUTHORITY[\"EPSG\",\"2927\"]],VERT_CS[\"NAVD88 height (ftUS) - Geoid18 (ftUS)\",VERT_DATUM[\"North American Vertical Datum 1988\",2005,AUTHORITY[\"EPSG\",\"5103\"]],UNIT[\"US survey foot\",0.3048006096012192,AUTHORITY[\"EPSG\",\"9003\"]],AXIS[\"Up\",UP],AUTHORITY[\"EPSG\",\"6360\"]]]");

SpatialReference destinationProjectedCrs = new(String.Empty);
destinationProjectedCrs.ImportFromEpsg(32610);
SpatialReference destinationVerticalCrs = new(String.Empty);
destinationVerticalCrs.ImportFromEpsg(5703);
SpatialReference destinationCrs = new(String.Empty);
CPLErr gdalError = (CPLErr)destinationCrs.SetCompoundCS(destinationProjectedCrs.GetName() + " + " + destinationVerticalCrs.GetName(), destinationProjectedCrs, destinationVerticalCrs);
Assert.IsTrue(gdalError == CPLErr.CE_None);

CoordinateTransformation transform = new(sourceCrs, destinationCrs, new());
double[] x = [ 1231079.0 ];
double[] y = [ 204775.0 ];
double[] z = [ 3280.84 ];
transform.TransformPoints(1, x, y, z);
Assert.IsTrue(z[0] == 1000.0);

double[] point = new double[3];
gdalError = (CPLErr)transform.TransformPointWithErrorCode(point, 1231079.0, 204775.0, 3280.84, 0.0);
Assert.IsTrue((gdalError != CPLErr.CE_None) || (point[2] == 1000.0));
```

### Versions and provenance

Windows 11 24H2 with MaxRev.Gdal.Core 3.11.1.337 and MaxRev.Gdal.WindowsRuntime.Minimal 3.11.1.337 nugets. Also happens on the 3.11.0.335 MaxRev builds.

### Additional context

In my case special casing using `SpatialReference.GetAuthorityCode("VERT_CS")` to detect these two specific EPSGs and then fix up z values is a viable workaround. There's enough vertical coordinate systems this approach scales poorly, though, particularly where the geoid differs.

From GDAL's C# API it doesn't look like there's a way to probe what's happening with PROJ but [#1773](https://github.com/OSGeo/gdal/issues/1773) notes a similar issue in the GDAL 2.3 and PROJ 5/6 era.


Discussion:
Can be reproduced with PROJ:
```
$ bin/projinfo -s "COMPD_CS[\"NAD83(HARN) / Washington South (ftUS) + NAVD88 height (ftUS) - Geoid18 (ftUS)\",PROJCS[\"NAD83(HARN) / Washington South (ftUS)\",GEOGCS[\"NAD83(HARN)\",DATUM[\"NAD83 (High Accuracy Reference Network)\",SPHEROID[\"GRS 1980\",6378137,298.257222101,AUTHORITY[\"EPSG\",\"7019\"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY[\"EPSG\",\"6152\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4152\"]],PROJECTION[\"Lambert_Conformal_Conic_2SP\"],PARAMETER[\"standard_parallel_1\",47.33333333333334],PARAMETER[\"standard_parallel_2\",45.83333333333334],PARAMETER[\"latitude_of_origin\",45.33333333333334],PARAMETER[\"central_meridian\",-120.5],PARAMETER[\"false_easting\",1640416.667],PARAMETER[\"false_northing\",0],UNIT[\"US survey foot\",0.3048006096012192,AUTHORITY[\"EPSG\",\"9003\"]],AXIS[\"X\",EAST],AXIS[\"Y\",NORTH],AUTHORITY[\"EPSG\",\"2927\"]],VERT_CS[\"NAVD88 height (ftUS) - Geoid18 (ftUS)\",VERT_DATUM[\"North American Vertical Datum 1988\",2005,AUTHORITY[\"EPSG\",\"5103\"]],UNIT[\"US survey foot\",0.3048006096012192,AUTHORITY[\"EPSG\",\"9003\"]],AXIS[\"Up\",UP],AUTHORITY[\"EPSG\",\"6360\"]]]" -t EPSG:32610+5703 --grid-check none
Candidate operations found: 1
Note: using '--spatial-test intersects' would bring more results (330)
-------------------------------------
Operation No. 1:

unknown id, Inverse of unnamed + Transformation from NAD83(HARN) to WGS84 + UTM zone 10N, unknown accuracy, World

PROJ string:
+proj=pipeline
  +step +proj=unitconvert +xy_in=us-ft +xy_out=m
  +step +inv +proj=lcc +lat_0=45.3333333333333 +lon_0=-120.5
        +lat_1=47.3333333333333 +lat_2=45.8333333333333 +x_0=500000.0001016
        +y_0=0 +ellps=GRS80
  +step +proj=utm +zone=10 +ellps=WGS84

```



Patch:
diff --git a/src/iso19111/operation/coordinateoperationfactory.cpp b/src/iso19111/operation/coordinateoperationfactory.cpp
index 480a8941b7..8923fe00ce 100644
--- a/src/iso19111/operation/coordinateoperationfactory.cpp
+++ b/src/iso19111/operation/coordinateoperationfactory.cpp
@@ -6599,6 +6599,7 @@ void CoordinateOperationFactory::Private::createOperationsCompoundToCompound(
     }
 
     std::vector<CoordinateOperationNNPtr> verticalTransforms;
+    bool bHasTriedVerticalTransforms = false;
     bool bTryThroughIntermediateGeogCRS = false;
     if (componentsSrc.size() >= 2) {
         const auto vertSrc = componentsSrc[1]->extractVerticalCRS();
@@ -6642,6 +6643,7 @@ void CoordinateOperationFactory::Private::createOperationsCompoundToCompound(
                 // If we have a geoid model, force using through it
                 bTryThroughIntermediateGeogCRS = true;
             } else {
+                bHasTriedVerticalTransforms = true;
                 verticalTransforms =
                     createOperations(componentsSrc[1], sourceEpoch,
                                      componentsDst[1], targetEpoch, context);
@@ -6912,6 +6914,12 @@ void CoordinateOperationFactory::Private::createOperationsCompoundToCompound(
         if (!res.empty()) {
             return;
         }
+
+        if (!bHasTriedVerticalTransforms) {
+            verticalTransforms =
+                createOperations(componentsSrc[1], sourceEpoch,
+                                 componentsDst[1], targetEpoch, context);
+        }
     }
 
     for (const auto &verticalTransform : verticalTransforms) {


Test patch:
diff --git a/test/unit/test_operationfactory.cpp b/test/unit/test_operationfactory.cpp
index 741131daeb..082347162f 100644
--- a/test/unit/test_operationfactory.cpp
+++ b/test/unit/test_operationfactory.cpp
@@ -6543,6 +6543,66 @@ TEST(operation,
 
 // ---------------------------------------------------------------------------
 
+TEST(operation, compoundCRS_to_compoundCRS_issue_4550) {
+    auto dbContext = DatabaseContext::create();
+    auto authFactory = AuthorityFactory::create(dbContext, std::string());
+    auto ctxt = CoordinateOperationContext::create(authFactory, nullptr, 0.0);
+    ctxt->setGridAvailabilityUse(
+        CoordinateOperationContext::GridAvailabilityUse::
+            IGNORE_GRID_AVAILABILITY);
+    auto wkt =
+        "COMPD_CS["
+        "\"NAD83(HARN) / Washington South (ftUS) + "
+        "NAVD88 height (ftUS) - Geoid18 (ftUS)\","
+        "PROJCS[\"NAD83(HARN) / Washington South (ftUS)\","
+        "GEOGCS[\"NAD83(HARN)\","
+        "DATUM[\"NAD83 (High Accuracy Reference Network)\","
+        "SPHEROID[\"GRS 1980\",6378137,298.257222101,"
+        "AUTHORITY[\"EPSG\",\"7019\"]],TOWGS84[0,0,0,0,0,0,0],"
+        "AUTHORITY[\"EPSG\",\"6152\"]],"
+        "PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],"
+        "UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],"
+        "AUTHORITY[\"EPSG\",\"4152\"]],"
+        "PROJECTION[\"Lambert_Conformal_Conic_2SP\"],"
+        "PARAMETER[\"standard_parallel_1\",47.33333333333334],"
+        "PARAMETER[\"standard_parallel_2\",45.83333333333334],"
+        "PARAMETER[\"latitude_of_origin\",45.33333333333334],"
+        "PARAMETER[\"central_meridian\",-120.5],"
+        "PARAMETER[\"false_easting\",1640416.667],"
+        "PARAMETER[\"false_northing\",0],"
+        "UNIT[\"US survey foot\",0.3048006096012192,"
+        "AUTHORITY[\"EPSG\",\"9003\"]],"
+        "AXIS[\"X\",EAST],AXIS[\"Y\",NORTH],"
+        "AUTHORITY[\"EPSG\",\"2927\"]],"
+        "VERT_CS[\"NAVD88 height (ftUS) - Geoid18 (ftUS)\","
+        "VERT_DATUM[\"North American Vertical Datum 1988\",2005,"
+        "AUTHORITY[\"EPSG\",\"5103\"]],"
+        "UNIT[\"US survey foot\",0.3048006096012192,"
+        "AUTHORITY[\"EPSG\",\"9003\"]],AXIS[\"Up\",UP],"
+        "AUTHORITY[\"EPSG\",\"6360\"]]]";
+    auto srcObj = createFromUserInput(wkt, dbContext, false);
+    auto src = nn_dynamic_pointer_cast<CRS>(srcObj);
+    ASSERT_TRUE(src != nullptr);
+    // "UTM Zone 10N / WGS 84 + NAVD88 height"
+    auto dstObj = createFromUserInput("EPSG:32610+5703", dbContext, false);
+    auto dst = nn_dynamic_pointer_cast<CRS>(dstObj);
+    ASSERT_TRUE(dst != nullptr);
+
+    auto list = CoordinateOperationFactory::create()->createOperations(
+        NN_NO_CHECK(src), NN_NO_CHECK(dst), ctxt);
+    ASSERT_GE(list.size(), 1U);
+    EXPECT_EQ(list[0]->exportToPROJString(PROJStringFormatter::create().get()),
+              "+proj=pipeline "
+              "+step +proj=unitconvert +xy_in=us-ft +xy_out=m "
+              "+step +inv +proj=lcc +lat_0=45.3333333333333 +lon_0=-120.5 "
+              "+lat_1=47.3333333333333 +lat_2=45.8333333333333 "
+              "+x_0=500000.0001016 +y_0=0 +ellps=GRS80 "
+              "+step +proj=unitconvert +z_in=us-ft +z_out=m "
+              "+step +proj=utm +zone=10 +ellps=WGS84");
+}
+
+// ---------------------------------------------------------------------------
+
 TEST(operation,
      compoundCRS_to_compoundCRS_with_derivedVerticalCRS_ellipsoidal_height) {
     // Test scenario of https://github.com/OSGeo/PROJ/issues/4175