This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new f416e5744b Refactor the 
`CoordinateOperationFinder.createOperationStep(GeodeticCRS source, GeodeticCRS 
target)` method for adding a "spherical to ellipsoidal" step (or its inverse) 
when needed, including addition of radius coordinate ("spherical 2D to 3D").
f416e5744b is described below

commit f416e5744b135c2c00b3f53d16743da7d49227e4
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Sun Jun 8 19:28:43 2025 +0200

    Refactor the `CoordinateOperationFinder.createOperationStep(GeodeticCRS 
source, GeodeticCRS target)` method for adding a
    "spherical to ellipsoidal" step (or its inverse) when needed, including 
addition of radius coordinate ("spherical 2D to 3D").
    
    https://issues.apache.org/jira/browse/SIS-302
---
 .../sis/referencing/datum/BursaWolfParameters.java |   2 +-
 .../referencing/datum/DefaultGeodeticDatum.java    |   2 +-
 .../internal/ParameterizedTransformBuilder.java    | 239 ++++++++++------
 .../operation/CoordinateOperationFinder.java       | 208 +++-----------
 .../operation/MathTransformContext.java            |  88 +++++-
 .../provider/GeocentricTranslation3D.java          |   1 +
 .../operation/provider/Geographic2Dto3D.java       |  26 +-
 .../operation/provider/Geographic3Dto2D.java       |  19 +-
 .../operation/provider/Spherical2Dto3D.java        |   7 +-
 .../operation/provider/Spherical3Dto2D.java        |   7 +-
 .../CoordinateSystemTransformBuilder.java          | 318 +++++++++++++++++----
 .../transform/DefaultMathTransformFactory.java     |   4 +-
 .../ParameterizedTransformBuilderTest.java         |   2 +-
 .../operation/CoordinateOperationFinderTest.java   |  22 +-
 14 files changed, 613 insertions(+), 332 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/BursaWolfParameters.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/BursaWolfParameters.java
index a38bd2c7d8..5af07dcc5a 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/BursaWolfParameters.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/BursaWolfParameters.java
@@ -645,7 +645,7 @@ public class BursaWolfParameters extends FormattableObject 
implements Cloneable,
     @Override
     public boolean equals(final Object object) {
         if (object != null && object.getClass() == getClass()) {
-            final BursaWolfParameters that = (BursaWolfParameters) object;
+            final var that = (BursaWolfParameters) object;
             return Arrays.equals(this.getValues(),      that.getValues()) &&
                   Objects.equals(this.targetDatum,      that.targetDatum) &&
                   Objects.equals(this.domainOfValidity, that.domainOfValidity);
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/DefaultGeodeticDatum.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/DefaultGeodeticDatum.java
index d90ed49af8..75cc8178e2 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/DefaultGeodeticDatum.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/DefaultGeodeticDatum.java
@@ -413,7 +413,7 @@ public class DefaultGeodeticDatum extends AbstractDatum 
implements GeodeticDatum
      */
     public Matrix getPositionVectorTransformation(final GeodeticDatum 
targetDatum, final Extent areaOfInterest) {
         ensureNonNull("targetDatum", targetDatum);
-        final ExtentSelector<BursaWolfParameters> selector = new 
ExtentSelector<>(areaOfInterest);
+        final var selector = new 
ExtentSelector<BursaWolfParameters>(areaOfInterest);
         BursaWolfParameters candidate = select(targetDatum, selector);
         if (candidate != null) {
             return createTransformation(candidate, areaOfInterest);
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/ParameterizedTransformBuilder.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/ParameterizedTransformBuilder.java
index d439168dd4..4b9d9ef158 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/ParameterizedTransformBuilder.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/ParameterizedTransformBuilder.java
@@ -37,11 +37,11 @@ import org.opengis.referencing.operation.Matrix;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.MathTransformFactory;
 import org.opengis.referencing.operation.OperationMethod;
+import org.opengis.referencing.operation.NoninvertibleTransformException;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.opengis.util.NoSuchIdentifierException;
 import org.opengis.util.FactoryException;
 import org.apache.sis.util.ArgumentChecks;
-import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.Classes;
 import org.apache.sis.util.privy.Strings;
 import org.apache.sis.util.privy.Constants;
@@ -52,12 +52,14 @@ import org.apache.sis.referencing.cs.AxesConvention;
 import org.apache.sis.referencing.cs.CoordinateSystems;
 import org.apache.sis.referencing.operation.matrix.Matrices;
 import org.apache.sis.referencing.operation.provider.AbstractProvider;
+import org.apache.sis.referencing.operation.provider.Geographic2Dto3D;
 import org.apache.sis.referencing.operation.provider.VerticalOffset;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.referencing.operation.transform.MathTransformBuilder;
 import org.apache.sis.referencing.operation.transform.ContextualParameters;
 import org.apache.sis.referencing.operation.transform.MathTransformProvider;
 import 
org.apache.sis.referencing.operation.transform.DefaultMathTransformFactory;
+import 
org.apache.sis.referencing.operation.transform.EllipsoidToRadiusTransform;
 import org.apache.sis.referencing.factory.InvalidGeodeticParameterException;
 import org.apache.sis.referencing.privy.CoordinateOperations;
 import org.apache.sis.referencing.privy.ReferencingUtilities;
@@ -99,12 +101,12 @@ public class ParameterizedTransformBuilder extends 
MathTransformBuilder implemen
     /**
      * Coordinate system of the source or target points.
      */
-    private CoordinateSystem sourceCS, targetCS;
+    protected CoordinateSystem sourceCS, targetCS;
 
     /**
      * The ellipsoid of the source or target ellipsoidal coordinate system, or 
{@code null} if it does not apply.
      */
-    private Ellipsoid sourceEllipsoid, targetEllipsoid;
+    protected Ellipsoid sourceEllipsoid, targetEllipsoid;
 
     /**
      * The parameters of the transform to create. This is initialized to 
default values.
@@ -117,7 +119,7 @@ public class ParameterizedTransformBuilder extends 
MathTransformBuilder implemen
      * or {@link #getCompletedParameters()} will throw {@link 
IllegalStateException},
      * unless {@link #setParameters(ParameterValueGroup, boolean)} is 
invoked.</p>
      */
-    private ParameterValueGroup parameters;
+    protected ParameterValueGroup parameters;
 
     /**
      * Names of parameters which have been inferred from the context.
@@ -353,7 +355,7 @@ public class ParameterizedTransformBuilder extends 
MathTransformBuilder implemen
 
     /**
      * Returns the parameter values to modify for defining the transform to 
create.
-     * Those parameters are initialized to default values, which are 
{@linkplain #getMethod() method} depend.
+     * Those parameters are initialized to default values, which are 
{@linkplain #getMethod() method} dependent.
      * User-supplied values should be set directly in the returned instance 
with codes like
      * 
<code>parameter(</code><var>name</var><code>).setValue(</code><var>value</var><code>)</code>.
      *
@@ -665,14 +667,19 @@ public class ParameterizedTransformBuilder extends 
MathTransformBuilder implemen
 
     /**
      * Given a transform between normalized spaces,
-     * creates a transform taking in account axis directions, units of 
measurement and longitude rotation.
-     * This method {@linkplain #createConcatenatedTransform concatenates} the 
given parameterized transform
+     * creates a transform taking in account axis directions and units of 
measurement.
+     * This method {@linkplain #createConcatenatedTransform concatenates} the 
given normalized transform
      * with any other transform required for performing units changes and 
coordinates swapping.
      *
-     * <p>The given {@code parameterized} transform shall expect
+     * <h4>Design note</h4>
+     * The {@code normalized} transform is a black box receiving inputs in any 
<abbr>CS</abbr> and producing
+     * outputs in any <abbr>CS</abbr>, not necessarily of the same kind. For 
that reason, this method cannot
+     * use {@link CoordinateSystems#swapAndScaleAxes(CoordinateSystem, 
CoordinateSystem)} between the normalized CS.
+     * This method have to trust that the callers know that the coordinate 
systems that they provided are correct
+     * for the work done by the transform. The given {@code normalized} 
transform shall expect
      * {@linkplain org.apache.sis.referencing.cs.AxesConvention#NORMALIZED 
normalized} input coordinates
      * and produce normalized output coordinates. See {@link 
org.apache.sis.referencing.cs.AxesConvention}
-     * for more information about what Apache SIS means by "normalized".</p>
+     * for more information about what Apache SIS means by "normalized".
      *
      * <h4>Example</h4>
      * The most typical examples of transforms with normalized inputs/outputs 
are normalized
@@ -694,16 +701,20 @@ public class ParameterizedTransformBuilder extends 
MathTransformBuilder implemen
      * @see 
org.apache.sis.referencing.operation.DefaultConversion#DefaultConversion(Map, 
OperationMethod, MathTransform, ParameterValueGroup)
      */
     public MathTransform swapAndScaleAxes(final MathTransform normalized) 
throws FactoryException {
-        ArgumentChecks.ensureNonNull("parameterized", normalized);
+        ArgumentChecks.ensureNonNull("normalized", normalized);
         /*
-         * Compute matrices for swapping axis and performing units conversion.
+         * Compute matrices for swapping axes and performing units conversion.
          * There is one matrix to apply before projection from (λ,φ) 
coordinates,
          * and one matrix to apply after projection on (easting,northing) 
coordinates.
          */
         final Matrix swap1 = 
getMatrix(ContextualParameters.MatrixRole.NORMALIZATION);
         final Matrix swap3 = 
getMatrix(ContextualParameters.MatrixRole.DENORMALIZATION);
         /*
-         * Prepare the concatenation of the matrices computed above and the 
projection.
+         * Prepare the concatenation of above `swap` matrices with the 
normalized transform.
+         * The chain of transforms built by this method will be:
+         *
+         *     step1  ⟶  step2 (normalized)  ⟶  step3
+         *
          * Note that at this stage, the dimensions between each step may not 
be compatible.
          * For example, the projection (step2) is usually two-dimensional 
while the source
          * coordinate system (step1) may be three-dimensional if it has a 
height.
@@ -712,77 +723,79 @@ public class ParameterizedTransformBuilder extends 
MathTransformBuilder implemen
         MathTransform step3 = swap3 != null ? 
factory.createAffineTransform(swap3) : 
MathTransforms.identity(normalized.getTargetDimensions());
         MathTransform step2 = normalized;
         /*
-         * Special case for the way EPSG handles reversal of axis direction. 
For now the "Vertical Offset" (EPSG:9616)
-         * method is the only one for which we found a need for special case. 
But if more special cases are added in a
-         * future SIS version, then we should replace the static method by a 
non-static one defined in AbstractProvider.
+         * Special case for the way that EPSG handles reversal of axis 
direction.
+         * For now the "Vertical Offset" (EPSG:9616) method is the only known 
case.
+         * But if more special cases are added in a future SIS version, then 
we should
+         * replace the static method by a non-static one defined in 
`AbstractProvider`.
          */
         if (provider instanceof VerticalOffset) {
             step2 = VerticalOffset.postCreate(step2, swap3);
         }
         /*
-         * If the target coordinate system has a height, instruct the 
projection to pass the height unchanged from
-         * the base CRS to the target CRS. After this block, the dimensions of 
`step2` and `step3` should match.
-         *
-         * The height is always the last dimension in a normalized 
EllipdoidalCS. We accept only a hard-coded list
-         * of dimensions because it is not `MathTransformFactory` job to build 
a transform chain in a generic way.
-         * We handle only the cases that are necessary because of the way some 
operation methods are provided.
-         * In particular Apache SIS provides only 2D map projections, so 3D 
projections have to be "generated"
-         * on the fly. That use case is:
+         * Add or remove the vertical coordinate of an ellipsoidal or 
spherical coordinate system.
+         * For an ellipsoidal CS, the vertical coordinate is the height and 
its default value is 0.
+         * For a spherical CS, the vertical coordinate is the radius and its 
value depends on the latitude.
+         * If there is more than one dimension to add or remove, the extra 
dimensions will be handled later.
          *
-         *     - Source CRS: a GeographicCRS (regardless its number of 
dimension – it will be addressed in next block)
-         *     - Target CRS: a 3D ProjectedCRS
-         *     - Parameterized transform: a 2D map projection. We need the 
ellipsoidal height to passthrough.
-         *
-         * The reverse order (projected source CRS and geographic target CRS) 
is also accepted but should be uncommon.
+         * Note that the vertical coordinate is added only if needed by 
`step2`. If `step2` does not expect
+         * a vertical coordinate, then that coordinate is not added here. It 
will be added later with a NaN
+         * value for avoiding a false sense of information availability.
+         */
+        int sourceDim = step1.getTargetDimensions();      // Number of 
available dimensions.
+        int neededDim = step2.getSourceDimensions();      // Number of 
required dimensions.
+        int kernelDim = step2.getTargetDimensions();      // Result of the 
core part of transform.
+        int resultDim = step3.getSourceDimensions();      // Expected result 
after the kernel part.
+        if (sourceDim == 2 && neededDim > 2) {
+            MathTransform change = addOrRemoveVertical(sourceCS, 
sourceEllipsoid, targetEllipsoid, false);
+            if (change != null) {
+                step1 = factory.createConcatenatedTransform(step1, change);
+                sourceDim = step1.getTargetDimensions();
+            }
+        }
+        if (kernelDim == 3 && resultDim < 3) {
+            MathTransform change = addOrRemoveVertical(targetCS, 
targetEllipsoid, sourceEllipsoid, true);
+            if (change != null) {
+                step3 = factory.createConcatenatedTransform(change, step3);
+                resultDim = step3.getSourceDimensions();
+            }
+        }
+        /*
+         * Make the number of target dimensions of `step2` compatible with the 
number of source dimensions of `step3`.
+         * For example, SIS provides only 2D map projections, therefore 3D 
projections must be generated on the fly
+         * by adding a "pass-through" transform.
          */
-        final int resultDim = step3.getSourceDimensions();              // 
Final result (minus trivial changes).
-        final int kernelDim = step2.getTargetDimensions();              // 
Result of the core part of transform.
         final int numTrailingCoordinates = resultDim - kernelDim;
         if (numTrailingCoordinates != 0) {
-            ensureDimensionChangeAllowed(normalized, numTrailingCoordinates, 
resultDim);
+            ensureDimensionChangeAllowed(numTrailingCoordinates, resultDim, 
normalized);
             if (numTrailingCoordinates > 0) {
                 step2 = factory.createPassThroughTransform(0, step2, 
numTrailingCoordinates);
             } else {
-                var select = Matrices.createDimensionSelect(kernelDim, 
ArraysExt.range(0, resultDim));
-                step2 = factory.createConcatenatedTransform(step2, 
factory.createAffineTransform(select));
+                step2 = factory.createConcatenatedTransform(step2, 
addOrRemoveDimensions(kernelDim, resultDim));
             }
+            neededDim = step2.getSourceDimensions();
+            kernelDim = step2.getTargetDimensions();
         }
         /*
-         * If the source CS has a height but the target CS doesn't, drops the 
extra coordinates.
-         * Conversely if the source CS is missing a height, add a height with 
NaN values.
-         * After this block, the dimensions of `step1` and `step2` should 
match.
-         *
-         * When adding an ellipsoidal height, there are two scenarios: the 
ellipsoidal height may be used by the
-         * parameterized operation, or it may be passed through (in which case 
the operation ignores the height).
-         * If the height is expected as operation input, set the height to 0. 
Otherwise (the pass through case),
-         * set the height to NaN. We do that way because the given 
`parameterized` transform may be a Molodensky
-         * transform or anything else that could use the height in its 
calculation. If we have to add a height as
-         * a pass through dimension, maybe the parameterized transform is a 2D 
Molodensky instead of a 3D Molodensky.
-         * The result of passing through the height is not the same as if a 3D 
Molodensky was used in the first place.
-         * A NaN value avoid to give a false sense of accuracy.
+         * Make the number of target dimensions of `step1` compatible with the 
number of source dimensions of `step2`.
+         * If dimensions must be added, their values will be NaN. Note that 
the vertical dimension (height or radius)
+         * has already been added with a non-NaN value before to reach this 
point if that value was required by `step2`.
          */
-        final int sourceDim = step1.getTargetDimensions();
-        final int targetDim = step2.getSourceDimensions();
-        int insertCount = targetDim - sourceDim;
-        if (insertCount != 0) {
-            ensureDimensionChangeAllowed(normalized, insertCount, targetDim);
-            final Matrix resize = Matrices.createZero(targetDim+1, 
sourceDim+1);
-            for (int j=0; j<targetDim; j++) {
-                resize.setElement(j, Math.min(j, sourceDim), (j < sourceDim) ? 
1 :
-                        ((--insertCount >= numTrailingCoordinates) ? 0 : 
Double.NaN));        // See above note.
-            }
-            resize.setElement(targetDim, sourceDim, 1);     // Element in the 
lower-right corner.
-            step1 = factory.createConcatenatedTransform(step1, 
factory.createAffineTransform(resize));
+        if (sourceDim != neededDim) {
+            ensureDimensionChangeAllowed(neededDim - sourceDim, neededDim, 
normalized);
+            step1 = factory.createConcatenatedTransform(step1, 
addOrRemoveDimensions(sourceDim, neededDim));
+        }
+        if (kernelDim != resultDim) {
+            ensureDimensionChangeAllowed(resultDim - kernelDim, resultDim, 
normalized);
+            step3 = 
factory.createConcatenatedTransform(addOrRemoveDimensions(kernelDim, 
resultDim), step3);
         }
-        MathTransform mt = 
factory.createConcatenatedTransform(factory.createConcatenatedTransform(step1, 
step2), step3);
         /*
-         * At this point we finished to create the transform.  But before to 
return it, verify if the
-         * parameterized transform given in argument had some custom 
parameters. This happen with the
-         * Equirectangular projection, which can be simplified as an 
AffineTransform while we want to
-         * continue to describe it with the "semi_major", "semi_minor", etc. 
parameters  instead of
-         * "elt_0_0", "elt_0_1", etc.  The following code just forwards those 
parameters to the newly
-         * created transform; it does not change the operation.
+         * Create the transform.
+         *
+         * Special case: if the parameterized transform was a map projection 
but the result, after simplification,
+         * is an affine transform (it can happen with the Equirectangular 
projection), wraps the affine transform
+         * for continuing to show "semi_major", "semi_minor", etc. parameters 
instead of "elt_0_0", "elt_0_1", etc.
          */
+        MathTransform mt = 
factory.createConcatenatedTransform(factory.createConcatenatedTransform(step1, 
step2), step3);
         if (normalized instanceof ParameterizedAffine && !(mt instanceof 
ParameterizedAffine)) {
             if (mt != (mt = ((ParameterizedAffine) 
normalized).newTransform(mt))) {
                 mt = unique(mt);
@@ -792,26 +805,80 @@ public class ParameterizedTransformBuilder extends 
MathTransformBuilder implemen
     }
 
     /**
-     * Checks whether {@link #swapAndScaleAxes(MathTransform)} should accept 
to adjust the number of
-     * transform dimensions. The current implementation accepts only addition 
or removal of ellipsoidal height,
-     * but future version may expand the list of accepted cases. The intent 
for this method is to catch errors
-     * caused by wrong coordinate systems associated to a parameterized 
transform, keeping in mind that it is
-     * not {@link DefaultMathTransformFactory} job to handle changes between 
arbitrary CRS (those changes are
-     * handled by {@link 
org.apache.sis.referencing.operation.DefaultCoordinateOperationFactory} 
instead).
+     * Adds or removes the ellipsoidal height or spherical radius dimension.
+     *
+     * @param  cs         the coordinate system for which to add a dimension, 
or {@code null} if unknown.
+     * @param  ellipsoid  the ellipsoid to use, or {@code null} if unknown.
+     * @param  fallback   another ellipsoid that may be used if {@code 
ellipsoid} is null.
+     * @param  remove     {@code false} for adding the dimension, or {@code 
true} for removing the dimension.
+     * @return the transform to concatenate, or {@code null} if the coordinate 
system is not recognized.
+     * @throws FactoryException if an error occurred while creating the 
transform.
      *
-     * <h4>Implementation note</h4>
-     * The {@code parameterized} transform is a black box receiving inputs in 
any <abbr>CS</abbr> and
-     * producing outputs in any <abbr>CS</abbr>, not necessarily of the same 
kind. For that reason, we cannot use
-     * {@link CoordinateSystems#swapAndScaleAxes(CoordinateSystem, 
CoordinateSystem)} between the normalized CS.
-     * We have to trust that the caller knows that the coordinate systems 
(s)he provided are correct for the work
-     * done by the transform.
+     * @see 
org.apache.sis.referencing.operation.transform.CoordinateSystemTransformBuilder#addOrRemoveVertical
+     */
+    private MathTransform addOrRemoveVertical(final CoordinateSystem cs, 
Ellipsoid ellipsoid,
+            final Ellipsoid fallback, final boolean remove) throws 
FactoryException
+    {
+        if (ellipsoid == null) {
+            ellipsoid = fallback;
+        }
+        MathTransform tr;
+        if (cs instanceof EllipsoidalCS) {
+            Matrix resize = Matrices.createDiagonal(4, 3);  // Set the height 
to zero (not NaN).
+            resize.setElement(3, 2, 1);                     // Element in the 
lower-right corner.
+            resize.setElement(2, 2, Geographic2Dto3D.DEFAULT_HEIGHT);
+            tr = factory.createAffineTransform(resize);
+        } else if (ellipsoid != null && cs instanceof SphericalCS) {
+            tr = EllipsoidToRadiusTransform.createGeodeticConversion(factory, 
ellipsoid);
+        } else {
+            return null;
+        }
+        if (remove) try {
+            tr = tr.inverse();
+        } catch (NoninvertibleTransformException cause) {
+            throw new FactoryException(cause.getMessage(), cause);
+        }
+        return tr;
+    }
+
+    /**
+     * Adds or removes an arbitrary number of dimensions.
+     * Coordinate values of added dimensions will be NaN.
      *
-     * @param  parameterized  the parameterized transform, for producing an 
error message if needed.
-     * @param  change         number of dimensions to add (if positive) or 
remove (if negative).
-     * @param  resultDim      number of dimensions after the change.
+     * @param  sourceDim  the current number of dimensions.
+     * @param  targetDim  the desired number of dimensions.
+     * @return the transform to concatenate.
+     * @throws FactoryException if the transform cannot be created.
+     */
+    private MathTransform addOrRemoveDimensions(final int sourceDim, final int 
targetDim) throws FactoryException {
+        final Matrix resize = Matrices.createDiagonal(targetDim+1, 
sourceDim+1);
+        if (sourceDim < targetDim) {
+            for (int j=sourceDim; j<targetDim; j++) {
+                resize.setElement(j, sourceDim, Double.NaN);
+            }
+        } else {
+            resize.setElement(targetDim, targetDim, 0);
+        }
+        resize.setElement(targetDim, sourceDim, 1);     // Element in the 
lower-right corner.
+        return factory.createAffineTransform(resize);
+    }
+
+    /**
+     * Checks whether {@link #swapAndScaleAxes(MathTransform)} should accept 
to adjust the number of dimensions.
+     * This method is for catching errors caused by wrong coordinate systems 
associated to a parameterized transform,
+     * keeping in mind that it is not {@link DefaultMathTransformFactory} job 
to handle changes between arbitrary CRS
+     * (those changes are handled by {@link 
org.apache.sis.referencing.operation.DefaultCoordinateOperationFactory}).
+     *
+     * <h4>Current rules</h4>
+     * The current implementation accepts only addition or removal of 
ellipsoidal height.
+     * Future Apache SIS versions may expand the list of accepted cases.
+     *
+     * @param  change      number of dimensions to add (if positive) or remove 
(if negative).
+     * @param  resultDim   number of dimensions after the change.
+     * @param  normalized  the parameterized transform, for producing an error 
message if needed.
      */
-    private void ensureDimensionChangeAllowed(final MathTransform 
parameterized,
-            final int change, final int resultDim) throws FactoryException
+    private void ensureDimensionChangeAllowed(final int change, final int 
resultDim, final MathTransform normalized)
+            throws FactoryException
     {
         if (Math.abs(change) == 1 && resultDim >= 2 && resultDim <= 3) {
             if (sourceCS instanceof EllipsoidalCS || targetCS instanceof 
EllipsoidalCS) {
@@ -822,16 +889,16 @@ public class ParameterizedTransformBuilder extends 
MathTransformBuilder implemen
          * Creates the error message for a transform that cannot be associated 
with given coordinate systems.
          */
         String name = null;
-        if (parameterized instanceof Parameterized) {
-            name = IdentifiedObjects.getDisplayName(((Parameterized) 
parameterized).getParameterDescriptors(), null);
+        if (normalized instanceof Parameterized) {
+            name = IdentifiedObjects.getDisplayName(((Parameterized) 
normalized).getParameterDescriptors(), null);
         }
         if (name == null) {
-            name = Classes.getShortClassName(parameterized);
+            name = Classes.getShortClassName(normalized);
         }
         final var b = new StringBuilder();
         getSourceDimensions().ifPresent((dim) -> b.append(dim).append("D → "));
-        b.append("tr(").append(parameterized.getSourceDimensions()).append("D 
→ ")
-                       
.append(parameterized.getTargetDimensions()).append("D)");
+        b.append("tr(").append(normalized.getSourceDimensions()).append("D → ")
+                       .append(normalized.getTargetDimensions()).append("D)");
         getTargetDimensions().ifPresent((dim) -> b.append(" → 
").append(dim).append('D'));
         throw new 
InvalidGeodeticParameterException(Resources.format(Resources.Keys.CanNotAssociateToCS_2,
 name, b));
     }
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationFinder.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationFinder.java
index 241ec340b4..2eb7c5a3fa 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationFinder.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationFinder.java
@@ -37,8 +37,6 @@ import org.opengis.metadata.Identifier;
 import org.opengis.metadata.quality.PositionalAccuracy;
 import org.opengis.metadata.extent.GeographicBoundingBox;
 import org.opengis.parameter.ParameterValueGroup;
-import org.opengis.parameter.ParameterDescriptorGroup;
-import org.apache.sis.parameter.TensorParameters;
 import org.apache.sis.measure.Units;
 import org.apache.sis.metadata.iso.citation.Citations;
 import org.apache.sis.metadata.iso.extent.Extents;
@@ -58,12 +56,7 @@ import org.apache.sis.referencing.datum.DefaultGeodeticDatum;
 import org.apache.sis.referencing.datum.PseudoDatum;
 import org.apache.sis.referencing.operation.matrix.Matrices;
 import org.apache.sis.referencing.operation.matrix.MatrixSIS;
-import org.apache.sis.referencing.operation.provider.Affine;
 import org.apache.sis.referencing.operation.provider.DatumShiftMethod;
-import org.apache.sis.referencing.operation.provider.Geographic2Dto3D;
-import org.apache.sis.referencing.operation.provider.Geographic3Dto2D;
-import org.apache.sis.referencing.operation.provider.GeographicToGeocentric;
-import org.apache.sis.referencing.operation.provider.GeocentricToGeographic;
 import org.apache.sis.referencing.operation.provider.GeocentricAffine;
 import org.apache.sis.util.Utilities;
 import org.apache.sis.util.ArgumentChecks;
@@ -490,8 +483,8 @@ public class CoordinateOperationFinder extends 
CoordinateOperationRegistry {
      * </ul>
      *
      * This method returns only <em>one</em> step for a chain of concatenated 
operations (to be built by the caller).
-     * But a list is returned because the same step may be implemented by 
different operation methods. Only one element
-     * in the returned list should be selected (usually the first one).
+     * But a list is returned because the same step may be implemented by 
different operation methods.
+     * Only one element in the returned list should be selected (usually the 
first one).
      *
      * @param  sourceCRS  input coordinate reference system.
      * @param  targetCRS  output coordinate reference system.
@@ -506,176 +499,69 @@ public class CoordinateOperationFinder extends 
CoordinateOperationRegistry {
         final CoordinateSystem targetCS = targetCRS.getCoordinateSystem();
         final GeodeticDatum sourceDatum = PseudoDatum.of(sourceCRS);
         final GeodeticDatum targetDatum = PseudoDatum.of(targetCRS);
-        Matrix datumShift = null;
-        /*
-         * If the prime meridian is not the same, we will concatenate a 
longitude rotation before or after datum shift
-         * (that concatenation will be performed by the `MathTransformContext` 
builder created below).
-         * Actually we do not know if the longitude rotation should be before 
or after datum shift. But this ambiguity
-         * can usually be ignored because Bursa-Wolf parameters are always 
used with source and target prime meridians
-         * set to Greenwich in EPSG dataset 8.9.  For safety, the SIS's 
DefaultGeodeticDatum class ensures that if the
-         * prime meridians are not the same, then the target meridian must be 
Greenwich.
-         */
-        final MathTransformFactory mtFactory = 
factorySIS.getMathTransformFactory();
-        final var context = new MathTransformContext(mtFactory, sourceDatum, 
targetDatum);
-        context.setSourceAxes(sourceCS, sourceDatum.getEllipsoid());
-        context.setTargetAxes(targetCS, targetDatum.getEllipsoid());
         /*
-         * If both CRS use the same datum and the same prime meridian, then 
the coordinate operation is only axis
-         * swapping, unit conversion or change of coordinate system type 
(Ellipsoidal ↔ Cartesian ↔ Spherical).
-         * Otherwise (if the datum are not the same), we will need to perform 
a scale, translation and rotation
-         * in Cartesian space using the Bursa-Wolf parameters. If the user 
does not require the best accuracy,
-         * then the Molodensky approximation may be used for avoiding the 
conversion step to geocentric CRS.
+         * Find the type of operation depending on whether there is a change 
of geodetic reference frame (datum).
+         * The `DATUM_SHIFT` and `ELLIPSOID_CHANGE` identifiers mean that 
there is a datum change, and all other
+         * identifiers mean that the coordinate operation is only a change of 
coordinate system type (Ellipsoidal
+         * ↔ Cartesian ↔ Spherical), axis swapping and unit conversions.
          */
-        Identifier identifier;
-        boolean isGeographicToGeocentric = false;
+        final Matrix datumShift;
+        final Identifier identifier;
+        final MathTransform transform;
+        ParameterValueGroup parameters;
+        final Optional<OperationMethod> method;
         final Optional<GeodeticDatum> commonDatum = 
PseudoDatum.ofOperation(sourceCRS, targetCRS);
         if (commonDatum.isPresent()) {
-            final boolean isGeocentricToGeographic;
-            isGeographicToGeocentric = (sourceCS instanceof EllipsoidalCS && 
targetCS instanceof CartesianCS);
-            isGeocentricToGeographic = (sourceCS instanceof CartesianCS && 
targetCS instanceof EllipsoidalCS);
             /*
-             * Above booleans should never be true at the same time. If it 
nevertheless happen (we are paranoiac;
-             * maybe a lazy user implemented all interfaces in a single 
class), do not apply any geographic ↔
-             * geocentric conversion. Instead, do as if the coordinate system 
types were the same.
+             * Coordinate system change (including change in the number of 
dimensions) without datum shift.
+             * May contain the addition of ellipsoidal height or spherical 
radius, which need an ellipsoid.
              */
-            if (isGeocentricToGeographic ^ isGeographicToGeocentric) {
-                identifier = GEOCENTRIC_CONVERSION;
-            } else {
-                identifier = AXIS_CHANGES;
-            }
+            final boolean isGeographic = (sourceCS instanceof EllipsoidalCS);
+            identifier = isGeographic != (targetCS instanceof EllipsoidalCS) ? 
GEOCENTRIC_CONVERSION : AXIS_CHANGES;
+            final var builder = 
factorySIS.getMathTransformFactory().builder(Constants.COORDINATE_SYSTEM_CONVERSION);
+            final var ellipsoid = (isGeographic ? sourceDatum : 
targetDatum).getEllipsoid();
+            builder.setSourceAxes(sourceCS, ellipsoid);
+            builder.setTargetAxes(targetCS, ellipsoid);
+            transform  = builder.create();
+            method     = builder.getMethod();
+            parameters = builder.parameters();
+            datumShift = null;
         } else {
-            identifier = ELLIPSOID_CHANGE;
-            if (sourceDatum instanceof DefaultGeodeticDatum) {
-                datumShift = ((DefaultGeodeticDatum) 
sourceDatum).getPositionVectorTransformation(targetDatum, areaOfInterest);
-                if (datumShift != null) {
-                    identifier = DATUM_SHIFT;
-                }
-            }
-        }
-        /*
-         * Conceptually, all transformations below could be done by first 
converting from source coordinate
-         * system to geocentric Cartesian coordinates (X,Y,Z), apply an affine 
transform represented by the
-         * datum shift matrix, then convert from the (X′,Y′,Z′) coordinates to 
the target coordinate system.
-         * However, there are two exceptions to this path:
-         *
-         *   1) In the particular where both the source and target CS are 
ellipsoidal, we may use the
-         *      Molodensky approximation as a shortcut (if the desired 
accuracy allows).
-         *
-         *   2) Even if we really go through the XYZ coordinates without 
Molodensky approximation, there is
-         *      at least 9 different ways to name this operation depending on 
whether the source and target
-         *      CRS are geocentric or geographic, 2- or 3-dimensional, whether 
there is a translation or not,
-         *      the rotation sign, etc. We try to use the most specific name 
if we can find one, and fallback
-         *      on an arbitrary name only in last resort.
-         */
-        MathTransform before = null, after = null;
-        ParameterValueGroup parameters;
-        OperationMethod method = null;
-        if (identifier == DATUM_SHIFT || identifier == ELLIPSOID_CHANGE) {
             /*
-             * If the transform can be represented by a single coordinate 
operation, returns that operation.
+             * Conceptually, all transformations below could be done by first 
converting from source coordinate
+             * system to geocentric Cartesian coordinates (X,Y,Z), apply an 
affine transform represented by the
+             * datum shift matrix, then convert from the (X′,Y′,Z′) 
coordinates to the target coordinate system.
+             * However, there are exceptions to this path:
+             *
+             *   1) Conversion from ellipsoidal to spherical CS can skip the 
Cartesian step for performance.
+             *   2) Transformation between ellipsoidal CS may use the 
Molodensky approximation as a shortcut.
+             *   3) Even when really going through the XYZ coordinates, the 
name of that operation depends on
+             *      whether the source and target CRS are geocentric or 
geographic, 2- or 3-dimensional,
+             *      whether there is a translation, the rotation sign, etc.
+             *
              * Possible operations are:
              *
              *    - Position Vector transformation (in geocentric, 
geographic-2D or geographic-3D domains)
              *    - Geocentric translation         (in geocentric, 
geographic-2D or geographic-3D domains)
              *    - [Abridged] Molodensky          (as an approximation of 
geocentric translation)
              *    - Identity                       (if the desired accuracy is 
so large than we can skip datum shift)
-             *
-             * If both CS are ellipsoidal but with different number of 
dimensions, then a three-dimensional
-             * operation is used and `DefaultMathTransformFactory` will add an 
ellipsoidal height on-the-fly.
-             * We let the transform factory do this work instead of adding an 
intermediate "geographic 2D
-             * to 3D" operation here because the transform factory works with 
normalized CS, which avoid the
-             * need the search in which dimension to add the ellipsoidal 
height (it should always be last,
-             * but SIS is tolerant to unusual axis order).
              */
+            datumShift = (sourceDatum instanceof DefaultGeodeticDatum) ?
+                    ((DefaultGeodeticDatum) 
sourceDatum).getPositionVectorTransformation(targetDatum, areaOfInterest) : 
null;
+            identifier = (datumShift != null) ? DATUM_SHIFT : ELLIPSOID_CHANGE;
+            var builder = new 
MathTransformContext(factorySIS.getMathTransformFactory(), sourceDatum, 
targetDatum);
+            builder.setSourceAxes(sourceCS, sourceDatum.getEllipsoid());
+            builder.setTargetAxes(targetCS, targetDatum.getEllipsoid());
             parameters = GeocentricAffine.createParameters(sourceCS, targetCS, 
datumShift,
                                             
DatumShiftMethod.forAccuracy(desiredAccuracy));
-            if (parameters == null) {
-                /*
-                 * Failed to select a coordinate operation. Maybe because the 
coordinate system types are not the same.
-                 * Convert unconditionally to XYZ geocentric coordinates and 
apply the datum shift in that CS space.
-                 */
-                if (datumShift != null) {
-                    parameters = 
TensorParameters.WKT1.createValueGroup(properties(Constants.AFFINE), 
datumShift);
-                } else {
-                    parameters = Affine.identity(3);                        // 
Dimension of geocentric CRS.
-                }
-                final CoordinateSystem normalized = 
CommonCRS.WGS84.geocentric().getCoordinateSystem();
-                before = context.createCoordinateSystemChange(sourceCS, 
normalized, sourceDatum.getEllipsoid());
-                after  = context.createCoordinateSystemChange(normalized, 
targetCS, targetDatum.getEllipsoid());
-                context.setSourceAxes(normalized, null);
-                context.setTargetAxes(normalized, null);
-                /*
-                 * The name of the `parameters` group determines the 
`OperationMethod` later in this method.
-                 * We cannot leave that name to "Affine" if `before` or 
`after` transforms are not identity.
-                 * Note: we check for identity transforms instead of relaxing 
to general `LinearTransform`
-                 * because otherwise, we would have to update values declared 
in `parameters`. It is doable
-                 * but not done yet.
-                 */
-                if (!(before.isIdentity() && after.isIdentity())) {
-                    method = LooselyDefinedMethod.AFFINE_GEOCENTRIC;
-                }
-            }
-        } else if (identifier == GEOCENTRIC_CONVERSION) {
-            /*
-             * Geographic ↔︎ Geocentric conversion.
-             */
-            final ParameterDescriptorGroup descriptor;
-            if (isGeographicToGeocentric) {
-                descriptor = GeographicToGeocentric.PARAMETERS;
+            if (parameters != null) {
+                builder.setParameters(parameters, false);
+                transform = builder.create();
             } else {
-                descriptor = GeocentricToGeographic.PARAMETERS;
-            }
-            parameters = descriptor.createValue();
-        } else {
-            /*
-             * Coordinate system change (including change in the number of 
dimensions) without datum shift.
-             */
-            final int sourceDim = sourceCS.getDimension();
-            final int targetDim = targetCS.getDimension();
-            if (sourceDim == 2 && targetDim == 3 && sourceCS instanceof 
EllipsoidalCS) {
-                parameters = Geographic2Dto3D.PARAMETERS.createValue();
-            } else if (sourceDim == 3 && targetDim == 2 && targetCS instanceof 
EllipsoidalCS) {
-                parameters = Geographic3Dto2D.PARAMETERS.createValue();
-            } else {
-                parameters = Affine.identity(targetDim);
-                /*
-                 * "Coordinate system conversion" needs the ellipsoid 
associated to the ellipsoidal coordinate system,
-                 * if any. If none or both coordinate systems are ellipsoidal, 
then the ellipsoid will be ignored.
-                 */
-                var ellipsoid = (sourceCS instanceof EllipsoidalCS ? 
sourceDatum : targetDatum).getEllipsoid();
-                var builder   = 
mtFactory.builder(Constants.COORDINATE_SYSTEM_CONVERSION);
-                builder.setSourceAxes(sourceCS, ellipsoid);
-                builder.setTargetAxes(targetCS, ellipsoid);
-                before = builder.create();
-                method = builder.getMethod().orElse(null);
-                context.setSourceAxes(targetCS, null);
-            }
-        }
-        /*
-         * Transform between differents datums using Bursa Wolf parameters. 
The Bursa Wolf parameters are used
-         * with "standard" geocentric CS, i.e. with X axis towards the prime 
meridian, Y axis towards East and
-         * Z axis toward North, unless the Molodensky approximation is used. 
The following steps are applied:
-         *
-         *     source CRS                        →
-         *     normalized CRS with source datum  →
-         *     normalized CRS with target datum  →
-         *     target CRS
-         *
-         * Those steps may be either explicit with the `before` and `after` 
transforms, or implicit with the
-         * Context parameter. The operation name is inferred from the 
parameters, unless a method has been
-         * specified in advance.
-         */
-        context.setParameters(parameters, false);
-        MathTransform transform = context.create();
-        if (method == null) {
-            method = context.getMethod().orElse(null);
-        }
-        if (before != null) {
-            parameters = null;      // Providing parameters would be 
misleading because they apply to only a step of the operation.
-            transform = mtFactory.createConcatenatedTransform(before, 
transform);
-            if (after != null) {
-                transform = mtFactory.createConcatenatedTransform(transform, 
after);
+                transform = builder.createAffineGeocentric(datumShift);
             }
+            method     = builder.getMethod();
+            parameters = builder.parametersForMetadata();
         }
         /*
          * Adjust the accuracy information if the datum shift has been 
computed by an indirect path.
@@ -690,7 +576,7 @@ public class CoordinateOperationFinder extends 
CoordinateOperationRegistry {
         if (accuracy != null) {
             
properties.put(CoordinateOperation.COORDINATE_OPERATION_ACCURACY_KEY, accuracy);
         }
-        return asList(createFromMathTransform(properties, sourceCRS, 
targetCRS, transform, method, parameters, null));
+        return asList(createFromMathTransform(properties, sourceCRS, 
targetCRS, transform, method.orElse(null), parameters, null));
     }
 
     /**
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/MathTransformContext.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/MathTransformContext.java
index bac42900d7..1e8f15335d 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/MathTransformContext.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/MathTransformContext.java
@@ -16,7 +16,9 @@
  */
 package org.apache.sis.referencing.operation;
 
+import java.util.Map;
 import org.opengis.util.FactoryException;
+import org.opengis.referencing.IdentifiedObject;
 import org.opengis.referencing.cs.CartesianCS;
 import org.opengis.referencing.cs.SphericalCS;
 import org.opengis.referencing.cs.EllipsoidalCS;
@@ -26,12 +28,16 @@ import org.opengis.referencing.datum.Ellipsoid;
 import org.opengis.referencing.operation.Matrix;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.MathTransformFactory;
+import org.opengis.parameter.ParameterValueGroup;
+import org.apache.sis.referencing.CommonCRS;
 import org.apache.sis.referencing.privy.ReferencingUtilities;
 import org.apache.sis.referencing.operation.matrix.Matrices;
 import org.apache.sis.referencing.operation.matrix.Matrix4;
 import org.apache.sis.referencing.operation.matrix.MatrixSIS;
+import org.apache.sis.referencing.operation.provider.Affine;
 import 
org.apache.sis.referencing.operation.transform.ContextualParameters.MatrixRole;
 import org.apache.sis.referencing.internal.ParameterizedTransformBuilder;
+import org.apache.sis.parameter.TensorParameters;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.privy.Constants;
 import org.apache.sis.measure.Units;
@@ -42,10 +48,22 @@ import org.opengis.util.UnimplementedServiceException;
 
 /**
  * Information about the context in which a {@code MathTransform} is created.
- * This class performs the same normalization as the super-class (namely axis 
swapping and unit conversions),
- * with the addition of longitude rotation for supporting change of prime 
meridian.
- * This latter change is not applied by the super-class because prime meridian 
is part of geodetic reference frame,
- * and the public math transform factory knows nothing about datum (on design, 
for separation of concerns).
+ * This class inherits the normalization performed by the super-class (axis 
swapping and unit conversions),
+ * then adds the work described in the sub-sections below. This additional 
work is separated in this class
+ * because that work handles changes of a property of reference frames (e.g., 
a change of prime meridian),
+ * and the public math transform factory does not handle datum change by 
design (for separation of concerns).
+ *
+ * <h2>Longitude rotation</h2>
+ * If the prime meridian is not the same, this class concatenates a longitude 
rotation with the normalization.
+ * Actually, we do not know if the longitude rotation should be before or 
after datum shift. But this ambiguity
+ * can usually be ignored because Bursa-Wolf parameters are always used with 
source and target prime meridians
+ * set to Greenwich in EPSG dataset 8.9. For safety, {@link 
org.apache.sis.referencing.datum.DefaultGeodeticDatum}
+ * constructor ensures that if the prime meridians are not the same, then the 
target meridian must be Greenwich.
+ *
+ * <h2>Bursa-Wolf parameters</h2>
+ * If the source and target ellipsoids are not the same, this class can apply 
an affine operation in geocentric
+ * Cartesian coordinates using Bursa-Wolf parameters. This is used only as a 
fallback when no explicit operation
+ * was found in the <abbr>EPSG</abbr> database.
  *
  * @author  Martin Desruisseaux (Geomatys)
  */
@@ -92,19 +110,71 @@ final class MathTransformContext extends 
ParameterizedTransformBuilder {
      * @return a conversion from the given source to the given target 
coordinate system.
      * @throws FactoryException if the conversion cannot be created.
      */
-    final MathTransform createCoordinateSystemChange(final CoordinateSystem 
source,
-                                                     final CoordinateSystem 
target,
-                                                     final Ellipsoid ellipsoid)
+    private MathTransform createCoordinateSystemChange(final CoordinateSystem 
source,
+                                                       final CoordinateSystem 
target,
+                                                       final Ellipsoid 
ellipsoid)
             throws FactoryException
     {
-        final var builder = 
getFactory().builder(Constants.COORDINATE_SYSTEM_CONVERSION);
+        final var builder = 
factory.builder(Constants.COORDINATE_SYSTEM_CONVERSION);
         builder.setSourceAxes(source, ellipsoid);
         builder.setTargetAxes(target, ellipsoid);
         return builder.create();
     }
 
     /**
-     * Returns the normalization or denormalization matrix.
+     * Creates an affine operation (translation and rotation) in geocentric 
Cartesian space.
+     * This method is invoked as a fallback when {@link 
CoordinateOperationFinder} failed to
+     * select a coordinate operation, maybe because the coordinate system 
types are not the same.
+     * This method unconditionally converts to geocentric Cartesian 
coordinates and applies
+     * the datum shift in that space.
+     *
+     * <p>This method sets {@link #canDeclareParameters} to {@code true} if it 
is okay to use
+     * {@link #parameters()} as metadata in the coordinate operation created 
with this transform.</p>
+     *
+     * @param  datumShift  the operation to apply in geocentric Cartesian 
coordinates, or {@code null} for identity.
+     * @return the transform applying the specified affine operation in 
geocentric Cartesian space.
+     * @throws FactoryException if the transformation cannot be created.
+     */
+    final MathTransform createAffineGeocentric(final Matrix datumShift) throws 
FactoryException {
+        if (datumShift != null) {
+            final var properties = Map.of(IdentifiedObject.NAME_KEY, 
Constants.AFFINE);
+            parameters = TensorParameters.WKT1.createValueGroup(properties, 
datumShift);
+        } else {
+            parameters = Affine.identity(3);        // Dimension of geocentric 
CRS.
+        }
+        provider = Affine.provider();
+        final CoordinateSystem normalized = 
CommonCRS.WGS84.geocentric().getCoordinateSystem();
+        final MathTransform before = createCoordinateSystemChange(sourceCS, 
normalized, sourceEllipsoid);
+        final MathTransform after  = createCoordinateSystemChange(normalized, 
targetCS, targetEllipsoid);
+        setSourceAxes(normalized, null);
+        setTargetAxes(normalized, null);
+        final MathTransform tr = factory.createConcatenatedTransform(before,
+                                 factory.createConcatenatedTransform(create(), 
after));
+        if (!(before.isIdentity() && after.isIdentity())) {
+            // Providing parameters would be misleading because they apply to 
only a step of the operation.
+            provider = LooselyDefinedMethod.AFFINE_GEOCENTRIC;
+            parameters = null;
+        }
+        return tr;
+    }
+
+    /**
+     * Returns the parameters if they can be declared as metadata in the 
coordinate operation.
+     * The parameters may need to be ignored when providing them would be 
misleading, for example,
+     * because additional steps have been added for normalization before or 
after the main transform.
+     */
+    final ParameterValueGroup parametersForMetadata() {
+        return parameters;
+    }
+
+    /**
+     * Returns the normalization or denormalization matrix, including 
longitude rotation if any.
+     * This method is invoked indirectly by {@link #create()} and {@link 
#createAffineGeocentric(Matrix)}.
+     * Therefore, longitude rotations are injected in a transform chain 
indirectly through this method.
+     *
+     * @param  role  whether the normalization or denormalization matrix is 
desired.
+     * @return the requested matrix, or {@code null} if this builder has no 
information about the coordinate system.
+     * @throws UnimplementedServiceException if a longitude rotation is needed 
but the coordinate system type is not recognized.
      */
     @Override
     @SuppressWarnings("fallthrough")
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/GeocentricTranslation3D.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/GeocentricTranslation3D.java
index 6ffa20b55f..359454aa13 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/GeocentricTranslation3D.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/GeocentricTranslation3D.java
@@ -45,6 +45,7 @@ public final class GeocentricTranslation3D extends 
GeocentricAffineBetweenGeogra
                 .addName("Geocentric translations (geog3D domain)")
                 
.createGroupWithSameParameters(GeocentricTranslation2D.PARAMETERS);
     }
+
     /**
      * The canonical instance of this operation method.
      *
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Geographic2Dto3D.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Geographic2Dto3D.java
index 09c385a610..db79328b9b 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Geographic2Dto3D.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Geographic2Dto3D.java
@@ -26,6 +26,8 @@ import org.opengis.referencing.operation.MathTransform;
 import org.apache.sis.metadata.iso.citation.Citations;
 import org.apache.sis.parameter.ParameterBuilder;
 import org.apache.sis.util.privy.Constants;
+import org.apache.sis.parameter.Parameters;
+import org.apache.sis.measure.Units;
 
 
 /**
@@ -39,6 +41,7 @@ import org.apache.sis.util.privy.Constants;
  * @author  Martin Desruisseaux (Geomatys)
  *
  * @see Geographic3Dto2D
+ * @see Spherical2Dto3D
  */
 @XmlTransient
 public final class Geographic2Dto3D extends AbstractProvider {
@@ -47,6 +50,11 @@ public final class Geographic2Dto3D extends AbstractProvider 
{
      */
     private static final long serialVersionUID = -1198461394243672064L;
 
+    /**
+     * The name used by Apache <abbr>SIS</abbr> for this operation method.
+     */
+    public static final String NAME = "Geographic2D to 3D conversion";
+
     /**
      * The ellipsoidal height to set.
      *
@@ -56,7 +64,12 @@ public final class Geographic2Dto3D extends AbstractProvider 
{
      *   <tr><td> SIS:     </td><td> height </td></tr>
      * </table>
      */
-    public static final ParameterDescriptor<Double> HEIGHT;
+    private static final ParameterDescriptor<Double> HEIGHT;
+
+    /**
+     * The default height value.
+     */
+    public static final double DEFAULT_HEIGHT = 0;
 
     /**
      * The group of all parameters expected by this coordinate operation.
@@ -64,8 +77,8 @@ public final class Geographic2Dto3D extends AbstractProvider {
     public static final ParameterDescriptorGroup PARAMETERS;
     static {
         final ParameterBuilder builder = builder().setCodeSpace(Citations.SIS, 
Constants.SIS);
-        HEIGHT = createShift(builder.addName("height"));
-        PARAMETERS = builder.addName("Geographic2D to 3D 
conversion").createGroup(HEIGHT);
+        HEIGHT = builder.addName("height").create(DEFAULT_HEIGHT, Units.METRE);
+        PARAMETERS = builder.addName(NAME).createGroup(HEIGHT);
     }
 
     /**
@@ -118,7 +131,8 @@ public final class Geographic2Dto3D extends 
AbstractProvider {
     }
 
     /**
-     * Returns the transform.
+     * Creates the transform adding a constant ellipsoidal height.
+     * The parameter value is unconditionally converted to metres.
      *
      * @param  context  the parameter values together with its context.
      * @return the math transform for the given parameter values.
@@ -126,8 +140,10 @@ public final class Geographic2Dto3D extends 
AbstractProvider {
      */
     @Override
     public MathTransform createMathTransform(final Context context) throws 
FactoryException {
+        final Parameters pg = 
Parameters.castOrWrap(context.getCompletedParameters());
         return Geographic3Dto2D.createMathTransform(context,
                 context.getSourceDimensions().orElse(2),
-                context.getTargetDimensions().orElse(3));
+                context.getTargetDimensions().orElse(3),
+                pg.doubleValue(HEIGHT));
     }
 }
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Geographic3Dto2D.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Geographic3Dto2D.java
index 9fa4933f14..9286ff48bc 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Geographic3Dto2D.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Geographic3Dto2D.java
@@ -41,6 +41,7 @@ import org.apache.sis.io.wkt.Formatter;
  * @author  Martin Desruisseaux (Geomatys)
  *
  * @see Geographic2Dto3D
+ * @see Spherical3Dto2D
  */
 @XmlTransient
 public final class Geographic3Dto2D extends AbstractProvider {
@@ -49,11 +50,16 @@ public final class Geographic3Dto2D extends 
AbstractProvider {
      */
     private static final long serialVersionUID = -9103595336196565505L;
 
+    /**
+     * The <abbr>EPSG</abbr> name used for this operation method.
+     */
+    public static final String NAME = "Geographic3D to 2D conversion";
+
     /**
      * The group of all parameters expected by this coordinate operation (in 
this case, none).
      */
     public static final ParameterDescriptorGroup PARAMETERS = builder()
-            .addIdentifier("9659").addName("Geographic3D to 2D 
conversion").createGroup();
+            .addIdentifier("9659").addName(NAME).createGroup();
 
     /**
      * The canonical instance of this operation method.
@@ -122,13 +128,14 @@ public final class Geographic3Dto2D extends 
AbstractProvider {
     public MathTransform createMathTransform(final Context context) throws 
FactoryException {
         return createMathTransform(context,
                 context.getSourceDimensions().orElse(3),
-                context.getTargetDimensions().orElse(2));
+                context.getTargetDimensions().orElse(2),
+                Geographic2Dto3D.DEFAULT_HEIGHT);
     }
 
     /**
      * Implementation of {@link #createMathTransform(Context)} shared by 
{@link Geographic2Dto3D}.
      */
-    static MathTransform createMathTransform(final Context context, int 
sourceDimensions, int targetDimensions)
+    static MathTransform createMathTransform(final Context context, int 
sourceDimensions, int targetDimensions, final double height)
             throws FactoryException
     {
         final boolean inverse = (sourceDimensions > targetDimensions);
@@ -138,13 +145,13 @@ public final class Geographic3Dto2D extends 
AbstractProvider {
             targetDimensions = swap;
         }
         final MatrixSIS m = Matrices.createDiagonal(targetDimensions + 1, 
sourceDimensions + 1);
-        m.setElement(sourceDimensions, sourceDimensions, 0);    // Here is the 
height value that we want.
-        m.setElement(targetDimensions, sourceDimensions, 1);    // Most be 
last in case the matrix is square.
+        m.setElement(sourceDimensions, sourceDimensions, height);
+        m.setElement(targetDimensions, sourceDimensions, 1);    // Must be 
last in case the matrix is square.
         MathTransform tr = context.getFactory().createAffineTransform(m);
         if (inverse) try {
             tr = tr.inverse();
         } catch (NoninvertibleTransformException e) {
-            throw new FactoryException(e);                          // Should 
never happen.
+            throw new FactoryException(e);                      // Should 
never happen.
         }
         return tr;
     }
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Spherical2Dto3D.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Spherical2Dto3D.java
index 4893b482ab..10c117bff4 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Spherical2Dto3D.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Spherical2Dto3D.java
@@ -44,13 +44,18 @@ public final class Spherical2Dto3D extends AbstractProvider 
{
      */
     private static final long serialVersionUID = -2320384527305401074L;
 
+    /**
+     * The name used by Apache <abbr>SIS</abbr> for this operation method.
+     */
+    public static final String NAME = "Spherical2D to 3D conversion";
+
     /**
      * The group of all parameters expected by this coordinate operation.
      */
     public static final ParameterDescriptorGroup PARAMETERS;
     static {
         final ParameterBuilder builder = builder().setCodeSpace(Citations.SIS, 
Constants.SIS);
-        PARAMETERS = builder.addName("Spherical2D to 3D 
conversion").createGroupForMapProjection();
+        PARAMETERS = builder.addName(NAME).createGroupForMapProjection();
     }
 
     /**
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Spherical3Dto2D.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Spherical3Dto2D.java
index a831f5e455..408b8788d1 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Spherical3Dto2D.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/provider/Spherical3Dto2D.java
@@ -45,13 +45,18 @@ public final class Spherical3Dto2D extends AbstractProvider 
{
      */
     private static final long serialVersionUID = -6165087357029633662L;
 
+    /**
+     * The name used by Apache <abbr>SIS</abbr> for this operation method.
+     */
+    public static final String NAME = "Spherical3D to 2D conversion";
+
     /**
      * The group of all parameters expected by this coordinate operation.
      */
     public static final ParameterDescriptorGroup PARAMETERS;
     static {
         final ParameterBuilder builder = builder().setCodeSpace(Citations.SIS, 
Constants.SIS);
-        PARAMETERS = builder.addName("Spherical3D to 2D 
conversion").createGroupForMapProjection();
+        PARAMETERS = builder.addName(NAME).createGroupForMapProjection();
     }
 
     /**
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/CoordinateSystemTransformBuilder.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/CoordinateSystemTransformBuilder.java
index 1476165cc0..82a6d02100 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/CoordinateSystemTransformBuilder.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/CoordinateSystemTransformBuilder.java
@@ -29,10 +29,17 @@ import org.opengis.referencing.cs.SphericalCS;
 import org.opengis.referencing.cs.PolarCS;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.MathTransformFactory;
+import org.opengis.referencing.operation.OperationMethod;
 import org.opengis.referencing.operation.OperationNotFoundException;
+import org.apache.sis.parameter.Parameterized;
 import org.apache.sis.referencing.cs.AxesConvention;
 import org.apache.sis.referencing.cs.CoordinateSystems;
 import org.apache.sis.referencing.internal.Resources;
+import org.apache.sis.referencing.operation.provider.Affine;
+import org.apache.sis.referencing.operation.provider.Spherical2Dto3D;
+import org.apache.sis.referencing.operation.provider.Spherical3Dto2D;
+import org.apache.sis.referencing.operation.provider.Geographic2Dto3D;
+import org.apache.sis.referencing.operation.provider.Geographic3Dto2D;
 import org.apache.sis.referencing.operation.provider.GeocentricToGeographic;
 import org.apache.sis.referencing.operation.provider.GeographicToGeocentric;
 import org.apache.sis.referencing.privy.WKTUtilities;
@@ -41,6 +48,9 @@ import org.apache.sis.util.resources.Errors;
 
 /**
  * Builder of transforms between coordinate systems.
+ * This class performs only axis swapping, unit conversions and change of 
coordinate system type.
+ * This class does not handle datum shifts. All <abbr>CRS</abbr> associated to 
the <abbr>CS</abbr>
+ * must use the same datum.
  *
  * @author  Martin Desruisseaux (Geomatys)
  */
@@ -51,12 +61,53 @@ final class CoordinateSystemTransformBuilder extends 
MathTransformBuilder {
     private CoordinateSystem source, target;
 
     /**
-     * The ellipsoid of the source or the target.
-     * Only one of the source or target should have an ellipsoid,
-     * because this builder is not for datum change.
+     * The ellipsoid of the source and/or the target. Usually, only one of the 
source or target
+     * is associated to an ellipsoid. If an ellipsoid is specified for both 
source and target,
+     * then it must be the same ellipsoid because this builder is not for 
datum change.
      */
     private Ellipsoid ellipsoid;
 
+    /**
+     * The parameters used for creating the transform, or {@code null} if 
unspecified.
+     *
+     * @see #parameters()
+     * @see #setParameters(MathTransform, OperationMethod, ParameterValueGroup)
+     */
+    private ParameterValueGroup parameters;
+
+    /**
+     * The {@link MathTransform} to use as the source of parameters, or {@code 
null} if none.
+     * This is used as a fallback if {@link #parameters} is null.
+     *
+     * @see #parameters()
+     * @see #setParameters(MathTransform, OperationMethod, ParameterValueGroup)
+     */
+    private Parameterized parameterized;
+
+    /**
+     * A code identifying the type of parameters. Values can be:
+     *
+     * <ul>
+     *   <li>0: parameters are not set.</li>
+     *   <li>1: identity transform.</li>
+     *   <li>2: linear transform.</li>
+     *   <li>3: non-linear transform.</li>
+     * </ul>
+     *
+     * THe {@link #provider} and {@link #parameters} fields should be updated 
together
+     * and only with strictly increasing {@code parameterType} values. If 
parameters
+     * are specified twice for the same type, the first occurrence prevails.
+     *
+     * @see #parameters()
+     * @see #setParameters(MathTransform, OperationMethod, ParameterValueGroup)
+     */
+    private byte parametersType;
+
+    /**
+     * Values for the {@link #parametersType} field.
+     */
+    private static final byte IDENTITY = 1, LINEAR = 2, CONVERSION = 3;
+
     /**
      * Creates a new builder.
      *
@@ -68,6 +119,9 @@ final class CoordinateSystemTransformBuilder extends 
MathTransformBuilder {
 
     /**
      * Sets the source coordinate system.
+     * The ellipsoid shall be either null or the same as the target.
+     *
+     * @throws IllegalStateException if more than one ellipsoid is specified.
      */
     @Override
     public void setSourceAxes(CoordinateSystem cs, Ellipsoid ellipsoid) {
@@ -77,6 +131,9 @@ final class CoordinateSystemTransformBuilder extends 
MathTransformBuilder {
 
     /**
      * Sets the target coordinate system.
+     * The ellipsoid shall be either null or the same as the source.
+     *
+     * @throws IllegalStateException if more than one ellipsoid is specified.
      */
     @Override
     public void setTargetAxes(CoordinateSystem cs, Ellipsoid ellipsoid) {
@@ -85,7 +142,10 @@ final class CoordinateSystemTransformBuilder extends 
MathTransformBuilder {
     }
 
     /**
-     * Sets the ellipsoid if it was not already set.
+     * Sets the unique ellipsoid.
+     *
+     * @param  value  the ellipsoid, or {@code null} if unspecified.
+     * @throws IllegalStateException if more than one ellipsoid is specified.
      */
     private void setEllipsoid(final Ellipsoid value) {
         if (value != null) {
@@ -97,11 +157,31 @@ final class CoordinateSystemTransformBuilder extends 
MathTransformBuilder {
     }
 
     /**
-     * Unsupported operation because this builder has no parameters.
+     * Returns the parameters for creating the transform. If possible, the 
parameter values will be set
+     * to the values actually used, but this is not guaranteed (this is not 
required by method contract).
+     * This information is valid after the {@link #create()} method has been 
invoked.
      */
     @Override
     public ParameterValueGroup parameters() {
-        throw new 
IllegalStateException(Errors.format(Errors.Keys.MissingValueForProperty_1, 
"method"));
+        if (parameters == null) {
+            if (parameterized != null) {
+                parameters = parameterized.getParameterValues();
+            }
+            if (parameters == null) {
+                OperationMethod method = provider;
+                if (method == null) {
+                    method = Affine.provider();
+                }
+                /*
+                 * This is either a parameterless conversion (for example, 
from spherical to Cartesian coordinate system),
+                 * in which case there is no parameters to set, or an affine 
transform. In the latter case, the default is
+                 * the identity transform, which is often correct. But even if 
not exact, an identity affine is still okay
+                 * because this method contract is to provide an initial set 
of parameters to be filled by the user.
+                 */
+                parameters = method.getParameters().createValue();
+            }
+        }
+        return parameters;
     }
 
     /**
@@ -122,16 +202,6 @@ final class CoordinateSystemTransformBuilder extends 
MathTransformBuilder {
         final List<CoordinateSystem> sources = 
CoordinateSystems.getSingleComponents(source);
         final List<CoordinateSystem> targets = 
CoordinateSystems.getSingleComponents(target);
         final int count = sources.size();
-        if (ellipsoid != null && (count | targets.size()) == 1) {
-            final boolean isEllipsoidalSource = (source instanceof 
EllipsoidalCS);
-            if (isEllipsoidalSource != (target instanceof EllipsoidalCS)) {
-                final var context = factory.builder(isEllipsoidalSource ? 
GeographicToGeocentric.NAME
-                                                                        : 
GeocentricToGeographic.NAME);
-                context.setSourceAxes(source, isEllipsoidalSource? ellipsoid : 
null);
-                context.setTargetAxes(target, isEllipsoidalSource ? null : 
ellipsoid);
-                return context.create();
-            }
-        }
         /*
          * Current implementation expects the same number of components, in 
the same order
          * and with the same number of dimensions in each component. A future 
version will
@@ -142,40 +212,90 @@ final class CoordinateSystemTransformBuilder extends 
MathTransformBuilder {
             final int dimension = source.getDimension();
             int firstAffectedCoordinate = 0;
             for (int i=0; i<count; i++) {
-                final CoordinateSystem s = sources.get(i);
-                final CoordinateSystem t = targets.get(i);
-                final int sd = s.getDimension();
-                if (t.getDimension() != sd) {
+                final CoordinateSystem stepSource = sources.get(i);
+                final CoordinateSystem stepTarget = targets.get(i);
+                final int sourceDim = stepSource.getDimension();
+                if (stepTarget.getDimension() != sourceDim) {
                     result = null;
                     break;
                 }
-                final MathTransform subTransform = 
factory.createPassThroughTransform(
-                        firstAffectedCoordinate,
-                        single(s, t),
-                        dimension - (firstAffectedCoordinate + sd));
+                MathTransform step;
+                try {
+                    step = single(stepSource, stepTarget);
+                } catch (IllegalArgumentException | IncommensurableException 
e) {
+                    throw new 
OperationNotFoundException(operationNotFound(stepSource, stepTarget), e);
+                }
+                final int numTrailingCoordinates = dimension - 
(firstAffectedCoordinate + sourceDim);
+                step = 
factory.createPassThroughTransform(firstAffectedCoordinate, step, 
numTrailingCoordinates);
                 if (result == null) {
-                    result = subTransform;
+                    result = step;
                 } else {
-                    result = factory.createConcatenatedTransform(result, 
subTransform);
+                    result = factory.createConcatenatedTransform(result, step);
                 }
-                firstAffectedCoordinate += sd;
+                firstAffectedCoordinate += sourceDim;
             }
         }
-        // If we couldn't process components separately, try with the compound 
CS as a whole.
-        if (result == null) {
+        /*
+         * If we couldn't process components separately, try with the CS as a 
whole.
+         * It may be a `CompoundCS` (in which case we can still apply axis 
swapping)
+         * or it may be standard CS with different number of dimensions.
+         */
+        if (result == null) try {
             result = single(source, target);
+        } catch (IllegalArgumentException | IncommensurableException e) {
+            throw new OperationNotFoundException(operationNotFound(source, 
target), e);
         }
         return unique(result);
     }
 
     /**
      * Implementation of {@code create(…)} for a single component.
-     * This implementation can handle changes of coordinate system type between
+     * This implementation can handle changes of coordinate system type 
between {@link EllipsoidalCS},
      * {@link CartesianCS}, {@link SphericalCS}, {@link CylindricalCS} and 
{@link PolarCS}.
+     *
+     * @param  stepSource  source coordinate system of the step to build.
+     * @param  stepTarget  target coordinate system of the step to build.
+     * @return transform between the given coordinate systems (never null in 
current implementation).
+     * @throws IllegalArgumentException if the <abbr>CS</abbr> are not 
compatible, or axes do not match.
+     * @throws IncommensurableException if the units are not compatible, or 
the conversion is non-linear.
+     * @throws FactoryException if a factory method failed.
      */
     private MathTransform single(final CoordinateSystem stepSource,
-                                 final CoordinateSystem stepTarget) throws 
FactoryException
+                                 final CoordinateSystem stepTarget)
+            throws FactoryException, IncommensurableException
     {
+        /*
+         * Cases that require an ellipsoid. All those cases are delegated to 
another operation method
+         * in the transform factory. The check for axis order and unit of 
measurement will be done by
+         * public methods of the factory, which may invoke this 
`CoordinateSystemTransformBuilder`
+         * recursively but with a different pair of coordinate systems.
+         */
+        if (ellipsoid != null) {
+            if (stepSource instanceof EllipsoidalCS) {
+                if (stepTarget instanceof EllipsoidalCS) {
+                    return addOrRemoveVertical(stepSource, stepTarget, 
Geographic2Dto3D.NAME, Geographic3Dto2D.NAME);
+                }
+                if ((stepTarget instanceof CartesianCS || stepTarget 
instanceof SphericalCS)) {
+                    final var context = 
factory.builder(GeographicToGeocentric.NAME);
+                    context.setSourceAxes(stepSource, ellipsoid);
+                    context.setTargetAxes(stepTarget, null);
+                    return delegate(context);
+                }
+            } else if (stepTarget instanceof EllipsoidalCS) {
+                if ((stepSource instanceof CartesianCS || stepSource 
instanceof SphericalCS)) {
+                    final var context = 
factory.builder(GeocentricToGeographic.NAME);
+                    context.setSourceAxes(stepSource, null);
+                    context.setTargetAxes(stepTarget, ellipsoid);
+                    return delegate(context);
+                }
+            } else if (stepSource instanceof SphericalCS && stepTarget 
instanceof SphericalCS) {
+                return addOrRemoveVertical(stepSource, stepTarget, 
Spherical2Dto3D.NAME, Spherical3Dto2D.NAME);
+            }
+        }
+        /*
+         * Cases that can be done without ellipsoid. Change of axis order and 
unit of measurement
+         * needs to be done here. There is no 
`CoordinateSystemTransformBuilder` recursive calls.
+         */
         int passthrough = 0;
         CoordinateSystemTransform kernel = null;
         if (stepSource instanceof CartesianCS) {
@@ -197,32 +317,122 @@ final class CoordinateSystemTransformBuilder extends 
MathTransformBuilder {
                 passthrough = 1;
             }
         }
-        Exception cause = null;
-        try {
-            if (kernel == null) {
-                return 
factory.createAffineTransform(CoordinateSystems.swapAndScaleAxes(stepSource, 
stepTarget));
-            } else if (stepSource.getDimension() == 
kernel.getSourceDimensions() + passthrough &&
-                       stepTarget.getDimension() == 
kernel.getTargetDimensions() + passthrough)
+        final MathTransform normalized, result;
+        final OperationMethod method;
+        if (kernel == null) {
+            method = Affine.provider();
+            result = 
factory.createAffineTransform(CoordinateSystems.swapAndScaleAxes(stepSource, 
stepTarget));
+            normalized = result;
+        } else {
+            if (stepSource.getDimension() != kernel.getSourceDimensions() + 
passthrough ||
+                stepTarget.getDimension() != kernel.getTargetDimensions() + 
passthrough)
             {
-                final MathTransform tr = (passthrough == 0)
-                        ? kernel.completeTransform(factory)
-                        : kernel.passthrough(factory);
-                final MathTransform before = factory.createAffineTransform(
-                        CoordinateSystems.swapAndScaleAxes(stepSource,
-                        CoordinateSystems.replaceAxes(stepSource, 
AxesConvention.NORMALIZED)));
-                final MathTransform after  = factory.createAffineTransform(
-                        CoordinateSystems.swapAndScaleAxes(
-                        CoordinateSystems.replaceAxes(stepTarget, 
AxesConvention.NORMALIZED), stepTarget));
-                final MathTransform result = 
factory.createConcatenatedTransform(before,
-                                             
factory.createConcatenatedTransform(tr, after));
-                provider = (passthrough == 0 ? kernel.method : 
kernel.method3D);
-                return result;
+                throw new 
OperationNotFoundException(operationNotFound(stepSource, stepTarget));
+            }
+            final MathTransform before, after;
+            if (passthrough == 0) {
+                method     = kernel.method;
+                normalized = kernel.completeTransform(factory);
+            } else {
+                method     = kernel.method3D;
+                normalized = kernel.passthrough(factory);
+            }
+            /*
+             * Adjust for axis order an units of measurement.
+             */
+            before = factory.createAffineTransform(
+                    CoordinateSystems.swapAndScaleAxes(stepSource,
+                    CoordinateSystems.replaceAxes(stepSource, 
AxesConvention.NORMALIZED)));
+            after  = factory.createAffineTransform(
+                    CoordinateSystems.swapAndScaleAxes(
+                    CoordinateSystems.replaceAxes(stepTarget, 
AxesConvention.NORMALIZED), stepTarget));
+            result = factory.createConcatenatedTransform(before,
+                     factory.createConcatenatedTransform(normalized, after));
+        }
+        setParameters(normalized, method, null);
+        return result;
+    }
+
+    /**
+     * Adds or removes the ellipsoidal height or spherical radius dimension.
+     *
+     * @param  stepSource  source coordinate system of the step to build.
+     * @param  stepTarget  target coordinate system of the step to build.
+     * @param  add         the operation method for adding the vertical 
dimension.
+     * @param  remove      the operation method for removing the vertical 
dimension.
+     * @return transform adding or removing a vertical coordinate.
+     * @throws IllegalArgumentException if the <abbr>CS</abbr> are not 
compatible, or axes do not match.
+     * @throws IncommensurableException if the units are not compatible, or 
the conversion is non-linear.
+     * @throws FactoryException if a factory method failed.
+     *
+     * @see 
org.apache.sis.referencing.internal.ParameterizedTransformBuilder#addOrRemoveVertical
+     */
+    private MathTransform addOrRemoveVertical(final CoordinateSystem 
stepSource,
+                                              final CoordinateSystem 
stepTarget,
+                                              final String add, final String 
remove)
+            throws FactoryException, IncommensurableException
+    {
+        final int change = stepTarget.getDimension() - 
stepSource.getDimension();
+        if (change != 0) {
+            final String method = change < 0 ? remove : add;
+            final var context = factory.builder(method);
+            context.setSourceAxes(stepSource, ellipsoid);
+            context.setTargetAxes(stepTarget, ellipsoid);
+            return delegate(context);
+        }
+        // No change in the number of dimensions. Maybe there is axis swapping 
and unit conversions.
+        MathTransform step = 
factory.createAffineTransform(CoordinateSystems.swapAndScaleAxes(stepSource, 
stepTarget));
+        setParameters(step, Affine.provider(), null);
+        return step;
+    }
+
+    /**
+     * Delegates the transform creation to another builder, then remember the 
operation method which was used.
+     *
+     * @param  context  an initialized context on which to invoke the {@code 
create()} method.
+     * @return result of {@code context.create()}.
+     * @throws FactoryException if the given context cannot create the 
transform.
+     */
+    private MathTransform delegate(final MathTransform.Builder context) throws 
FactoryException {
+        final MathTransform step = context.create();
+        setParameters(step, context.getMethod().orElse(null), 
context.parameters());
+        return step;
+    }
+
+    /**
+     * Remembers the operation method and parameters for the given transform.
+     *
+     * @param  result  the transform that has been created.
+     * @param  method  the method, or {@code null} if unspecified.
+     * @param  values  the parameter values, or {@code null} for inferring 
from the method.
+     */
+    private void setParameters(final MathTransform result, final 
OperationMethod method, final ParameterValueGroup values) {
+        final byte type;
+        if (result.isIdentity()) {
+            type = IDENTITY;
+        } else if (MathTransforms.isLinear(result)) {
+            type = LINEAR;
+        } else {
+            type = CONVERSION;
+        }
+        if (parametersType < type) {
+            parametersType = type;
+            provider = method;
+            parameters= values;
+            if (result instanceof Parameterized) {
+                parameterized = (Parameterized) result;
             }
-        } catch (IllegalArgumentException | IncommensurableException e) {
-            cause = e;
         }
-        throw new 
OperationNotFoundException(Resources.format(Resources.Keys.CoordinateOperationNotFound_2,
+    }
+
+    /**
+     * Returns the error message for an operation not found between the 
coordinate systems.
+     */
+    private static String operationNotFound(final CoordinateSystem stepSource,
+                                            final CoordinateSystem stepTarget)
+    {
+        return Resources.format(Resources.Keys.CoordinateOperationNotFound_2,
                 WKTUtilities.toType(CoordinateSystem.class, 
stepSource.getClass()),
-                WKTUtilities.toType(CoordinateSystem.class, 
stepTarget.getClass())), cause);
+                WKTUtilities.toType(CoordinateSystem.class, 
stepTarget.getClass()));
     }
 }
diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/DefaultMathTransformFactory.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/DefaultMathTransformFactory.java
index cf13231218..75f6020366 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/DefaultMathTransformFactory.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/DefaultMathTransformFactory.java
@@ -460,8 +460,8 @@ public class DefaultMathTransformFactory extends 
AbstractFactory implements Math
      * <ol>
      *   <li>Inferring the {@code "semi_major"}, {@code "semi_minor"}, {@code 
"src_semi_major"},
      *       {@code "src_semi_minor"}, {@code "tgt_semi_major"} or {@code 
"tgt_semi_minor"} parameter values
-     *       from the {@link Ellipsoid} associated to the source or target 
CRS, if these parameters are
-     *       not explicitly given and if they are relevant for the coordinate 
operation method.</li>
+     *       from the {@link Ellipsoid} associated to the source or target 
<abbr>CRS</abbr>, if these parameters
+     *       are not explicitly given and if they are relevant for the 
coordinate operation method.</li>
      *   <li>{@linkplain #createConcatenatedTransform Concatenating} the 
parameterized transform
      *       with any other transforms required for performing units changes 
and coordinates swapping.</li>
      * </ol>
diff --git 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/internal/ParameterizedTransformBuilderTest.java
 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/internal/ParameterizedTransformBuilderTest.java
index 45f5c4546e..daa49c56bb 100644
--- 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/internal/ParameterizedTransformBuilderTest.java
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/internal/ParameterizedTransformBuilderTest.java
@@ -47,7 +47,7 @@ public final class ParameterizedTransformBuilderTest extends 
TestCase {
     }
 
     /**
-     * Tests {@link 
DefaultMathTransformFactory#swapAndScaleAxes(MathTransform, 
MathTransformProvider.Context)}
+     * Tests {@link 
ParameterizedTransformBuilder#swapAndScaleAxes(MathTransform)}
      * with different number of dimensions.
      *
      * @throws FactoryException if the transform construction failed.
diff --git 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/CoordinateOperationFinderTest.java
 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/CoordinateOperationFinderTest.java
index be6446c47e..85d7fed458 100644
--- 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/CoordinateOperationFinderTest.java
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/CoordinateOperationFinderTest.java
@@ -29,6 +29,7 @@ import org.opengis.referencing.crs.VerticalCRS;
 import org.opengis.referencing.crs.TemporalCRS;
 import org.opengis.referencing.crs.CompoundCRS;
 import org.opengis.referencing.crs.DerivedCRS;
+import org.opengis.referencing.crs.ProjectedCRS;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.opengis.referencing.operation.CoordinateOperation;
 import org.opengis.referencing.operation.SingleOperation;
@@ -47,6 +48,7 @@ import org.apache.sis.referencing.operation.matrix.Matrices;
 import org.apache.sis.referencing.cs.DefaultCartesianCS;
 import org.apache.sis.referencing.cs.DefaultCoordinateSystemAxis;
 import org.apache.sis.referencing.datum.DefaultEngineeringDatum;
+import org.apache.sis.referencing.crs.DefaultGeocentricCRS;
 import org.apache.sis.referencing.crs.DefaultEngineeringCRS;
 import org.apache.sis.referencing.crs.DefaultCompoundCRS;
 import org.apache.sis.referencing.crs.DefaultDerivedCRS;
@@ -663,7 +665,7 @@ public final class CoordinateOperationFinderTest extends 
MathTransformTestCase {
      */
     @Test
     public void testSphericalToProjection() throws ParseException, 
FactoryException, TransformException {
-        final CoordinateReferenceSystem sourceCRS = parse(
+        final DefaultGeocentricCRS sourceCRS = 
assertInstanceOf(DefaultGeocentricCRS.class, parse(
                 "GEODCRS[\"Mars (2015) / Ocentric\",\n" +
                 "  DATUM[\"Mars (2015)\",\n" +
                 "    ELLIPSOID[\"Mars (2015)\", 3396190, 169.8944472236118,\n" 
+
@@ -677,9 +679,9 @@ public final class CoordinateOperationFinderTest extends 
MathTransformTestCase {
                 "    AXIS[\"planetocentric longitude (V)\", east,\n" +
                 "      ANGLEUNIT[\"degree\", 0.0174532925199433]],\n" +
                 "  ID[\"IAU\", 49902, 2015],\n" +
-                "  REMARK[\"Source of IAU Coordinate systems: 
doi:10.1007/s10569-017-9805-5\"]]");
+                "  REMARK[\"Source of IAU Coordinate systems: 
doi:10.1007/s10569-017-9805-5\"]]"));
 
-        final CoordinateReferenceSystem targetCRS = parse(
+        final ProjectedCRS targetCRS = assertInstanceOf(ProjectedCRS.class, 
parse(
                 "PROJCRS[\"Mars (2015) / Ocentric / Equirectangular, clon = 
0\",\n" +
                 "  BASEGEODCRS[\"Mars (2015) / Ocentric\",\n" +
                 "    DATUM[\"Mars (2015)\",\n" +
@@ -696,11 +698,23 @@ public final class CoordinateOperationFinderTest extends 
MathTransformTestCase {
                 "      LENGTHUNIT[\"metre\", 1]],\n" +
                 "    AXIS[\"Northing (N)\", north,\n" +
                 "      LENGTHUNIT[\"metre\", 1]],\n" +
-                "  ID[\"IAU\", 49912, 2015]]");
+                "  ID[\"IAU\", 49912, 2015]]"));
 
         final CoordinateOperation operation = 
finder().createOperation(sourceCRS, targetCRS);
         assertSame(sourceCRS, operation.getSourceCRS());
         assertSame(targetCRS, operation.getTargetCRS());
+        /*
+         * The result of coordinate operations below have have not been 
verified by an external source.
+         * We test "geographic to projected" before to test "spherical to 
projected" for verifying that
+         * the "spherical to geographic" conversion has been inserted.
+         */
+        tolerance = 1E-2;
+        transform = targetCRS.getConversionFromBase().getMathTransform();
+        verifyTransform(new double[] {40, 120}, new double[] {7112963.70, 
2349260.02});
+
+        transform = operation.getMathTransform();
+        verifyTransform(new double[] {40, 120}, new double[] {7112963.70, 
2368936.27});
+        validate();
     }
 
 


Reply via email to