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

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

commit 237edefa4ccf2cbe2e2aac504cbd4407c4a7f697
Merge: 975aed6c18 eb43834def
Author: Martin Desruisseaux <martin.desruisse...@geomatys.com>
AuthorDate: Mon Jul 8 16:58:12 2024 +0200

    Merge branch 'geoapi-3.1'

 .../geometry/wrapper/SpatialOperationContext.java  |   4 +-
 .../org/apache/sis/util/iso/AbstractFactory.java   |  22 +-
 .../main/org/apache/sis/io/wkt/Element.java        |   6 +-
 .../org/apache/sis/io/wkt/MathTransformParser.java |  15 +-
 .../org/apache/sis/parameter/TensorParameters.java |  12 +-
 .../main/org/apache/sis/referencing/CRS.java       |   3 +-
 .../sis/referencing/MultiRegisterOperations.java   | 396 +++++++++
 .../referencing/factory/IdentifiedObjectSet.java   |   5 +-
 .../factory/MultiAuthoritiesFactory.java           |  22 +-
 .../referencing/factory/sql/EPSGDataAccess.java    |  11 +-
 .../internal/ParameterizedTransformBuilder.java    | 877 +++++++++++++++++++
 .../operation/AbstractSingleOperation.java         |  11 +-
 .../operation/CoordinateOperationFinder.java       |  44 +-
 .../operation/CoordinateOperationRegistry.java     |  83 +-
 .../operation/DefaultConcatenatedOperation.java    |  13 +-
 .../referencing/operation/DefaultConversion.java   |  74 +-
 .../DefaultCoordinateOperationFactory.java         |  25 +-
 .../operation/LooselyDefinedMethod.java            |   3 -
 .../operation/MathTransformContext.java            |  68 +-
 .../sis/referencing/operation/package-info.java    |  14 -
 .../operation/projection/NormalizedProjection.java |   3 +-
 .../operation/projection/package-info.java         |   5 +-
 .../operation/provider/AbstractProvider.java       |   2 +-
 .../operation/provider/GeographicToGeocentric.java |   7 +-
 .../transform/CoordinateSystemTransform.java       | 153 +---
 .../CoordinateSystemTransformBuilder.java          | 259 ++++++
 .../transform/DefaultMathTransformFactory.java     | 955 +++++----------------
 .../operation/transform/MathTransformBuilder.java  | 196 +++++
 .../operation/transform/MathTransformProvider.java |  61 ++
 .../referencing/privy/CoordinateOperations.java    | 116 ++-
 .../privy/ReferencingFactoryContainer.java         |   9 +-
 .../referencing/privy/ReferencingUtilities.java    |  59 --
 .../ParameterizedTransformBuilderTest.java         | 116 +++
 .../projection/MapProjectionTestCase.java          |   3 +-
 .../operation/provider/GeographicOffsetsTest.java  |  13 +-
 .../transform/CoordinateSystemTransformTest.java   |  42 +-
 .../transform/DefaultMathTransformFactoryTest.java |  70 --
 .../transform/MathTransformFactoryBase.java        |   1 +
 .../transform/MathTransformFactoryMock.java        |  13 +-
 .../main/org/apache/sis/util/privy/Constants.java  |   5 +
 .../sis/storage/shapefile/ShapefileStore.java      |  27 +-
 .../apache/sis/storage/shapefile/dbf/DBFField.java |  22 +-
 42 files changed, 2539 insertions(+), 1306 deletions(-)

diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/Element.java
index d572f2d97e,d572f2d97e..e560c5e22a
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/Element.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/Element.java
@@@ -406,8 -406,8 +406,10 @@@ final class Element 
       * @return the exception to be thrown.
       */
      final ParseException parseFailed(final Exception cause) {
--        return new UnparsableObjectException(errorLocale, 
Resources.Keys.CannotParseElement_2,
--                new String[] {keyword, Exceptions.getLocalizedMessage(cause, 
errorLocale)}, offset).initCause(cause);
++        return new UnparsableObjectException(Resources.forLocale(errorLocale)
++                .getString(Resources.Keys.CannotParseElement_2, new String[] {
++                    keyword, Exceptions.getLocalizedMessage(cause, 
errorLocale)
++                }), offset).initCause(cause);
      }
  
      /**
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/MathTransformParser.java
index 114268ab85,0226437ebc..f2869be02d
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/MathTransformParser.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/MathTransformParser.java
@@@ -47,6 -47,6 +47,10 @@@ import org.apache.sis.math.DecimalFunct
  import org.apache.sis.measure.UnitFormat;
  import org.apache.sis.measure.Units;
  
++// Specific to the main branch:
++import org.apache.sis.referencing.privy.CoordinateOperations;
++import org.apache.sis.referencing.operation.transform.MathTransformBuilder;
++
  
  /**
   * Well Known Text (WKT) parser for {@linkplain MathTransform math 
transform}s.
@@@ -415,10 -415,9 +419,9 @@@ class MathTransformParser extends Abstr
              return null;
          }
          classification = element.pullString("classification");
-         final MathTransformFactory mtFactory = 
factories.getMathTransformFactory();
-         final ParameterValueGroup parameters;
 -        final MathTransform.Builder builder;
++        final MathTransformBuilder builder;
          try {
-             parameters = mtFactory.getDefaultParameters(classification);
 -            builder = 
factories.getMathTransformFactory().builder(classification);
++            builder = 
CoordinateOperations.builder(factories.getMathTransformFactory(), 
classification);
          } catch (NoSuchIdentifierException exception) {
              throw element.parseFailed(exception);
          }
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/parameter/TensorParameters.java
index ad75d7e7ca,a69f5e35f1..e572812150
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/parameter/TensorParameters.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/parameter/TensorParameters.java
@@@ -699,23 -699,19 +699,19 @@@ public class TensorParameters<E> implem
       *     <th>Property name</th>
       *     <th>Value type</th>
       *     <th>Returned by</th>
-      *   </tr>
-      *   <tr>
+      *   </tr><tr>
       *     <td>{@value org.opengis.referencing.IdentifiedObject#NAME_KEY}</td>
 -     *     <td>{@link org.opengis.metadata.Identifier} or {@link String}</td>
 +     *     <td>{@link org.opengis.referencing.ReferenceIdentifier} or {@link 
String}</td>
       *     <td>{@link DefaultParameterDescriptorGroup#getName()}</td>
-      *   </tr>
-      *   <tr>
+      *   </tr><tr>
       *     <td>{@value 
org.opengis.referencing.IdentifiedObject#ALIAS_KEY}</td>
       *     <td>{@link org.opengis.util.GenericName} or {@link CharSequence} 
(optionally as array)</td>
       *     <td>{@link DefaultParameterDescriptorGroup#getAlias()}</td>
-      *   </tr>
-      *   <tr>
+      *   </tr><tr>
       *     <td>{@value 
org.opengis.referencing.IdentifiedObject#IDENTIFIERS_KEY}</td>
 -     *     <td>{@link org.opengis.metadata.Identifier} (optionally as 
array)</td>
 +     *     <td>{@link org.opengis.referencing.ReferenceIdentifier} 
(optionally as array)</td>
       *     <td>{@link DefaultParameterDescriptorGroup#getIdentifiers()}</td>
-      *   </tr>
-      *   <tr>
+      *   </tr><tr>
       *     <td>{@value 
org.opengis.referencing.IdentifiedObject#REMARKS_KEY}</td>
       *     <td>{@link org.opengis.util.InternationalString} or {@link 
String}</td>
       *     <td>{@link DefaultParameterDescriptorGroup#getRemarks()}</td>
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/MultiRegisterOperations.java
index 0000000000,db0cdca8e4..d1be8b9426
mode 000000,100644..100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/MultiRegisterOperations.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/MultiRegisterOperations.java
@@@ -1,0 -1,445 +1,396 @@@
+ /*
+  * Licensed to the Apache Software Foundation (ASF) under one or more
+  * contributor license agreements.  See the NOTICE file distributed with
+  * this work for additional information regarding copyright ownership.
+  * The ASF licenses this file to You under the Apache License, Version 2.0
+  * (the "License"); you may not use this file except in compliance with
+  * the License.  You may obtain a copy of the License at
+  *
+  *     http://www.apache.org/licenses/LICENSE-2.0
+  *
+  * Unless required by applicable law or agreed to in writing, software
+  * distributed under the License is distributed on an "AS IS" BASIS,
+  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  * See the License for the specific language governing permissions and
+  * limitations under the License.
+  */
+ package org.apache.sis.referencing;
+ 
+ import java.util.Map;
+ import java.util.Set;
+ import java.util.List;
+ import java.util.Iterator;
+ import java.util.AbstractSet;
+ import java.util.Objects;
+ import java.util.Optional;
+ import org.opengis.util.Factory;
+ import org.opengis.util.FactoryException;
 -import org.opengis.util.InternationalString;
+ import org.opengis.metadata.citation.Citation;
+ import org.opengis.metadata.extent.GeographicBoundingBox;
+ import org.opengis.referencing.IdentifiedObject;
+ import org.opengis.referencing.AuthorityFactory;
 -import org.opengis.referencing.RegisterOperations;
 -import org.opengis.referencing.crs.SingleCRS;
+ import org.opengis.referencing.crs.CRSFactory;
+ import org.opengis.referencing.crs.CRSAuthorityFactory;
+ import org.opengis.referencing.crs.CoordinateReferenceSystem;
+ import org.opengis.referencing.cs.CSFactory;
+ import org.opengis.referencing.cs.CSAuthorityFactory;
+ import org.opengis.referencing.datum.DatumFactory;
+ import org.opengis.referencing.datum.DatumAuthorityFactory;
+ import org.opengis.referencing.operation.CoordinateOperation;
+ import org.opengis.referencing.operation.CoordinateOperationFactory;
+ import org.opengis.referencing.operation.CoordinateOperationAuthorityFactory;
+ import org.opengis.referencing.operation.MathTransformFactory;
+ import org.apache.sis.referencing.factory.GeodeticObjectFactory;
+ import org.apache.sis.referencing.factory.MultiAuthoritiesFactory;
+ import org.apache.sis.referencing.factory.NoSuchAuthorityFactoryException;
+ import org.apache.sis.referencing.operation.DefaultCoordinateOperationFactory;
+ import 
org.apache.sis.referencing.operation.transform.DefaultMathTransformFactory;
 -import org.apache.sis.util.Utilities;
+ import org.apache.sis.util.logging.Logging;
+ import org.apache.sis.util.resources.Errors;
+ import org.apache.sis.util.iso.AbstractFactory;
+ 
+ 
+ /**
+  * Finds <abbr>CRS</abbr>s or coordinate operations in one or many geodetic 
registries.
+  * Each {@code MultiRegisterOperations} instance can narrow the search to a 
single registry,
+  * a specific version of that registry, or to a domain of validity.
+  * Each instance is immutable and thread-safe.
+  *
+  * <p>This class delegates its work to {@linkplain CRS#forCode(String) static 
methods} or to
+  * {@link MultiAuthoritiesFactory}. It does not provide new services compared 
to the above,
+  * but provides a more high-level <abbr>API</abbr> with the most important 
registry-based
+  * services in a single place. {@link RegisterOperations} can also be used as 
en entry point,
+  * with accesses to the low-level <abbr>API</abbr> granted by {@link 
#getFactory(Class)}.</p>
+  *
+  * <h2>User-defined geodetic registries</h2>
+  * User-defined authorities can be added to the SIS environment by creating 
{@link CRSAuthorityFactory}
+  * implementations with a public no-argument constructor or a public static 
{@code provider()} method,
+  * and declaring the name of those classes in the {@code module-info.java} 
file as a provider of the
+  * {@code org.opengis.referencing.crs.CRSAuthorityFactory} service.
+  *
+  * @author  Martin Desruisseaux (Geomatys)
+  * @version 1.5
+  * @since   1.5
+  */
 -public class MultiRegisterOperations extends AbstractFactory implements 
RegisterOperations {
++public class MultiRegisterOperations extends AbstractFactory {
+     /**
+      * Types of factories supported by this implementation.
+      * A value of {@code true} means that the factory is an authority factory.
+      * A value of {@code false} means that the factory is an object factory.
+      *
+      * @see #getFactory(Class)
+      */
+     private static final Map<Class<?>, Boolean> FACTORY_TYPES = Map.of(
+             CoordinateOperationAuthorityFactory.class, Boolean.TRUE,
+             DatumAuthorityFactory.class, Boolean.TRUE,
+             CRSAuthorityFactory.class,   Boolean.TRUE,
+             CSAuthorityFactory.class,    Boolean.TRUE,
+             DatumFactory.class,          Boolean.FALSE,
+             CRSFactory.class,            Boolean.FALSE,
+             CSFactory.class,             Boolean.FALSE);
+ 
+     /**
+      * The authority of the <abbr>CRS</abbr> and coordinate operations to 
search,
+      * or {@code null} for all authorities. In the latter case, the authority 
must
+      * be specified in the code, for example {@code "EPSG:4326"} instead of 
"4326".
+      *
+      * @see #withAuthority(String)
+      */
+     private final String authority;
+ 
+     /**
+      * Version of the registry to use, or {@code null} for the default 
version.
+      * Can be non-null only if {@link #authority} is also non-null.
+      * If null, the default version is usually the latest one.
+      *
+      * @see #withVersion(String)
+      */
+     private final String version;
+ 
+     /**
+      * The area of interest for coordinate operations, or {@code null} for 
the whole world.
+      *
+      * @see #withAreaOfInterest(GeographicBoundingBox)
+      */
+     private final GeographicBoundingBox areaOfInterest;
+ 
+     /**
+      * The authority factory to use for extracting <abbr>CRS</abbr> 
instances, or {@code null} if no
+      * authority has been specified. In the latter case, {@link CRS} static 
methods should be used.
+      * In Apache SIS implementation, this is also an {@link 
CoordinateOperationAuthorityFactory}.
+      *
+      * @see #findCoordinateReferenceSystem(String)
+      * @see #findCoordinateOperation(String)
+      */
+     private final CRSAuthorityFactory crsFactory;
+ 
+     /**
+      * The singleton instance for all authorities in their default versions, 
with no <abbr>AOI</abbr>.
+      *
+      * @see #provider()
+      */
+     private static final MultiRegisterOperations DEFAULT = new 
MultiRegisterOperations();
+ 
+     /**
+      * Returns an instance which will search <abbr>CRS</abbr> definitions in 
all registries that are known to SIS.
+      * Because this instance is not for a specific registry, the authority 
will need to be part of the {@code code}
+      * argument given to {@code create(String)} methods. For example, {@code 
"EPSG:4326"} instead of {@code "4326"}.
+      * The registry can be made implicit by a call to {@link 
#withAuthority(String)}.
+      *
+      * @return the default instance for all registries known to SIS.
+      */
+     public static MultiRegisterOperations provider() {
+         return DEFAULT;
+     }
+ 
+     /**
+      * Creates an instance which will search <abbr>CRS</abbr> definitions in 
all registries that are known to SIS.
+      *
+      * @see #provider()
+      */
+     private MultiRegisterOperations() {
+         authority      = null;
+         version        = null;
+         crsFactory     = null;
+         areaOfInterest = null;
+     }
+ 
+     /**
+      * Creates an instance with the same register than the given instance, 
but a different <abbr>AOI</abbr>.
+      *
+      * @param source          the register from which to copy the authority 
and version.
+      * @param areaOfInterest  the new area of interest (<abbr>AOI</abbr>), or 
{@code null} if none.
+      *
+      * @see #withAreaOfInterest(GeographicBoundingBox)
+      */
+     protected MultiRegisterOperations(final MultiRegisterOperations source, 
final GeographicBoundingBox areaOfInterest) {
+         authority  = source.authority;
+         version    = source.version;
+         crsFactory = source.crsFactory;
+         this.areaOfInterest = areaOfInterest;
+     }
+ 
+     /**
+      * Creates an instance which will use the registry of the specified 
authority, optionally at a specified version.
+      *
+      * @param source     the register from which to copy the area of interest.
+      * @param authority  identification of the registry to use (e.g., "EPSG").
+      * @param version    the registry version to use, or {@code null} for the 
default version.
+      * @throws NoSuchAuthorityFactoryException if the specified registry has 
not been found.
+      *
+      * @see #withAuthority(String)
+      * @see #withVersion(String)
+      */
+     protected MultiRegisterOperations(final MultiRegisterOperations source, 
final String authority, final String version)
+             throws NoSuchAuthorityFactoryException
+     {
+         this.authority      = Objects.requireNonNull(authority);
+         this.version        = version;
+         this.areaOfInterest = source.areaOfInterest;
+         crsFactory = 
AuthorityFactories.ALL.getAuthorityFactory(CRSAuthorityFactory.class, 
authority, version);
+     }
+ 
+     /**
+      * Returns the <abbr>CRS</abbr> authority factory.
+      */
+     private CRSAuthorityFactory crsFactory() {
+         return (crsFactory != null) ? crsFactory : AuthorityFactories.ALL;
+     }
+ 
+     /**
+      * Returns the organization or party responsible for definition and 
maintenance of the register.
+      * If an authority has been specified by a call to {@link 
#withAuthority(String)}, then this method
+      * returns that authority. Otherwise, this method returns {@code null}.
+      *
+      * @return the organization responsible for definitions in the registry, 
or {@code null} if none or many.
+      *
+      * @see MultiAuthoritiesFactory#getAuthority()
+      */
 -    @Override
+     public Citation getAuthority() {
+         return crsFactory().getAuthority();
+     }
+ 
+     /**
+      * Returns an instance for a geodetic registry of the specified 
authority, such as "EPSG".
+      * If a {@linkplain #withVersion(String) version number was specified} 
previously, that version is cleared.
+      * If an area of interest was specified, the same area of interest is 
reused.
+      *
+      * <h2>User-defined geodetic registries</h2>
+      * A user-defined authority can be specified if the implementation is 
declared in a {@code module-info}
+      * file as a {@link CRSAuthorityFactory} service. See class javadoc for 
more information.
+      *
+      * @param  newValue  the desired authority, or {@code null} for all of 
them.
+      * @return register operations for the specified authority.
+      * @throws NoSuchAuthorityFactoryException if the given authority is 
unknown to SIS.
+      *
+      * @see CRS#getAuthorityFactory(String)
+      */
+     public MultiRegisterOperations withAuthority(final String newValue) 
throws NoSuchAuthorityFactoryException {
+         if (version == null && Objects.equals(authority, newValue)) {
+             return this;
+         } else if (newValue == null) {
+             return DEFAULT.withAreaOfInterest(areaOfInterest);
+         } else {
+             return new MultiRegisterOperations(this, newValue, null);
+         }
+     }
+ 
+     /**
+      * Returns an instance for the specified version of the geodetic registry.
+      * A non-null authority must have been {@linkplain #withAuthority(String) 
specified} before to invoke this method.
+      * If an area of interest was specified, the same area of interest is 
reused.
+      *
+      * @param  newValue  the desired version, or {@code null} for the default 
version.
+      * @return register operations for the specified version of the geodetic 
registry.
+      * @throws IllegalStateException if the version is non-null and no 
authority has been specified previously.
+      * @throws NoSuchAuthorityFactoryException if the given version is 
unknown to SIS.
+      */
+     public MultiRegisterOperations withVersion(final String newValue) throws 
NoSuchAuthorityFactoryException {
+         if (Objects.equals(version, newValue)) {
+             return this;
+         } else if (newValue == null && authority == null) {
+             return DEFAULT.withAreaOfInterest(areaOfInterest);
+         } else if (authority != null) {
+             return new MultiRegisterOperations(this, authority, newValue);
+         } else {
+             throw new 
IllegalStateException(Errors.format(Errors.Keys.MissingValueForProperty_1, 
"authority"));
+         }
+     }
+ 
+     /**
+      * Returns an instance for the specified area of interest 
(<abbr>AOI</abbr>).
+      * The area of interest is used for filtering coordinate operations 
between
+      * a {@linkplain #findCoordinateOperations between a pair of CRSs}.
+      *
+      * @param  newValue  the desired area of interest, or {@code null} for 
the world.
+      * @return register operations for the specified area of interest.
+      */
+     public MultiRegisterOperations withAreaOfInterest(final 
GeographicBoundingBox newValue) {
+         if (Objects.equals(areaOfInterest, newValue)) {
+             return this;
+         } else if (newValue == null && authority == null && version == null) {
+             return DEFAULT;
+         } else {
+             return new MultiRegisterOperations(this, newValue);
+         }
+     }
+ 
+     /**
+      * Returns the set of authority codes for objects of the given type.
+      * The {@code type} argument specifies the base type of identified 
objects.
+      * For example, {@code CoordinateReferenceSystem.class} is for requesting 
the <abbr>CRS</abbr> codes.
+      *
+      * <h4>Limitations</h4>
+      * In the current implementation, codes are filtered by authority and 
registry version,
+      * but not for the area of interest.
+      *
+      * @param  type  the type of referencing object for which to get 
authority codes.
+      * @return the set of authority codes for referencing objects of the 
given type.
+      * @throws FactoryException if access to the underlying database failed.
+      */
 -    @Override
+     public Set<String> getAuthorityCodes(Class<? extends IdentifiedObject> 
type) throws FactoryException {
+         return crsFactory().getAuthorityCodes(type);
+     }
+ 
 -    /**
 -     * Returns a textual description of the object corresponding to a code.
 -     * The description may be used in graphical user interfaces.
 -     *
 -     * @param  type  the type of object for which to get a description.
 -     * @param  code  value allocated by the authority for an object of the 
given type.
 -     * @return a description of the object, or empty if the object has no 
description.
 -     * @throws NoSuchAuthorityCodeException if the specified {@code code} was 
not found.
 -     * @throws FactoryException if the query failed for some other reason.
 -     */
 -    @Override
 -    public Optional<InternationalString> getDescriptionText(Class<? extends 
IdentifiedObject> type, String code)
 -            throws FactoryException
 -    {
 -        return crsFactory().getDescriptionText(type, code);
 -    }
 -
+     /**
+      * Extracts <abbr>CRS</abbr> details from the registry. If this {@code 
RegisterOperations} has not
+      * been restricted to a specific authority by a call to {@link 
#withAuthority(String)}, then the
+      * given code must contain the authority (e.g., {@code "EPSG:4326"} 
instead of {@code "4326"}.
+      * Otherwise, this method delegates to {@link CRS#forCode(jString)}.
+      *
+      * <p>By default, this method recognizes the {@code "EPSG"} and {@code 
"OGC"} authorities.
+      * In the {@code "EPSG"} case, whether the full set of EPSG codes is 
supported or not depends
+      * on whether a {@linkplain org.apache.sis.referencing.factory.sql 
connection to the database}
+      * can be established. If no connection can be established, then this 
method uses a small embedded
+      * EPSG factory containing at least the CRS defined in the {@link 
#forCode(String)} method javadoc.</p>
+      *
+      * @param  code  <abbr>CRS</abbr> identifier allocated by the authority.
+      * @return the <abbr>CRS</abbr> for the given authority code.
+      * @throws NoSuchAuthorityCodeException if the specified {@code code} was 
not found.
+      * @throws FactoryException if the search failed for some other reason.
+      *
+      * @see CRS#forCode(String)
+      */
 -    @Override
+     public CoordinateReferenceSystem findCoordinateReferenceSystem(final 
String code) throws FactoryException {
+         if (crsFactory != null) {
+             return crsFactory.createCoordinateReferenceSystem(code);
+         }
+         return CRS.forCode(code);
+     }
+ 
+     /**
+      * Extracts coordinate operation details from the registry. If this 
{@code RegisterOperations}
+      * has not been restricted to a specific authority by a call to {@link 
#withAuthority(String)},
+      * then the given code must contain the authority.
+      *
+      * @param  code  operation identifier allocated by the authority.
+      * @return the operation for the given authority code.
+      * @throws NoSuchAuthorityCodeException if the specified {@code code} was 
not found.
+      * @throws FactoryException if the search failed for some other reason.
+      */
 -    @Override
+     public CoordinateOperation findCoordinateOperation(String code) throws 
FactoryException {
+         if (crsFactory instanceof CoordinateOperationAuthorityFactory) {
+             ((CoordinateOperationAuthorityFactory) 
crsFactory).createCoordinateOperation(code);
+         }
+         return AuthorityFactories.ALL.createCoordinateOperation(code);
+     }
+ 
+     /**
+      * Finds or infers any coordinate operations for which the given 
<abbr>CRS</abbr>s are the source and target,
+      * in that order. This method searches for operation paths defined in the 
registry.
+      * If none are found, this method tries to infer a path itself.
+      *
+      * @param  source  the source <abbr>CRS</abbr>.
+      * @param  target  the target <abbr>CRS</abbr>.
+      * @return coordinate operations found or inferred between the given pair 
<abbr>CRS</abbr>s. May be an empty set.
+      * @throws FactoryException if an error occurred while searching for 
coordinate operations.
+      */
 -    @Override
+     public Set<CoordinateOperation> 
findCoordinateOperations(CoordinateReferenceSystem source, 
CoordinateReferenceSystem target)
+             throws FactoryException
+     {
+         final List<CoordinateOperation> operations = 
CRS.findOperations(source, target, areaOfInterest);
+         return new AbstractSet<>() {    // Assuming that the list does not 
contain duplicated elements.
+             @Override public Iterator<CoordinateOperation> iterator() {return 
operations.iterator();}
+             @Override public boolean isEmpty() {return operations.isEmpty();}
+             @Override public int size() {return operations.size();}
+         };
+     }
+ 
 -    /**
 -     * Determines whether two <abbr>CRS</abbr>s are members of one ensemble.
 -     * If this method returns {@code true}, then for low accuracy purposes 
coordinate sets referenced
 -     * to these <abbr>CRS</abbr>s may be merged without coordinate 
transformation.
 -     * The attribute {@link DatumEnsemble#getEnsembleAccuracy()} gives some 
indication
 -     * of the inaccuracy introduced through such merger.
 -     *
 -     * @param  source  the source <abbr>CRS</abbr>.
 -     * @param  target  the target <abbr>CRS</abbr>.
 -     * @return whether the two <abbr>CRS</abbr>s are members of one ensemble.
 -     * @throws FactoryException if an error occurred while searching for 
ensemble information in the registry.
 -     */
 -    @Override
 -    public boolean areMembersOfSameEnsemble(CoordinateReferenceSystem source, 
CoordinateReferenceSystem target)
 -            throws FactoryException
 -    {
 -        return (source instanceof SingleCRS) && (target instanceof SingleCRS)
 -                && Utilities.equalsIgnoreMetadata(
 -                        ((SingleCRS) source).getDatumEnsemble(),
 -                        ((SingleCRS) target).getDatumEnsemble());
 -    }
 -
+     /**
+      * Returns a factory used for building components of <abbr>CRS</abbr> or 
coordinate operations.
+      * The factories returned by this method provide accesses to the 
low-level services used by this
+      * {@code RegisterOperations} instance for implementing its high-level 
services.
+      *
+      * @param  <T>   compile-time value of the {@code type} argument.
+      * @param  type  the desired type of factory.
+      * @return factory of the specified type.
+      * @throws NullPointerException if the specified type is null.
+      * @throws IllegalArgumentException if the specified type is not one of 
the above-cited values.
+      */
 -    @Override
+     public <T extends Factory> Optional<T> getFactory(final Class<? extends 
T> type) {
+         final Factory factory;
+         final Boolean b = FACTORY_TYPES.get(type);
+         if (b != null) {
+             if (b) {
+                 final MultiAuthoritiesFactory mf = AuthorityFactories.ALL;
+                 if (authority == null) {
+                     factory = mf;
+                 } else try {
+                     factory = 
mf.getAuthorityFactory(type.asSubclass(AuthorityFactory.class), authority, 
version);
+                 } catch (NoSuchAuthorityFactoryException e) {
+                     Logging.recoverableException(AuthorityFactories.LOGGER, 
MultiRegisterOperations.class, "getFactory", e);
+                     return Optional.empty();
+                 }
+             } else {
+                 factory = GeodeticObjectFactory.provider();
+             }
+         } else if (type == CoordinateOperationFactory.class) {
+             factory = DefaultCoordinateOperationFactory.provider();
+         } else if (type == MathTransformFactory.class) {
+             factory = DefaultMathTransformFactory.provider();
+         } else {
+             throw new 
IllegalArgumentException(Errors.format(Errors.Keys.IllegalArgumentValue_2, 
"type", type));
+         }
+         return Optional.of(type.cast(factory));
+     }
+ }
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/internal/ParameterizedTransformBuilder.java
index 0000000000,d439168dd4..df3041be05
mode 000000,100644..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
@@@ -1,0 -1,880 +1,877 @@@
+ /*
+  * Licensed to the Apache Software Foundation (ASF) under one or more
+  * contributor license agreements.  See the NOTICE file distributed with
+  * this work for additional information regarding copyright ownership.
+  * The ASF licenses this file to You under the Apache License, Version 2.0
+  * (the "License"); you may not use this file except in compliance with
+  * the License.  You may obtain a copy of the License at
+  *
+  *     http://www.apache.org/licenses/LICENSE-2.0
+  *
+  * Unless required by applicable law or agreed to in writing, software
+  * distributed under the License is distributed on an "AS IS" BASIS,
+  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  * See the License for the specific language governing permissions and
+  * limitations under the License.
+  */
+ package org.apache.sis.referencing.internal;
+ 
+ import java.util.Map;
+ import java.util.LinkedHashMap;
+ import java.util.Collections;
+ import java.util.OptionalInt;
+ import java.util.logging.Level;
+ import java.util.logging.LogRecord;
+ import javax.measure.Unit;
+ import javax.measure.IncommensurableException;
+ import javax.measure.quantity.Length;
+ import org.opengis.parameter.ParameterValue;
+ import org.opengis.parameter.ParameterValueGroup;
+ import org.opengis.parameter.ParameterNotFoundException;
+ import org.opengis.referencing.IdentifiedObject;
+ import org.opengis.referencing.cs.CoordinateSystem;
+ import org.opengis.referencing.cs.EllipsoidalCS;
+ import org.opengis.referencing.cs.SphericalCS;
+ 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.referencing.operation.OperationMethod;
+ 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;
+ import org.apache.sis.util.logging.Logging;
+ import org.apache.sis.util.resources.Errors;
+ import org.apache.sis.referencing.IdentifiedObjects;
+ 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.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.factory.InvalidGeodeticParameterException;
+ import org.apache.sis.referencing.privy.CoordinateOperations;
+ import org.apache.sis.referencing.privy.ReferencingUtilities;
+ import org.apache.sis.referencing.privy.Formulas;
+ import org.apache.sis.parameter.Parameterized;
+ import org.apache.sis.parameter.Parameters;
+ import org.apache.sis.measure.Units;
+ 
 -// Specific to the geoapi-3.1 and geoapi-4.0 branches:
 -import org.opengis.util.UnimplementedServiceException;
 -
+ 
+ /**
+  * Builder of a parameterized math transform identified by a name or code.
+  * A builder can optionally contain the source and target coordinate systems
+  * for which a new parameterized transform is going to be used.
+  * {@link DefaultMathTransformFactory} uses this information for:
+  *
+  * <ul>
+  *   <li>Completing some parameters if they were not provided. In particular, 
the source ellipsoid can be used for
+  *       providing values for the {@code "semi_major"} and {@code 
"semi_minor"} parameters in map projections.</li>
+  *   <li>{@linkplain #swapAndScaleAxes Swapping and scaling axes} if the 
source or the target
+  *       coordinate systems are not {@linkplain AxesConvention#NORMALIZED 
normalized}.</li>
+  * </ul>
+  *
+  * Each instance should be used only once. This class is <em>not</em> 
thread-safe.
+  *
+  * @author  Martin Desruisseaux (Geomatys)
+  */
+ public class ParameterizedTransformBuilder extends MathTransformBuilder 
implements MathTransformProvider.Context {
+     /**
+      * Minimal precision of ellipsoid semi-major and semi-minor axis lengths, 
in metres.
+      * If the length difference between the axis of two ellipsoids is greater 
than this threshold,
+      * we will report a mismatch. This is used for logging purpose only and 
do not have any impact
+      * on the {@code MathTransform} objects to be created by the factory.
+      */
+     private static final double ELLIPSOID_PRECISION = 
Formulas.LINEAR_TOLERANCE;
+ 
+     /**
+      * 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.
+      * The instance is returned directly by {@link #parameters()} for 
allowing users to
+      * modify the values in-place. Then, contextual parameters are added the 
first time
+      * that {@link #getCompletedParameters()} is invoked.
+      *
+      * <p>This reference is {@code null} if this builder has been constructed 
without
+      * specifying a method or parameters. In such case, calls to {@link 
#parameters()}
+      * or {@link #getCompletedParameters()} will throw {@link 
IllegalStateException},
+      * unless {@link #setParameters(ParameterValueGroup, boolean)} is 
invoked.</p>
+      */
+     private ParameterValueGroup parameters;
+ 
+     /**
+      * Names of parameters which have been inferred from the context.
+      *
+      * @see #getContextualParameters()
+      */
+     private final Map<String,Boolean> contextualParameters;
+ 
+     /**
+      * Whether the user-specified parameters have been completed with the 
contextual parameters.
+      * This is set to {@code true} the first time that {@link 
#getCompletedParameters()} is invoked.
+      * After this flag become {@code true}, this builder should not be 
modified anymore.
+      *
+      * @see #completeParameters()
+      */
+     private boolean completedParameters;
+ 
+     /**
+      * The warning that occurred during parameters completion, or {@code 
null} if none.
+      * This warning is not always fatal, but will be appended to the 
suppressed exceptions
+      * of {@link FactoryException} if the {@link MathTransform} creation 
nevertheless fail.
+      */
+     private RuntimeException warning;
+ 
+     /**
+      * Creates a new builder for the given operation method.
+      *
+      * @param  factory  factory to use for building the transform.
+      * @param  method   a method known to the given factory, or {@code null} 
if none.
+      */
+     public ParameterizedTransformBuilder(final MathTransformFactory factory, 
final OperationMethod method) {
+         super(factory);
+         if (method != null) {
+             provider   = method;
+             parameters = method.getParameters().createValue();
+         }
+         contextualParameters = new LinkedHashMap<>();
+     }
+ 
+     /**
+      * Replaces the parameters by the given values. If {@code copy} is {@code 
false}, the given parameters
+      * will be used directly and may be modified. If {@code true}, the 
parameters will be copied in a group
+      * created by the provider. The latter group may contain more parameters 
than the given {@code values}.
+      * In particular, the copy may contain parameters such as {@code 
"semi_major"} that may not be present
+      * in the given values.
+      *
+      * @param  values  the parameter values.
+      * @param  copy    whether to copy the given parameter values.
+      * @throws NoSuchIdentifierException if no method has been found for the 
given parameters.
+      * @throws InvalidGeodeticParameterException if the parameters cannot be 
set to the given values.
+      */
+     public void setParameters(final ParameterValueGroup values, final boolean 
copy)
+             throws NoSuchIdentifierException, 
InvalidGeodeticParameterException
+     {
+         provider = CoordinateOperations.findMethod(factory, 
values.getDescriptor());
+         if (copy) try {
+             parameters = provider.getParameters().createValue();
+             Parameters.copy(values, parameters);
+         } catch (IllegalArgumentException e) {
+             throw new InvalidGeodeticParameterException(e.getMessage(), e);
+         } else {
+             parameters = values;
+         }
+     }
+ 
+     /**
+      * Returns the factory given at construction time.
+      *
+      * @return the factory to use for creating the transform.
+      */
+     @Override
+     public final MathTransformFactory getFactory() {
+         return factory;
+     }
+ 
+     /**
+      * Gives input coordinates hints derived from the given <abbr>CRS</abbr>. 
The hints are used for axis order and
+      * units conversions, and for completing parameters with axis lengths. No 
change of <abbr>CRS</abbr> other than
+      * axis order and units are performed. This method is not public for that 
reason.
+      *
+      * @param  crs  the <abbr>CRS</abbr> from which to fetch the hints, or 
{@code null}.
+      */
+     public final void setSourceAxes(final CoordinateReferenceSystem crs) {
+         setSourceAxes(crs != null ? crs.getCoordinateSystem() : null, 
ReferencingUtilities.getEllipsoid(crs));
+     }
+ 
+     /**
+      * Gives output coordinates hints derived from the given 
<abbr>CRS</abbr>. The hints are used for axis order and
+      * units conversions, and for completing parameters with axis lengths. No 
change of <abbr>CRS</abbr> other than
+      * axis order and units are performed. This method is not public for that 
reason.
+      *
+      * @param  crs  the <abbr>CRS</abbr> from which to fetch the hints, or 
{@code null}.
+      */
+     public final void setTargetAxes(final CoordinateReferenceSystem crs) {
+         setTargetAxes(crs != null ? crs.getCoordinateSystem() : null, 
ReferencingUtilities.getEllipsoid(crs));
+     }
+ 
+     /**
+      * Gives hints about axis lengths and their orientations in input 
coordinates.
+      * The {@code ellipsoid} argument is often provided together with an 
{@link EllipsoidalCS}, but not only.
+      * For example, a two-dimensional {@link SphericalCS} may also require 
information about the ellipsoid.
+      *
+      * <p>Each call to this method replaces the values of the previous call.
+      * However, this method cannot be invoked anymore after {@link 
#getCompletedParameters()} has been invoked.</p>
+      *
+      * @param  cs  the coordinate system defining source axis order and 
units, or {@code null} if none.
+      * @param  ellipsoid  the ellipsoid providing source semi-axis lengths, 
or {@code null} if none.
+      * @throws IllegalStateException if {@link #getCompletedParameters()} has 
already been invoked.
+      */
+     @Override
+     public void setSourceAxes(final CoordinateSystem cs, final Ellipsoid 
ellipsoid) {
+         if (completedParameters) {
+             throw new 
IllegalStateException(Errors.format(Errors.Keys.AlreadyInitialized_1, 
"completedParameters"));
+         }
+         sourceCS = cs;
+         sourceEllipsoid = ellipsoid;
+     }
+ 
+     /**
+      * Gives hints about axis lengths and their orientations in output 
coordinates.
+      * The {@code ellipsoid} argument is often provided together with an 
{@link EllipsoidalCS}, but not only.
+      * For example, a two-dimensional {@link SphericalCS} may also require 
information about the ellipsoid.
+      *
+      * <p>Each call to this method replaces the values of the previous call.
+      * However, this method cannot be invoked anymore after {@link 
#getCompletedParameters()} has been invoked.</p>
+      *
+      * @param  cs  the coordinate system defining target axis order and 
units, or {@code null} if none.
+      * @param  ellipsoid  the ellipsoid providing target semi-axis lengths, 
or {@code null} if none.
+      * @throws IllegalStateException if {@link #getCompletedParameters()} has 
already been invoked.
+      */
+     @Override
+     public void setTargetAxes(final CoordinateSystem cs, final Ellipsoid 
ellipsoid) {
+         if (completedParameters) {
+             throw new 
IllegalStateException(Errors.format(Errors.Keys.AlreadyInitialized_1, 
"completedParameters"));
+         }
+         targetCS = cs;
+         targetEllipsoid = ellipsoid;
+     }
+ 
+     /**
+      * Returns the desired number of source dimensions of the transform to 
create.
+      * This value is inferred from the source coordinate system if present.
+      */
+     @Override
+     public final OptionalInt getSourceDimensions() {
+         return (sourceCS != null) ? OptionalInt.of(sourceCS.getDimension()) : 
OptionalInt.empty();
+     }
+ 
+     /**
+      * Returns the type of the source coordinate system.
+      * The returned value may be an interface or an implementation class.
+      *
+      * @return the type of the source coordinate system, or {@code 
CoordinateSystem.class} if unknown.
+      */
+     @Override
+     public final Class<? extends CoordinateSystem> getSourceCSType() {
+         return (sourceCS != null) ? sourceCS.getClass() : 
CoordinateSystem.class;
+     }
+ 
+     /**
+      * Returns the desired number of target dimensions of the transform to 
create.
+      * This value is inferred from the target coordinate system if present.
+      */
+     @Override
+     public final OptionalInt getTargetDimensions() {
+         return (targetCS != null) ? OptionalInt.of(targetCS.getDimension()) : 
OptionalInt.empty();
+     }
+ 
+     /**
+      * Returns the type of the target coordinate system.
+      * The returned value may be an interface or an implementation class.
+      *
+      * @return the type of the target coordinate system, or {@code 
CoordinateSystem.class} if unknown.
+      */
+     @Override
+     public final Class<? extends CoordinateSystem> getTargetCSType() {
+         return (targetCS != null) ? targetCS.getClass() : 
CoordinateSystem.class;
+     }
+ 
+     /**
+      * Returns the matrix that represent the affine transform to concatenate 
before or after
+      * the parameterized transform. The {@code role} argument specifies which 
matrix is desired:
+      *
+      * <ul class="verbose">
+      *   <li>{@link 
org.apache.sis.referencing.operation.transform.ContextualParameters.MatrixRole#NORMALIZATION
+      *       NORMALIZATION} for the conversion from the {@linkplain 
#getSourceCS() source coordinate system} to
+      *       a {@linkplain AxesConvention#NORMALIZED normalized} coordinate 
system, usually with
+      *       (<var>longitude</var>, <var>latitude</var>) axis order in 
degrees or
+      *       (<var>easting</var>, <var>northing</var>) in metres.
+      *       This normalization needs to be applied <em>before</em> the 
parameterized transform.</li>
+      *
+      *   <li>{@link 
org.apache.sis.referencing.operation.transform.ContextualParameters.MatrixRole#DENORMALIZATION
+      *       DENORMALIZATION} for the conversion from a normalized coordinate 
system to the
+      *       {@linkplain #getTargetCS() target coordinate system}, for 
example with
+      *       (<var>latitude</var>, <var>longitude</var>) axis order.
+      *       This denormalization needs to be applied <em>after</em> the 
parameterized transform.</li>
+      *
+      *   <li>{@link 
org.apache.sis.referencing.operation.transform.ContextualParameters.MatrixRole#INVERSE_NORMALIZATION
 INVERSE_NORMALIZATION} and
+      *       {@link 
org.apache.sis.referencing.operation.transform.ContextualParameters.MatrixRole#INVERSE_DENORMALIZATION
 INVERSE_DENORMALIZATION}
+      *       are also supported but rarely used.</li>
+      * </ul>
+      *
+      * @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 FactoryException if an error occurred while computing the 
matrix.
+      */
+     @SuppressWarnings("fallthrough")
+     public Matrix getMatrix(final ContextualParameters.MatrixRole role) 
throws FactoryException {
+         final CoordinateSystem userCS;
+         boolean inverse = false;
+         switch (role) {
+             default: throw new 
IllegalArgumentException(Errors.format(Errors.Keys.IllegalArgumentValue_2, 
"role", role));
+             case INVERSE_NORMALIZATION:   inverse = true;     // Fall through
+             case NORMALIZATION:           userCS  = sourceCS; break;
+             case INVERSE_DENORMALIZATION: inverse = true;     // Fall through
+             case DENORMALIZATION:         inverse = !inverse;
+                                           userCS  = targetCS; break;
+         }
+         if (userCS == null) {
+             return null;
+         }
+         final CoordinateSystem normalized = 
CoordinateSystems.replaceAxes(userCS, AxesConvention.NORMALIZED);
+         try {
+             if (inverse) {
+                 return CoordinateSystems.swapAndScaleAxes(normalized, userCS);
+             } else {
+                 return CoordinateSystems.swapAndScaleAxes(userCS, normalized);
+             }
+         } catch (IllegalArgumentException | IncommensurableException cause) {
+             throw new 
InvalidGeodeticParameterException(cause.getLocalizedMessage(), cause);
+         }
+     }
+ 
+     /**
+      * 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.
+      * 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>.
+      *
+      * @return the parameter values to modify for defining the transform to 
create.
+      * @throws IllegalStateException if no operation method has been 
specified at construction time.
+      */
+     @Override
+     public final ParameterValueGroup parameters() {
+         if (parameters != null) {
+             return parameters;
+         }
+         throw new 
IllegalStateException(Errors.format(Errors.Keys.MissingValueForProperty_1, 
"method"));
+     }
+ 
+     /**
+      * Returns the names of parameters that have been inferred from the 
context.
+      * See the {@linkplain 
MathTransformProvider.Context#getContextualParameters interface} for more 
information.
+      *
+      * @return names of parameters inferred from context.
+      */
+     @Override
+     public Map<String,Boolean> getContextualParameters() {
+         return Collections.unmodifiableMap(contextualParameters);
+     }
+ 
+     /**
+      * Returns the parameter values used for the math transform creation,
+      * including the parameters completed by the factory.
+      * This is the union of {@link #parameters()} with {@link 
#getContextualParameters()}.
+      * The completed parameters may only have additional parameters compared 
to the user-supplied parameters.
+      * {@linkplain #parameters() Parameter} values that were explicitly set 
by the user are not overwritten.
+      *
+      * <p>After this method has been invoked, the {@link #setSourceAxes 
setSourceAxes(…)}
+      * and {@link #setTargetAxes setTargetAxes(…)} methods can no longer be 
invoked.</p>
+      *
+      * @return the parameter values used by the factory.
+      * @throws IllegalStateException if no operation method has been 
specified at construction time.
+      */
+     @Override
+     public ParameterValueGroup getCompletedParameters() {
+         if (parameters != null) {
+             /*
+              * If the user's parameters do not contain semi-major and 
semi-minor axis lengths, infer
+              * them from the ellipsoid. We have to do that because those 
parameters are often omitted,
+              * since the standard place where to provide this information is 
in the ellipsoid object.
+              */
+             if (!completedParameters) {
+                 warning = completeParameters();
+             }
+             return parameters;
+         }
+         throw new 
IllegalStateException(Resources.format(Resources.Keys.UnspecifiedParameterValues));
+     }
+ 
+     /**
+      * Gets a parameter for which to infer a value from the context.
+      * The consistency flag is initially set to {@link Boolean#TRUE}.
+      *
+      * @param  name  name of the contextual parameter.
+      * @return the parameter.
+      * @throws ParameterNotFoundException if the parameter was not found.
+      */
+     private ParameterValue<?> getContextualParameter(final String name) 
throws ParameterNotFoundException {
+         ParameterValue<?> parameter = parameters.parameter(name);
+         contextualParameters.put(name, Boolean.TRUE);               // Add 
only if above line succeeded.
+         return parameter;
+     }
+ 
+     /**
+      * Returns the value of the given parameter in the given unit, or {@code 
NaN} if the parameter is not set.
+      *
+      * <p><b>NOTE:</b> Do not merge this function with {@code ensureSet(…)}. 
We keep those two methods
+      * separated in order to give to {@code completeParameters()} an "all or 
nothing" behavior.</p>
+      */
+     private static double getValue(final ParameterValue<?> parameter, final 
Unit<?> unit) {
+         return (parameter.getValue() != null) ? parameter.doubleValue(unit) : 
Double.NaN;
+     }
+ 
+     /**
+      * Ensures that a value is set in the given parameter.
+      *
+      * <ul>
+      *   <li>If the parameter has no value, then it is set to the given 
value.<li>
+      *   <li>If the parameter already has a value, then the parameter is left 
unchanged
+      *       but its value is compared to the given one for consistency.</li>
+      * </ul>
+      *
+      * @param  parameter  the parameter which must have a value.
+      * @param  actual     the current parameter value, or {@code NaN} if none.
+      * @param  expected   the expected parameter value, derived from the 
ellipsoid.
+      * @param  unit       the unit of {@code value}.
+      * @param  tolerance  maximal difference (in unit of {@code unit}) for 
considering the two values as equivalent.
+      * @return {@code true} if there is a mismatch between the actual value 
and the expected one.
+      */
+     private static boolean ensureSet(final ParameterValue<?> parameter, final 
double actual,
+             final double expected, final Unit<?> unit, final double tolerance)
+     {
+         if (Math.abs(actual - expected) <= tolerance) {
+             return false;
+         }
+         if (Double.isNaN(actual)) {
+             parameter.setValue(expected, unit);
+             return false;
+         }
+         return true;
+     }
+ 
+     /**
+      * Completes the parameter group with information about source or target 
ellipsoid axis lengths,
+      * if available. This method writes semi-major and semi-minor parameter 
values only if they do not
+      * already exists in the given parameters.
+      *
+      * @param  ellipsoid          the ellipsoid from which to get axis 
lengths of flattening factor, or {@code null}.
+      * @param  semiMajor          {@code "semi_major}, {@code 
"src_semi_major} or {@code "tgt_semi_major} parameter name.
+      * @param  semiMinor          {@code "semi_minor}, {@code 
"src_semi_minor} or {@code "tgt_semi_minor} parameter name.
+      * @param  inverseFlattening  {@code true} if this method can try to set 
the {@code "inverse_flattening"} parameter.
+      * @return the exception if the operation failed, or {@code null} if 
none. This exception is not thrown now
+      *         because the caller may succeed in creating the transform 
anyway, or otherwise may produce a more
+      *         informative exception.
+      */
+     private RuntimeException setEllipsoid(final Ellipsoid ellipsoid, final 
String semiMajor, final String semiMinor,
+             final boolean inverseFlattening, RuntimeException failure)
+     {
+         /*
+          * Note: we could also consider to set the "dim" parameter here based 
on the number of dimensions
+          * of the coordinate system. But except for the Molodensky operation, 
this would be SIS-specific.
+          * A more portable way is to concatenate a "Geographic 3D to 2D" 
operation after the transform if
+          * we see that the dimensions do not match. It also avoid attempt to 
set a "dim" parameter on map
+          * projections, which is not allowed.
+          */
+         if (ellipsoid != null) {
+             ParameterValue<?> mismatchedParam = null;
+             double mismatchedValue = 0;
+             try {
+                 final ParameterValue<?> ap = 
getContextualParameter(semiMajor);
+                 final ParameterValue<?> bp = 
getContextualParameter(semiMinor);
+                 final Unit<Length> unit = ellipsoid.getAxisUnit();
+                 /*
+                  * The two calls to getValue(…) shall succeed before we write 
anything, in order to have a
+                  * "all or nothing" behavior as much as possible. Note that 
Ellipsoid.getSemi**Axis() have
+                  * no reason to fail, so we do not take precaution for them.
+                  */
+                 final double a   = getValue(ap, unit);
+                 final double b   = getValue(bp, unit);
+                 final double tol = 
Units.METRE.getConverterTo(unit).convert(ELLIPSOID_PRECISION);
+                 if (ensureSet(ap, a, ellipsoid.getSemiMajorAxis(), unit, 
tol)) {
+                     contextualParameters.put(semiMajor, Boolean.FALSE);
+                     mismatchedParam = ap;
+                     mismatchedValue = a;
+                 }
+                 if (ensureSet(bp, b, ellipsoid.getSemiMinorAxis(), unit, 
tol)) {
+                     contextualParameters.put(semiMinor, Boolean.FALSE);
+                     mismatchedParam = bp;
+                     mismatchedValue = b;
+                 }
+             } catch (IllegalArgumentException | IllegalStateException e) {
+                 /*
+                  * Parameter not found, or is not numeric, or unit of 
measurement is not linear.
+                  * Do not touch to the parameters. We will see if `create(…)` 
can do something
+                  * about that. If not, `create(…)` is the right place to 
throw the exception.
+                  */
+                 if (failure == null) {
+                     failure = e;
+                 } else {
+                     failure.addSuppressed(e);
+                 }
+             }
+             /*
+              * Following is specific to Apache SIS. We use this non-standard 
API for allowing the
+              * NormalizedProjection class (our base class for all map 
projection implementations)
+              * to known that the ellipsoid definitive parameter is the 
inverse flattening factor
+              * instead of the semi-major axis length. It makes a small 
difference in the accuracy
+              * of the eccentricity parameter.
+              */
+             if (mismatchedParam == null && inverseFlattening && 
ellipsoid.isIvfDefinitive()) try {
+                 final ParameterValue<?> ep = 
getContextualParameter(Constants.INVERSE_FLATTENING);
+                 final double e = getValue(ep, Units.UNITY);
+                 if (ensureSet(ep, e, ellipsoid.getInverseFlattening(), 
Units.UNITY, 1E-10)) {
+                     contextualParameters.put(Constants.INVERSE_FLATTENING, 
Boolean.FALSE);
+                     mismatchedParam = ep;
+                     mismatchedValue = e;
+                 }
+             } catch (ParameterNotFoundException e) {
+                 /*
+                  * Should never happen with Apache SIS implementation, but 
may happen if the given parameters come
+                  * from another implementation. We can safely abandon our 
attempt to set the inverse flattening value,
+                  * since it was redundant with semi-minor axis length.
+                  */
+                 Logging.recoverableException(CoordinateOperations.LOGGER, 
getClass(), "create", e);
+             }
+             /*
+              * If a parameter was explicitly specified by user but has a 
value inconsistent with the context,
+              * log a warning. In addition, the associated boolean value in 
`contextualParameters` map should
+              * have been set to `Boolean.FALSE`.
+              */
+             if (mismatchedParam != null) {
+                 final LogRecord record = 
Resources.forLocale(null).getLogRecord(Level.WARNING,
+                         Resources.Keys.MismatchedEllipsoidAxisLength_3, 
ellipsoid.getName().getCode(),
+                         mismatchedParam.getDescriptor().getName().getCode(), 
mismatchedValue);
+                 Logging.completeAndLog(CoordinateOperations.LOGGER, 
getClass(), "create", record);
+             }
+         }
+         return failure;
+     }
+ 
+     /**
+      * Completes the parameter group with information about source and target 
ellipsoid axis lengths,
+      * if available. This method writes semi-major and semi-minor parameter 
values only if they do not
+      * already exists in the current parameters.
+      *
+      * @return the exception if the operation failed, or {@code null} if 
none. This exception is not thrown now
+      *         because the caller may succeed in creating the transform 
anyway, or otherwise may produce a more
+      *         informative exception.
+      *
+      * @see #getCompletedParameters()
+      */
+     private RuntimeException completeParameters() throws 
IllegalArgumentException {
+         completedParameters = true;     // Need to be first.
+         /*
+          * Get a mask telling us if we need to set parameters for the source 
and/or target ellipsoid.
+          * This information should preferably be given by the provider. But 
if the given provider is
+          * not a SIS implementation, use as a fallback whether ellipsoids are 
provided. This fallback
+          * may be less reliable.
+          */
+         final boolean sourceOnEllipsoid, targetOnEllipsoid;
+         if (provider instanceof AbstractProvider) {
+             final var p = (AbstractProvider) provider;
+             sourceOnEllipsoid = p.sourceOnEllipsoid;
+             targetOnEllipsoid = p.targetOnEllipsoid;
+         } else {
+             sourceOnEllipsoid = sourceEllipsoid != null;
+             targetOnEllipsoid = targetEllipsoid != null;
+         }
+         /*
+          * Set the ellipsoid axis-length parameter values. Those parameters 
may appear in the source ellipsoid,
+          * in the target ellipsoid or in both ellipsoids. Only in the latter 
case, we also try to set the "dim"
+          * parameter, because OGC 01-009 defines this parameter only in 
operation between two geographic CRSs.
+          */
+         if (!(sourceOnEllipsoid | targetOnEllipsoid)) return null;
+         if (!targetOnEllipsoid) return setEllipsoid(sourceEllipsoid, 
Constants.SEMI_MAJOR, Constants.SEMI_MINOR, true, null);
+         if (!sourceOnEllipsoid) return setEllipsoid(targetEllipsoid, 
Constants.SEMI_MAJOR, Constants.SEMI_MINOR, true, null);
+ 
+         RuntimeException failure = null;
+         if (sourceCS != null) try {
+             final ParameterValue<?> p = getContextualParameter(Constants.DIM);
+             if (p.getValue() == null) {
+                 p.setValue(sourceCS.getDimension());
+             }
+         } catch (IllegalArgumentException | IllegalStateException e) {
+             failure = e;
+         }
+         failure = setEllipsoid(sourceEllipsoid, "src_semi_major", 
"src_semi_minor", false, failure);
+         failure = setEllipsoid(targetEllipsoid, "tgt_semi_major", 
"tgt_semi_minor", false, failure);
+         return failure;
+     }
+ 
+     /**
+      * Creates the parameterized transform. The operation method is given by 
{@link #getMethod()}
+      * and the parameter values should have been set on the group returned by 
{@link #parameters()}
+      * before to invoke this constructor.
+      *
+      * @return the parameterized transform.
+      * @throws FactoryException if the transform creation failed.
+      * This exception is thrown if some required parameters have not been 
supplied, or have illegal values.
+      */
+     @Override
+     public MathTransform create() throws FactoryException {
+         try {
+             if (provider instanceof AbstractProvider) {
+                 /*
+                  * The "Geographic/geocentric conversions" conversion 
(EPSG:9602) can be either:
+                  *
+                  *    - "Ellipsoid_To_Geocentric"
+                  *    - "Geocentric_To_Ellipsoid"
+                  *
+                  * EPSG defines both by a single operation, but Apache SIS 
needs to distinguish them.
+                  */
+                 final String method = ((AbstractProvider) 
provider).resolveAmbiguity(this);
+                 if (method != null) {
+                     provider = (factory instanceof DefaultMathTransformFactory
+                             ? (DefaultMathTransformFactory) factory
+                             :  
DefaultMathTransformFactory.provider()).getOperationMethod(method);
+                 }
+             }
+             /*
+              * Will catch only exceptions that may be the result of improper 
parameter usage (e.g. a value out
+              * of range). Do not catch exceptions caused by programming 
errors (e.g. null pointer exception).
+              */
+             final MathTransform transform;
+             if (provider instanceof MathTransformProvider) try {
+                 transform = ((MathTransformProvider) 
provider).createMathTransform(this);
+             } catch (IllegalArgumentException | IllegalStateException 
exception) {
+                 throw new 
InvalidGeodeticParameterException(exception.getLocalizedMessage(), exception);
+             } else {
 -                throw new UnimplementedServiceException(Errors.format(
++                throw new FactoryException(Errors.format(
+                         Errors.Keys.UnsupportedImplementation_1, 
Classes.getClass(provider)));
+             }
+             if (provider instanceof AbstractProvider) {
+                 provider = ((AbstractProvider) 
provider).variantFor(transform);
+             }
+             return swapAndScaleAxes(unique(transform));
+         } catch (FactoryException exception) {
+             if (warning != null) {
+                 exception.addSuppressed(warning);
+             }
+             throw exception;
+         }
+     }
+ 
+     /**
+      * 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
+      * with any other transform required for performing units changes and 
coordinates swapping.
+      *
+      * <p>The given {@code parameterized} 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>
+      *
+      * <h4>Example</h4>
+      * The most typical examples of transforms with normalized inputs/outputs 
are normalized
+      * map projections expecting (<var>longitude</var>, <var>latitude</var>) 
inputs in degrees
+      * and calculating (<var>x</var>, <var>y</var>) coordinates in metres,
+      * both of them with ({@linkplain 
org.opengis.referencing.cs.AxisDirection#EAST East},
+      * {@linkplain org.opengis.referencing.cs.AxisDirection#NORTH North}) 
axis orientations.
+      *
+      * <h4>When to use</h4>
+      * This method is invoked automatically by {@link #create()} and 
therefore usually does not need
+      * to be invoked explicitly. Explicit calls may be useful when the 
normalized transform has been
+      * constructed by the caller instead of by {@link #create()}.
+      *
+      * @param  normalized  a transform for normalized input and output 
coordinates.
+      * @return a transform taking in account unit conversions and axis 
swapping.
+      * @throws FactoryException if the object creation failed.
+      *
+      * @see org.apache.sis.referencing.cs.AxesConvention#NORMALIZED
+      * @see 
org.apache.sis.referencing.operation.DefaultConversion#DefaultConversion(Map, 
OperationMethod, MathTransform, ParameterValueGroup)
+      */
+     public MathTransform swapAndScaleAxes(final MathTransform normalized) 
throws FactoryException {
+         ArgumentChecks.ensureNonNull("parameterized", normalized);
+         /*
+          * Compute matrices for swapping axis 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.
+          * 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.
+          */
+         MathTransform step1 = swap1 != null ? 
factory.createAffineTransform(swap1) : 
MathTransforms.identity(normalized.getSourceDimensions());
+         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.
+          */
+         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:
+          *
+          *     - 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.
+          */
+         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);
+             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));
+             }
+         }
+         /*
+          * 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.
+          */
+         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));
+         }
+         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.
+          */
+         if (normalized instanceof ParameterizedAffine && !(mt instanceof 
ParameterizedAffine)) {
+             if (mt != (mt = ((ParameterizedAffine) 
normalized).newTransform(mt))) {
+                 mt = unique(mt);
+             }
+         }
+         return mt;
+     }
+ 
+     /**
+      * 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).
+      *
+      * <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.
+      *
+      * @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.
+      */
+     private void ensureDimensionChangeAllowed(final MathTransform 
parameterized,
+             final int change, final int resultDim) throws FactoryException
+     {
+         if (Math.abs(change) == 1 && resultDim >= 2 && resultDim <= 3) {
+             if (sourceCS instanceof EllipsoidalCS || targetCS instanceof 
EllipsoidalCS) {
+                 return;
+             }
+         }
+         /*
+          * 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 (name == null) {
+             name = Classes.getShortClassName(parameterized);
+         }
+         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)");
+         getTargetDimensions().ifPresent((dim) -> b.append(" → 
").append(dim).append('D'));
+         throw new 
InvalidGeodeticParameterException(Resources.format(Resources.Keys.CanNotAssociateToCS_2,
 name, b));
+     }
+ 
+     /**
+      * Returns a string representation of this builder/context for debugging 
purposes.
+      * The current implementation writes the name of source/target coordinate 
systems and ellipsoids.
+      * If the {@linkplain #getContextualParameters() contextual parameters} 
have already been inferred,
+      * then their names are appended with inconsistent parameters (if any) 
written on a separated line.
+      *
+      * @return a string representation of this builder/context.
+      */
+     @Override
+     public String toString() {
+         final Object[] properties = {
+             "sourceCS", sourceCS, "sourceEllipsoid", sourceEllipsoid,
+             "targetCS", targetCS, "targetEllipsoid", targetEllipsoid
+         };
+         for (int i=1; i<properties.length; i += 2) {
+             final var value = (IdentifiedObject) properties[i];
+             if (value != null) properties[i] = value.getName();
+         }
+         String text = Strings.toString(getClass(), properties);
+         if (!contextualParameters.isEmpty()) {
+             final var b = new StringBuilder(text);
+             boolean isContextual = true;
+             do {
+                 boolean first = true;
+                 for (final Map.Entry<String,Boolean> entry : 
contextualParameters.entrySet()) {
+                     if (entry.getValue() == isContextual) {
+                         if (first) {
+                             first = false;
+                             b.append(System.lineSeparator())
+                              .append(isContextual ? "Contextual parameters" : 
"Inconsistencies").append(": ");
+                         } else {
+                             b.append(", ");
+                         }
+                         b.append(entry.getKey());
+                     }
+                 }
+             } while ((isContextual = !isContextual) == false);
+             text = b.toString();
+         }
+         return text;
+     }
+ }
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationFinder.java
index d691538d70,d4e90e04ea..67fc5baa6a
--- 
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
@@@ -652,14 -655,16 +651,16 @@@ public class CoordinateOperationFinder 
              } else {
                  parameters = Affine.identity(targetDim);
                  /*
-                  * createCoordinateSystemChange(…) 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 (see
-                  * createCoordinateSystemChange(…) javadoc for the rational) 
so it does not matter which one we pick.
+                  * "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.
                   */
-                 before = mtFactory.createCoordinateSystemChange(sourceCS, 
targetCS,
-                         (sourceCS instanceof EllipsoidalCS ? sourceDatum : 
targetDatum).getEllipsoid());
-                 context.setSource(targetCS);
-                 method = mtFactory.getLastMethodUsed();
+                 var ellipsoid = (sourceCS instanceof EllipsoidalCS ? 
sourceDatum : targetDatum).getEllipsoid();
 -                var builder   = 
mtFactory.builder(Constants.COORDINATE_SYSTEM_CONVERSION);
++                var builder   = CoordinateOperations.builder(mtFactory, 
Constants.COORDINATE_SYSTEM_CONVERSION);
+                 builder.setSourceAxes(sourceCS, ellipsoid);
+                 builder.setTargetAxes(targetCS, ellipsoid);
+                 before = builder.create();
+                 method = builder.getMethod().orElse(null);
+                 context.setSourceAxes(targetCS, null);
              }
          }
          /*
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationRegistry.java
index 72912b3544,8f900fd7a8..7d2693cf16
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationRegistry.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationRegistry.java
@@@ -82,6 -83,6 +83,7 @@@ import org.apache.sis.util.resources.Vo
  
  // Specific to the main and geoapi-3.1 branches:
  import org.opengis.referencing.crs.GeneralDerivedCRS;
++import org.apache.sis.referencing.operation.transform.MathTransformBuilder;
  
  
  /**
@@@ -1139,23 -1138,20 +1138,20 @@@ class CoordinateOperationRegistry 
              Matrix matrix = MathTransforms.getMatrix(op.getMathTransform());
              if (matrix == null) {
                  if (SubTypes.isSingleOperation(op)) {
-                     final MathTransformFactory mtFactory = 
factorySIS.getMathTransformFactory();
-                     if (mtFactory instanceof DefaultMathTransformFactory) {
-                         if (forward) sourceCRS = toGeodetic3D(sourceCRS, 
source3D);
-                         else         targetCRS = toGeodetic3D(targetCRS, 
target3D);
-                         final DefaultMathTransformFactory.Context context;
-                         final MathTransform mt;
-                         try {
-                             context = 
ReferencingUtilities.createTransformContext(sourceCRS, targetCRS);
-                             mt = ((DefaultMathTransformFactory) 
mtFactory).createParameterizedTransform(
-                                     ((SingleOperation) 
op).getParameterValues(), context);
-                         } catch (InvalidGeodeticParameterException e) {
-                             log(null, e);
-                             break;
-                         }
-                         operations.set(recreate(op, sourceCRS, targetCRS, mt, 
context.getMethodUsed()));
-                         return true;
+                     if (forward) sourceCRS = toGeodetic3D(sourceCRS, 
source3D);
+                     else         targetCRS = toGeodetic3D(targetCRS, 
target3D);
 -                    final MathTransform.Builder builder;
++                    final MathTransformBuilder builder;
+                     final MathTransform mt;
+                     try {
+                         final var parameters = ((SingleOperation) 
op).getParameterValues();
+                         builder = createTransformBuilder(parameters, 
sourceCRS, targetCRS);
+                         mt = builder.create();
+                     } catch (InvalidGeodeticParameterException e) {
+                         log(null, e);
+                         break;
                      }
+                     operations.set(recreate(op, sourceCRS, targetCRS, mt, 
builder.getMethod().orElse(null)));
+                     return true;
                  }
                  break;
              }
@@@ -1255,6 -1251,30 +1251,30 @@@
          return properties;
      }
  
+     /**
+      * Creates a transform builder which will use the given <abbr>CRS</abbr> 
as contextual information.
+      * The ellipsoids will be used for completing the axis-length parameters, 
and the coordinate systems will
+      * be used for axis order and units of measurement. This method does not 
perform <abbr>CRS</abbr> changes
+      * other than axis order and units.
+      *
+      * @param  parameters  the operation parameter value group.
+      * @param  sourceCRS   the CRS from which to get the source coordinate 
system and ellipsoid.
+      * @param  targetCRS   the CRS from which to get the target coordinate 
system and ellipsoid.
+      * @return the parameterized transform.
+      * @throws FactoryException if the transform cannot be created.
+      */
 -    private MathTransform.Builder createTransformBuilder(
++    private MathTransformBuilder createTransformBuilder(
+             final ParameterValueGroup parameters,
+             final CoordinateReferenceSystem sourceCRS,
+             final CoordinateReferenceSystem targetCRS) throws FactoryException
+     {
+         final var builder = new 
ParameterizedTransformBuilder(factorySIS.getMathTransformFactory(), null);
+         builder.setParameters(parameters, true);
+         builder.setSourceAxes(sourceCRS);
+         builder.setTargetAxes(targetCRS);
+         return builder;
+     }
+ 
      /**
       * Creates a coordinate operation from a math transform.
       * The method performs the following steps:
@@@ -1335,12 -1355,11 +1355,11 @@@
              } else {
                  final ParameterDescriptorGroup descriptor = 
AbstractCoordinateOperation.getParameterDescriptors(transform);
                  if (descriptor != null) {
-                     final Identifier name = descriptor.getName();
-                     if (name != null) {
-                         method = 
factorySIS.getOperationMethod(name.getCode());
-                     }
-                     if (method == null) {
+                     try {
+                         method = 
CoordinateOperations.findMethod(factorySIS.getMathTransformFactory(), 
descriptor);
+                     } catch (NoSuchIdentifierException e) {
+                         recoverableException("createFromMathTransform", e);
 -                        method = factory.createOperationMethod(properties, 
descriptor);
 +                        method = factorySIS.createOperationMethod(properties, 
descriptor);
                      }
                  }
              }
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/DefaultConcatenatedOperation.java
index 908e6a6138,edf139a00a..4fb7959e4b
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/DefaultConcatenatedOperation.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/DefaultConcatenatedOperation.java
@@@ -173,13 -174,11 +174,11 @@@ final class DefaultConcatenatedOperatio
  
          if (targetCRS == null) {
              targetCRS = crs;
-         } else if (mtFactory instanceof DefaultMathTransformFactory) {
-             final var dmf = (DefaultMathTransformFactory) mtFactory;
-             final MathTransform t = dmf.createCoordinateSystemChange(
-                     crs.getCoordinateSystem(),
-                     targetCRS.getCoordinateSystem(),
-                     ReferencingUtilities.getEllipsoid(crs));
-             transform = dmf.createConcatenatedTransform(transform, t);
+         } else if (mtFactory != null) {
 -            var builder = 
mtFactory.builder(Constants.COORDINATE_SYSTEM_CONVERSION);
++            var builder = CoordinateOperations.builder(mtFactory, 
Constants.COORDINATE_SYSTEM_CONVERSION);
+             builder.setSourceAxes(crs.getCoordinateSystem(), 
ReferencingUtilities.getEllipsoid(crs));
+             builder.setTargetAxes(targetCRS.getCoordinateSystem(), null);
+             transform = mtFactory.createConcatenatedTransform(transform, 
builder.create());
          }
          /*
           * At this point we should have flattened.size() >= 2, except if some 
operations
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/DefaultConversion.java
index ab87dbe605,2ee557109e..c819569a22
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/DefaultConversion.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/DefaultConversion.java
@@@ -367,10 -362,9 +361,8 @@@ public class DefaultConversion extends 
       *         parameter values}, or a {@linkplain 
CoordinateSystems#swapAndScaleAxes change of axis order or units}
       *         failed.
       *
-      * @see 
DefaultMathTransformFactory#createParameterizedTransform(ParameterValueGroup, 
DefaultMathTransformFactory.Context)
-      *
       * @since 1.5
       */
 -    @SuppressWarnings("deprecation")
      public Conversion specialize(final CoordinateReferenceSystem sourceCRS,
                                   final CoordinateReferenceSystem targetCRS,
                                   MathTransformFactory factory) throws 
FactoryException
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/MathTransformContext.java
index 90b94e6f6d,d1c5bdf710..977d7c478c
--- 
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
@@@ -28,10 -31,14 +31,14 @@@ import org.apache.sis.referencing.opera
  import org.apache.sis.referencing.operation.matrix.Matrix4;
  import org.apache.sis.referencing.operation.matrix.MatrixSIS;
  import 
org.apache.sis.referencing.operation.transform.ContextualParameters.MatrixRole;
- import 
org.apache.sis.referencing.operation.transform.DefaultMathTransformFactory.Context;
+ import org.apache.sis.referencing.internal.ParameterizedTransformBuilder;
  import org.apache.sis.util.resources.Errors;
+ import org.apache.sis.util.privy.Constants;
  import org.apache.sis.measure.Units;
  
 -// Specific to the geoapi-3.1 and geoapi-4.0 branches:
 -import org.opengis.util.UnimplementedServiceException;
++// Specific to the main branch:
++import org.apache.sis.referencing.privy.CoordinateOperations;
+ 
  
  /**
   * Information about the context in which a {@code MathTransform} is created.
@@@ -66,6 -69,40 +69,40 @@@ final class MathTransformContext extend
          }
      }
  
+     /**
+      * Creates a math transform that represent a change of coordinate system.
+      * If one argument is an ellipsoidal coordinate systems, then the {@code 
ellipsoid} argument is mandatory.
+      * In other cases (including the case where both coordinate systems are 
ellipsoidal),
+      * the ellipsoid argument is ignored and can be {@code null}.
+      *
+      * <p>This method does not change the state of this {@code 
MathTransformContext}.
+      * This method is defined here for {@link CoordinateOperationFinder} 
convenience,
+      * because this method is invoked together with {@code 
setSource/TargetAxes(…)}.</p>
+      *
+      * <h4>Design note</h4>
+      * This method does not accept separated ellipsoid arguments for {@code 
source} and {@code target} because
+      * this method should not be used for datum shifts. If the two given 
coordinate systems are ellipsoidal,
+      * then they are assumed to use the same ellipsoid. If different 
ellipsoids are desired, then a
+      * parameterized transform like <q>Molodensky</q>, <q>Geocentric 
translations</q>, <q>Coordinate Frame Rotation</q>
+      * or <q>Position Vector transformation</q> should be used instead.
+      *
+      * @param  source     the source coordinate system.
+      * @param  target     the target coordinate system.
+      * @param  ellipsoid  the ellipsoid of {@code EllipsoidalCS}, or {@code 
null} if none.
+      * @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)
+             throws FactoryException
+     {
 -        final var builder = 
getFactory().builder(Constants.COORDINATE_SYSTEM_CONVERSION);
++        final var builder = CoordinateOperations.builder(getFactory(), 
Constants.COORDINATE_SYSTEM_CONVERSION);
+         builder.setSourceAxes(source, ellipsoid);
+         builder.setTargetAxes(target, ellipsoid);
+         return builder.create();
+     }
+ 
      /**
       * Returns the normalization or denormalization matrix.
       */
@@@ -110,7 -150,7 +150,7 @@@
                  }
                  matrix = cm;
              } else {
-                 throw new 
FactoryException(Errors.format(Errors.Keys.UnsupportedCoordinateSystem_1, 
cs.getName()));
 -                throw new 
UnimplementedServiceException(Errors.format(Errors.Keys.UnsupportedCoordinateSystem_1,
 userCS.getName()));
++                throw new 
FactoryException(Errors.format(Errors.Keys.UnsupportedCoordinateSystem_1, 
userCS.getName()));
              }
          }
          return matrix;
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/CoordinateSystemTransformBuilder.java
index 0000000000,3229427266..e01b832660
mode 000000,100644..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
@@@ -1,0 -1,255 +1,259 @@@
+ /*
+  * Licensed to the Apache Software Foundation (ASF) under one or more
+  * contributor license agreements.  See the NOTICE file distributed with
+  * this work for additional information regarding copyright ownership.
+  * The ASF licenses this file to You under the Apache License, Version 2.0
+  * (the "License"); you may not use this file except in compliance with
+  * the License.  You may obtain a copy of the License at
+  *
+  *     http://www.apache.org/licenses/LICENSE-2.0
+  *
+  * Unless required by applicable law or agreed to in writing, software
+  * distributed under the License is distributed on an "AS IS" BASIS,
+  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  * See the License for the specific language governing permissions and
+  * limitations under the License.
+  */
+ package org.apache.sis.referencing.operation.transform;
+ 
+ import java.util.List;
+ import java.util.ArrayList;
+ import javax.measure.IncommensurableException;
+ import org.opengis.util.FactoryException;
+ import org.opengis.parameter.ParameterValueGroup;
+ import org.opengis.referencing.datum.Ellipsoid;
+ import org.opengis.referencing.cs.CartesianCS;
+ import org.opengis.referencing.cs.CoordinateSystem;
+ import org.opengis.referencing.cs.CylindricalCS;
+ import org.opengis.referencing.cs.EllipsoidalCS;
+ 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.OperationNotFoundException;
+ import org.apache.sis.referencing.cs.AxesConvention;
+ import org.apache.sis.referencing.cs.CoordinateSystems;
+ import org.apache.sis.referencing.cs.DefaultCompoundCS;
+ import org.apache.sis.referencing.internal.Resources;
+ import org.apache.sis.referencing.operation.provider.GeocentricToGeographic;
+ import org.apache.sis.referencing.operation.provider.GeographicToGeocentric;
+ import org.apache.sis.referencing.privy.WKTUtilities;
+ import org.apache.sis.util.resources.Errors;
+ 
++// Specific to the main branch:
++import org.apache.sis.referencing.privy.CoordinateOperations;
++
+ 
+ /**
+  * Builder of transforms between coordinate systems.
+  *
+  * @author  Martin Desruisseaux (Geomatys)
+  */
+ final class CoordinateSystemTransformBuilder extends MathTransformBuilder {
+     /**
+      * The source and target coordinate systems.
+      */
+     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.
+      */
+     private Ellipsoid ellipsoid;
+ 
+     /**
+      * Creates a new builder.
+      *
+      * @param  factory  the factory to use for building the transform.
+      */
+     CoordinateSystemTransformBuilder(final MathTransformFactory factory) {
+         super(factory);
+     }
+ 
+     /**
+      * Sets the source coordinate system.
+      */
+     @Override
+     public void setSourceAxes(CoordinateSystem cs, Ellipsoid ellipsoid) {
+         setEllipsoid(ellipsoid);
+         source = cs;
+     }
+ 
+     /**
+      * Sets the target coordinate system.
+      */
+     @Override
+     public void setTargetAxes(CoordinateSystem cs, Ellipsoid ellipsoid) {
+         setEllipsoid(ellipsoid);
+         target = cs;
+     }
+ 
+     /**
+      * Sets the ellipsoid if it was not already set.
+      */
+     private void setEllipsoid(final Ellipsoid value) {
+         if (value != null) {
+             if (ellipsoid != null && ellipsoid != value) {
+                 throw new 
IllegalStateException(Errors.format(Errors.Keys.AlreadyInitialized_1, 
"ellipsoid"));
+             }
+             ellipsoid = value;
+         }
+     }
+ 
+     /**
+      * Unsupported operation because this builder has no parameters.
+      */
+     @Override
+     public ParameterValueGroup parameters() {
+         throw new 
IllegalStateException(Errors.format(Errors.Keys.MissingValueForProperty_1, 
"method"));
+     }
+ 
+     /**
+      * Adds the components of the given coordinate system in the specified 
list.
+      * This method may invoke itself recursively if there is nested compound 
CS.
+      * The returned list is always a copy and can be safely modified.
+      */
+     private static void getComponents(final CoordinateSystem cs, final 
List<CoordinateSystem> addTo) {
+         if (cs instanceof DefaultCompoundCS) {
+             addTo.addAll(((DefaultCompoundCS) cs).getComponents());
+         } else {
+             addTo.add(cs);
+         }
+     }
+ 
+     /**
+      * Creates the change of coordinate system.
+      *
+      * @todo Handle the case where coordinate system components are not in 
the same order.
+      *
+      * @return the transform from the given source CS to the given target CS.
+      * @throws FactoryException if an error occurred while creating a 
transform.
+      */
+     @Override
+     public MathTransform create() throws FactoryException {
+         if (source == null || target == null) {
+             throw new IllegalStateException(Errors.format(
+                     Errors.Keys.MissingValueForProperty_1,
+                     (source == null) ? "source" : "target"));
+         }
+         if (ellipsoid != null) {
+             final boolean isEllipsoidalSource = (source instanceof 
EllipsoidalCS);
+             if (isEllipsoidalSource != (target instanceof EllipsoidalCS)) {
+                 /*
+                  * For now we support only conversion between EllipsoidalCS 
and CartesianCS.
+                  * But future Apache SIS versions could add support for 
conversions between
+                  * EllipsoidalCS and SphericalCS or other coordinate systems.
+                  */
+                 if ((isEllipsoidalSource ? target : source) instanceof 
CartesianCS) {
 -                    final var context = factory.builder(isEllipsoidalSource ? 
GeographicToGeocentric.NAME
 -                                                                            : 
GeocentricToGeographic.NAME);
++                    final var context = CoordinateOperations.builder(factory,
++                            isEllipsoidalSource ? GeographicToGeocentric.NAME
++                                                : 
GeocentricToGeographic.NAME);
+                     if (isEllipsoidalSource) {
+                         context.setSourceAxes(source, ellipsoid);
+                         context.setTargetAxes(target, null);
+                     } else {
+                         context.setSourceAxes(source, null);
+                         context.setTargetAxes(target, ellipsoid);
+                     }
+                     return context.create();
+                 }
+             }
+         }
+         final var sources = new ArrayList<CoordinateSystem>(3); 
getComponents(source, sources);
+         final var targets = new ArrayList<CoordinateSystem>(3); 
getComponents(target, targets);
+         final int count   = sources.size();
+         /*
+          * 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
+          * need to improve on that.
+          */
+         MathTransform result = null;
+         if (count == targets.size()) {
+             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) {
+                     result = null;
+                     break;
+                 }
+                 final MathTransform subTransform = 
factory.createPassThroughTransform(
+                         firstAffectedCoordinate,
+                         single(s, t),
+                         dimension - (firstAffectedCoordinate + sd));
+                 if (result == null) {
+                     result = subTransform;
+                 } else {
+                     result = factory.createConcatenatedTransform(result, 
subTransform);
+                 }
+                 firstAffectedCoordinate += sd;
+             }
+         }
+         // If we couldn't process components separately, try with the 
compound CS as a whole.
+         if (result == null) {
+             result = single(source, target);
+         }
+         return unique(result);
+     }
+ 
+     /**
+      * Implementation of {@code create(…)} for a single component.
+      * This implementation can handle changes of coordinate system type 
between
+      * {@link CartesianCS}, {@link SphericalCS}, {@link CylindricalCS} and 
{@link PolarCS}.
+      */
+     private MathTransform single(final CoordinateSystem stepSource,
+                                  final CoordinateSystem stepTarget) throws 
FactoryException
+     {
+         int passthrough = 0;
+         CoordinateSystemTransform kernel = null;
+         if (stepSource instanceof CartesianCS) {
+             if (stepTarget instanceof SphericalCS) {
+                 kernel = CartesianToSpherical.INSTANCE;
+             } else if (stepTarget instanceof PolarCS) {
+                 kernel = CartesianToPolar.INSTANCE;
+             } else if (stepTarget instanceof CylindricalCS) {
+                 kernel = CartesianToPolar.INSTANCE;
+                 passthrough = 1;
+             }
+         } else if (stepTarget instanceof CartesianCS) {
+             if (stepSource instanceof SphericalCS) {
+                 kernel = SphericalToCartesian.INSTANCE;
+             } else if (stepSource instanceof PolarCS) {
+                 kernel = PolarToCartesian.INSTANCE;
+             } else if (stepSource instanceof CylindricalCS) {
+                 kernel = PolarToCartesian.INSTANCE;
+                 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 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;
+             }
+         } catch (IllegalArgumentException | IncommensurableException e) {
+             cause = e;
+         }
+         throw new 
OperationNotFoundException(Resources.format(Resources.Keys.CoordinateOperationNotFound_2,
+                 WKTUtilities.toType(CoordinateSystem.class, 
stepSource.getClass()),
+                 WKTUtilities.toType(CoordinateSystem.class, 
stepTarget.getClass())), cause);
+     }
+ }
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/DefaultMathTransformFactory.java
index 28d699293e,ac9393145a..1ea76f0c10
--- 
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
@@@ -55,37 -44,20 +44,20 @@@ import org.opengis.util.FactoryExceptio
  import org.opengis.util.NoSuchIdentifierException;
  import org.apache.sis.io.wkt.Parser;
  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;
- import org.apache.sis.referencing.IdentifiedObjects;
- import org.apache.sis.referencing.privy.Formulas;
+ import org.apache.sis.util.iso.AbstractFactory;
+ import org.apache.sis.util.collection.WeakHashSet;
  import org.apache.sis.referencing.privy.CoordinateOperations;
- import org.apache.sis.referencing.privy.ReferencingUtilities;
- import org.apache.sis.referencing.internal.ParameterizedAffine;
- import org.apache.sis.referencing.internal.Resources;
  import org.apache.sis.referencing.operation.DefaultOperationMethod;
- import org.apache.sis.referencing.operation.provider.AbstractProvider;
- import org.apache.sis.referencing.operation.provider.VerticalOffset;
- import org.apache.sis.referencing.operation.provider.GeographicToGeocentric;
- import org.apache.sis.referencing.operation.provider.GeocentricToGeographic;
- import org.apache.sis.system.Reflect;
- import org.apache.sis.metadata.iso.citation.Citations;
- import org.apache.sis.parameter.DefaultParameterValueGroup;
- import org.apache.sis.parameter.Parameterized;
- import org.apache.sis.parameter.Parameters;
- 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.internal.ParameterizedTransformBuilder;
  import org.apache.sis.referencing.factory.InvalidGeodeticParameterException;
- import org.apache.sis.referencing.operation.matrix.Matrices;
- import org.apache.sis.measure.Units;
- import org.apache.sis.util.collection.WeakHashSet;
- import org.apache.sis.util.iso.AbstractFactory;
- import org.apache.sis.util.logging.Logging;
+ import org.apache.sis.parameter.DefaultParameterValueGroup;
+ import org.apache.sis.system.Reflect;
+ 
+ // Specific to the main and geoapi-3.1 branches:
  import org.apache.sis.util.resources.Errors;
  
 +
  /**
   * Low level factory for creating {@linkplain AbstractMathTransform math 
transforms}.
   * The objects created by this factory do not know what the source and target 
coordinate systems mean.
@@@ -476,6 -442,49 +442,52 @@@ public class DefaultMathTransformFactor
          return method;
      }
  
+     /**
+      * Returns a builder for a parameterized math transform using the 
specified operation method.
+      * The {@code method} argument should be the name or identifier of an 
{@link OperationMethod}
+      * instance returned by <code>{@link #getAvailableMethods(Class) 
getAvailableMethods}(null)</code>,
+      * with the addition of the following pseudo-methods:
+      *
+      * <ul>
+      *   <li>"Coordinate system conversion"</li>
+      * </ul>
+      *
+      * The returned builder allows to specify not only the operation 
parameter values,
+      * but also some contextual information such as the source and target 
axes.
+      * The builder uses these information for:
+      *
+      * <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>
+      *   <li>{@linkplain #createConcatenatedTransform Concatenating} the 
parameterized transform
+      *       with any other transforms required for performing units changes 
and coordinates swapping.</li>
+      * </ol>
+      *
+      * The builder does <strong>not</strong> handle change of
+      * {@linkplain 
org.apache.sis.referencing.datum.DefaultGeodeticDatum#getPrimeMeridian() prime 
meridian}
+      * or anything else related to datum. Datum changes have dedicated {@link 
OperationMethod},
+      * for example <q>Longitude rotation</q> (EPSG:9601) for changing the 
prime meridian.
+      *
++     * <div class="warning"><b>Upcoming API generalization:</b>
++     * the return type of this method may be changed to a new {@code 
MathTransform.Builder} interface
++     * in a future Apache SIS version. This is pending GeoAPI 3.1 release, if 
approved by OGC.</div>
++     *
+      * @param  method  the case insensitive name or identifier of the desired 
coordinate operation method.
+      * @return a builder for a meth transform implementing the formulas 
identified by the given method.
+      * @throws NoSuchIdentifierException if there is no supported method for 
the given name or identifier.
+      *
+      * @see #getAvailableMethods(Class)
+      * @since 1.5
+      */
 -    @Override
 -    public MathTransform.Builder builder(final String method) throws 
NoSuchIdentifierException {
++    public MathTransformBuilder builder(final String method) throws 
NoSuchIdentifierException {
+         if (method.replace('_', ' 
').equalsIgnoreCase(Constants.COORDINATE_SYSTEM_CONVERSION)) {
+             return new CoordinateSystemTransformBuilder(this);
+         }
+         return new ParameterizedTransformBuilder(this, 
getOperationMethod(method));
+     }
+ 
      /**
       * Returns the default parameter values for a math transform using the 
given operation method.
       * The {@code method} argument is the name of any {@code OperationMethod} 
instance returned by
@@@ -618,7 -562,9 +565,9 @@@
           * The source ellipsoid is unconditionally set to {@code null}.
           *
           * @param  cs  the coordinate system to set as the source (can be 
{@code null}).
 -         * @deprecated Replaced by {@link 
MathTransform.Builder#setSourceAxes(CoordinateSystem, Ellipsoid)}.
++         * @deprecated Replaced by {@link 
MathTransformBuilder#setSourceAxes(CoordinateSystem, Ellipsoid)}.
           */
+         @Deprecated(since="1.5", forRemoval=true)
          public void setSource(final CoordinateSystem cs) {
              sourceCS = cs;
              sourceEllipsoid = null;
@@@ -636,7 -583,9 +586,9 @@@
           * @param  crs  the coordinate system and ellipsoid to set as the 
source, or {@code null}.
           *
           * @since 1.3
 -         * @deprecated Replaced by {@link 
MathTransform.Builder#setSourceAxes(CoordinateSystem, Ellipsoid)}.
++         * @deprecated Replaced by {@link 
MathTransformBuilder#setSourceAxes(CoordinateSystem, Ellipsoid)}.
           */
+         @Deprecated(since="1.5", forRemoval=true)
          public void setSource(final GeodeticCRS crs) {
              if (crs != null) {
                  sourceCS = crs.getCoordinateSystem();
@@@ -652,7 -602,9 +605,9 @@@
           * The target ellipsoid is unconditionally set to {@code null}.
           *
           * @param  cs  the coordinate system to set as the target (can be 
{@code null}).
 -         * @deprecated Replaced by {@link 
MathTransform.Builder#setTargetAxes(CoordinateSystem, Ellipsoid)}.
++         * @deprecated Replaced by {@link 
MathTransformBuilder#setTargetAxes(CoordinateSystem, Ellipsoid)}.
           */
+         @Deprecated(since="1.5", forRemoval=true)
          public void setTarget(final CoordinateSystem cs) {
              targetCS = cs;
              targetEllipsoid = null;
@@@ -670,7 -623,9 +626,9 @@@
           * @param  crs  the coordinate system and ellipsoid to set as the 
target, or {@code null}.
           *
           * @since 1.3
 -         * @deprecated Replaced by {@link 
MathTransform.Builder#setTargetAxes(CoordinateSystem, Ellipsoid)}.
++         * @deprecated Replaced by {@link 
MathTransformBuilder#setTargetAxes(CoordinateSystem, Ellipsoid)}.
           */
+         @Deprecated(since="1.5", forRemoval=true)
          public void setTarget(final GeodeticCRS crs) {
              if (crs != null) {
                  targetCS = crs.getCoordinateSystem();
@@@ -810,7 -736,7 +739,7 @@@
           * @throws IllegalStateException if {@link 
#createParameterizedTransform(ParameterValueGroup, Context)}
           *         has not yet been invoked.
           *
-          * @see #getLastMethodUsed()
 -         * @deprecated Replaced by {@link MathTransform.Builder#getMethod()}.
++         * @deprecated Replaced by {@link MathTransformBuilder#getMethod()}.
           *
           * @since 1.3
           */
@@@ -1759,10 -1211,12 +1198,12 @@@
       *
       * @return the last method used by a {@code create(…)} constructor, or 
{@code null} if unknown of unsupported.
       *
-      * @see #createParameterizedTransform(ParameterValueGroup, Context)
-      * @see Context#getMethodUsed()
+      * @see #createParameterizedTransform(ParameterValueGroup)
+      *
 -     * @deprecated Replaced by {@link MathTransform.Builder#getMethod()}.
++     * @deprecated Replaced by {@link MathTransformBuilder#getMethod()}.
       */
      @Override
+     @Deprecated(since = "1.5")
      public OperationMethod getLastMethodUsed() {
          return lastMethod.get();
      }
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/MathTransformBuilder.java
index 0000000000,9ca49145f5..b9b6151db1
mode 000000,100644..100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/MathTransformBuilder.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/MathTransformBuilder.java
@@@ -1,0 -1,111 +1,196 @@@
+ /*
+  * Licensed to the Apache Software Foundation (ASF) under one or more
+  * contributor license agreements.  See the NOTICE file distributed with
+  * this work for additional information regarding copyright ownership.
+  * The ASF licenses this file to You under the Apache License, Version 2.0
+  * (the "License"); you may not use this file except in compliance with
+  * the License.  You may obtain a copy of the License at
+  *
+  *     http://www.apache.org/licenses/LICENSE-2.0
+  *
+  * Unless required by applicable law or agreed to in writing, software
+  * distributed under the License is distributed on an "AS IS" BASIS,
+  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  * See the License for the specific language governing permissions and
+  * limitations under the License.
+  */
+ package org.apache.sis.referencing.operation.transform;
+ 
+ import java.util.Objects;
+ import java.util.Optional;
+ import org.opengis.referencing.operation.MathTransform;
+ import org.opengis.referencing.operation.MathTransformFactory;
+ import org.opengis.referencing.operation.OperationMethod;
+ import org.apache.sis.metadata.iso.citation.Citations;
+ import org.apache.sis.referencing.IdentifiedObjects;
+ import org.apache.sis.util.privy.Strings;
+ 
++// Specific to the main and geoapi-3.1 branches:
++import org.opengis.util.FactoryException;
++import org.opengis.referencing.datum.Ellipsoid;
++import org.opengis.referencing.cs.CoordinateSystem;
++import org.opengis.parameter.ParameterValueGroup;
++
+ 
+ /**
+  * Builder of a parameterized math transform using a method identified by a 
name or code.
+  * A builder instance is created by a call to {@link 
DefaultMathTransformFactory#builder(String)}.
+  * The {@linkplain #parameters() parameters} are set to default values and 
should be modified
+  * in-place by the caller. If the transform requires semi-major and 
semi-minor axis lengths,
+  * those parameters can be set directly or {@linkplain #setSourceAxes 
indirectly}.
+  * Then, the transform is created by a call to {@link #create()}.
+  *
+  * @author  Martin Desruisseaux (Geomatys)
+  * @version 1.5
+  * @since   1.5
+  */
 -public abstract class MathTransformBuilder implements MathTransform.Builder {
++public abstract class MathTransformBuilder {
+     /**
+      * The factory to use for building the transform.
+      */
+     protected final MathTransformFactory factory;
+ 
+     /**
+      * The provider that created the parameterized {@link MathTransform} 
instance, or {@code null}
+      * if this information does not apply. This is initially set to the 
operation method specified
+      * in the call to {@link #builder(String)}, but may be modified by {@link 
#create()}.
+      *
+      * <p>This operation method is usually an instance of {@link 
MathTransformProvider},
+      * but not necessarily.</p>
+      *
+      * @see #getMethod()
+      */
+     protected OperationMethod provider;
+ 
+     /**
+      * Creates a new builder.
+      *
+      * @param  factory  factory to use for building the transform.
+      */
+     protected MathTransformBuilder(final MathTransformFactory factory) {
+         this.factory = Objects.requireNonNull(factory);
+     }
+ 
+     /**
+      * Returns the operation method used for creating the math transform from 
the parameter values.
+      * This is initially the operation method specified in the call to {@link 
#builder(String)},
+      * but may change after the call to {@link #create()} if the method has 
been adjusted because
+      * of the parameter values.
+      *
+      * @return the operation method used for creating the math transform from 
the parameter values.
+      */
 -    @Override
+     public final Optional<OperationMethod> getMethod() {
+         return Optional.ofNullable(provider);
+     }
+ 
++    /**
++     * Returns the parameter values of the transform to create.
++     * Those parameters are initialized to default values, which may be 
implementation or method depend.
++     * 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>.
++     *
++     * @return the parameter values of the transform to create. Values should 
be set in-place.
++     */
++    public abstract ParameterValueGroup parameters();
++
++    /**
++     * Gives hints about axis lengths and their orientations in input 
coordinates.
++     * The action performed by this call depends on the {@linkplain 
#getMethod() operation method}.
++     * For map projections, the action may include something equivalent to 
the following code:
++     *
++     * {@snippet lang="java" :
++     * 
parameters().parameter("semi_major").setValue(ellipsoid.getSemiMajorAxis(), 
ellipsoid.getAxisUnit());
++     * 
parameters().parameter("semi_minor").setValue(ellipsoid.getSemiMinorAxis(), 
ellipsoid.getAxisUnit());
++     * }
++     *
++     * For geodetic datum shifts, the action may be similar to above code but 
with different parameter names:
++     * {@code "src_semi_major"} and {@code "src_semi_minor"}. Other operation 
methods may ignore the arguments.
++     *
++     * <h4>Axis order, units and direction</h4>
++     * By default, the source axes of a parameterized transform are 
normalized to <var>east</var>,
++     * <var>north</var>, <var>up</var> (if applicable) directions with units 
in degrees and meters.
++     * If this requirement is ambiguous, for example because the operation 
method uses incompatible
++     * axis directions or units, then the {@code cs} argument should be 
non-null for allowing the
++     * implementation to resolve that ambiguity.
++     *
++     * @param  cs         the coordinate system defining source axis order 
and units, or {@code null} if none.
++     * @param  ellipsoid  the ellipsoid providing source semi-axis lengths, 
or {@code null} if none.
++     */
++    public void setSourceAxes(CoordinateSystem cs, Ellipsoid ellipsoid) {
++    }
++
++    /**
++     * Gives hints about axis lengths and their orientations in output 
coordinates.
++     * The action performed by this call depends on the {@linkplain 
#getMethod() operation method}.
++     * For datum shifts, the action may include something equivalent to the 
following code:
++     *
++     * {@snippet lang="java" :
++     * 
parameters().parameter("tgt_semi_major").setValue(ellipsoid.getSemiMajorAxis(), 
ellipsoid.getAxisUnit());
++     * 
parameters().parameter("tgt_semi_minor").setValue(ellipsoid.getSemiMinorAxis(), 
ellipsoid.getAxisUnit());
++     * }
++     *
++     * <h4>Axis order, units and direction</h4>
++     * By default, the target axes of a parameterized transform are 
normalized to <var>east</var>,
++     * <var>north</var>, <var>up</var> (if applicable) directions with units 
in degrees and meters.
++     * If this requirement is ambiguous, for example because the operation 
method uses incompatible
++     * axis directions or units, then the {@code cs} argument should be 
non-null for allowing the
++     * implementation to resolve that ambiguity.
++     *
++     * @param  cs         the coordinate system defining target axis order 
and units, or {@code null} if none.
++     * @param  ellipsoid  the ellipsoid providing target semi-axis lengths, 
or {@code null} if none.
++     */
++    public void setTargetAxes(CoordinateSystem cs, Ellipsoid ellipsoid) {
++    }
++
++    /**
++     * Creates the parameterized transform. The operation method is given by 
{@link #getMethod()}
++     * and the parameter values should have been set on the group returned by 
{@link #parameters()}
++     * before to invoke this constructor.
++     * Example:
++     *
++     * {@snippet lang="java" :
++     * MathTransformFactory  factory = ...;
++     * MathTransformBuilder builder = factory.builder("Transverse_Mercator");
++     * ParameterValueGroup   pg = builder.parameters();
++     * pg.parameter("semi_major").setValue(6378137.000);
++     * pg.parameter("semi_minor").setValue(6356752.314);
++     * MathTransform mt = builder.create();
++     * }
++     *
++     * @return the parameterized transform.
++     * @throws FactoryException if the transform creation failed.
++     *         This exception is thrown if some required parameters have not 
been supplied, or have illegal values.
++     */
++    public abstract MathTransform create() throws FactoryException;
++
+     /**
+      * Eventually replaces the given transform by a unique instance. The 
replacement is done
+      * only if the {@linkplain #factory} is an instance of {@link 
DefaultMathTransformFactory}
+      * and {@linkplain DefaultMathTransformFactory#caching(boolean) caching} 
is enabled.
+      *
+      * <p>This is a helper method for {@link #create()} implementations.</p>
+      *
+      * @param  result  the newly created transform.
+      * @return a transform equals to the given transform (may be the given 
transform itself).
+      */
+     protected MathTransform unique(MathTransform result) {
+         if (factory instanceof DefaultMathTransformFactory) {
+             final var df = (DefaultMathTransformFactory) factory;
+             df.lastMethod.set(getMethod().orElse(null));
+             result = df.unique(result);
+         }
+         return result;
+     }
+ 
+     /**
+      * Returns a string representation of this builder for debugging purposes.
+      *
+      * @return a string representation of this builder.
+      */
+     @Override
+     public String toString() {
+         return Strings.toString(getClass(),
+                 "factory", Citations.getIdentifier(factory.getVendor()),
+                 "method", IdentifiedObjects.getDisplayName(provider, null));
+     }
+ }
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/privy/CoordinateOperations.java
index 8eabc60239,9034a63ef8..5614bc7fe9
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/privy/CoordinateOperations.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/privy/CoordinateOperations.java
@@@ -50,6 -56,6 +56,10 @@@ import org.apache.sis.system.Loggers
  
  // Specific to the main and geoapi-3.1 branches:
  import org.opengis.referencing.crs.GeneralDerivedCRS;
++import org.apache.sis.referencing.operation.transform.MathTransformBuilder;
++import org.apache.sis.referencing.internal.ParameterizedTransformBuilder;
++import org.opengis.referencing.operation.MathTransform;
++import org.opengis.util.FactoryException;
  
  
  /**
@@@ -202,6 -273,6 +277,33 @@@ public final class CoordinateOperation
                  Resources.Keys.NoSuchOperationMethod_2, identifier, 
URLs.OPERATION_METHODS), identifier);
      }
  
++    /**
++     * Returns the transform builder for the specified method name, using 
user-overrideable method if possible.
++     * If the given factory is an instance of {@link 
DefaultMathTransformFactory}, then this method delegates to it.
++     *
++     * @param  mtFactory  the factory to use for a builder.
++     * @param  method     the name of the operation method to fetch.
++     * @return the transform builder for the operation method of the given 
name.
++     * @throws NoSuchIdentifierException if the requested operation method 
cannot be found.
++     */
++    public static MathTransformBuilder builder(final MathTransformFactory 
mtFactory, final String method)
++            throws NoSuchIdentifierException
++    {
++        if (mtFactory instanceof DefaultMathTransformFactory) {
++            return ((DefaultMathTransformFactory) mtFactory).builder(method);
++        } else {
++            final var m = 
findMethod(mtFactory.getAvailableMethods(SingleOperation.class), method);
++            return new ParameterizedTransformBuilder(mtFactory, m) {
++                @Override public MathTransform create() throws 
FactoryException {
++                    if (sourceCS == null && targetCS == null && 
sourceEllipsoid == null && targetEllipsoid == null) {
++                        return 
swapAndScaleAxes(factory.createParameterizedTransform(parameters()));
++                    }
++                    return super.create();
++                }
++            };
++        }
++    }
++
      /**
       * Returns {@code true} if the given transform factory is the default 
instances used by Apache SIS.
       *
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/privy/ReferencingUtilities.java
index bd55de5634,d520a6c8dd..886673f8e5
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/privy/ReferencingUtilities.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/privy/ReferencingUtilities.java
@@@ -54,15 -51,7 +51,14 @@@ import org.apache.sis.referencing.cs.Ax
  import org.apache.sis.referencing.cs.DefaultEllipsoidalCS;
  import org.apache.sis.referencing.internal.VerticalDatumTypes;
  import 
org.apache.sis.referencing.operation.transform.DefaultMathTransformFactory;
- import 
org.apache.sis.referencing.operation.transform.DefaultMathTransformFactory.Context;
  
 +// Specific to the main branch:
 +import java.util.Collection;
 +import java.util.NoSuchElementException;
 +import org.opengis.referencing.ReferenceIdentifier;
 +import org.apache.sis.metadata.privy.Identifiers;
 +import org.apache.sis.xml.NilObject;
 +
  
  /**
   * A set of static methods working on GeoAPI referencing objects.
diff --cc 
endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/internal/ParameterizedTransformBuilderTest.java
index 0000000000,45f5c4546e..730724c996
mode 000000,100644..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
@@@ -1,0 -1,116 +1,116 @@@
+ /*
+  * Licensed to the Apache Software Foundation (ASF) under one or more
+  * contributor license agreements.  See the NOTICE file distributed with
+  * this work for additional information regarding copyright ownership.
+  * The ASF licenses this file to You under the Apache License, Version 2.0
+  * (the "License"); you may not use this file except in compliance with
+  * the License.  You may obtain a copy of the License at
+  *
+  *     http://www.apache.org/licenses/LICENSE-2.0
+  *
+  * Unless required by applicable law or agreed to in writing, software
+  * distributed under the License is distributed on an "AS IS" BASIS,
+  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  * See the License for the specific language governing permissions and
+  * limitations under the License.
+  */
+ package org.apache.sis.referencing.internal;
+ 
+ import org.opengis.util.FactoryException;
+ import org.opengis.referencing.operation.MathTransform;
+ import org.apache.sis.referencing.operation.matrix.Matrices;
+ import org.apache.sis.referencing.operation.transform.MathTransforms;
+ import 
org.apache.sis.referencing.operation.transform.DefaultMathTransformFactory;
+ import org.apache.sis.referencing.factory.InvalidGeodeticParameterException;
+ 
+ // Test dependencies
+ import org.junit.jupiter.api.Test;
+ import static org.junit.jupiter.api.Assertions.*;
+ import org.apache.sis.test.TestCase;
+ import org.apache.sis.referencing.cs.HardCodedCS;
+ import static org.apache.sis.test.Assertions.assertMessageContains;
+ 
 -// Specific to the geoapi-3.1 and geoapi-4.0 branches:
 -import static org.opengis.test.Assertions.assertMatrixEquals;
++// Specific to the main branch:
++import static org.apache.sis.test.GeoapiAssert.assertMatrixEquals;
+ 
+ 
+ /**
+  * Tests the {@link ParameterizedTransformBuilder} class.
+  *
+  * @author  Martin Desruisseaux (Geomatys)
+  */
+ public final class ParameterizedTransformBuilderTest extends TestCase {
+     /**
+      * Creates a new test case.
+      */
+     public ParameterizedTransformBuilderTest() {
+     }
+ 
+     /**
+      * Tests {@link 
DefaultMathTransformFactory#swapAndScaleAxes(MathTransform, 
MathTransformProvider.Context)}
+      * with different number of dimensions.
+      *
+      * @throws FactoryException if the transform construction failed.
+      */
+     @Test
+     public void testSwapAndScaleAxes() throws FactoryException {
+         final var context = new 
ParameterizedTransformBuilder(DefaultMathTransformFactory.provider(), null);
+         context.setSourceAxes(HardCodedCS.GEODETIC_3D,  null);
+         context.setTargetAxes(HardCodedCS.CARTESIAN_3D, null);
+         /*
+          * Simulate a case where the parameterized transform is a 
two-dimensional map projection,
+          * but the input and output CRS are three-dimensional geographic and 
projected CRS respectively.
+          */
+         MathTransform mt = 
context.swapAndScaleAxes(MathTransforms.identity(2));
+         assertEquals(3, mt.getSourceDimensions());
+         assertEquals(3, mt.getTargetDimensions());
+         assertTrue(mt.isIdentity());
+         /*
+          * Transform from 3D to 2D. Height dimension is dropped.
+          */
+         context.setSourceAxes(HardCodedCS.GEODETIC_3D, null);
+         context.setTargetAxes(HardCodedCS.GEODETIC_2D, null);
+         mt = context.swapAndScaleAxes(MathTransforms.identity(2));
+         var expected = Matrices.create(3, 4, new double[] {
+             1, 0, 0, 0,
+             0, 1, 0, 0,
+             0, 0, 0, 1
+         });
+         assertMatrixEquals(expected, MathTransforms.getMatrix(mt), STRICT, 
"3D → 2D");
+         /*
+          * Transform from 2D to 3D. Coordinate values in the height dimension 
are unknown (NaN).
+          * This case happen when the third dimension is handled as a "pass 
through" dimension.
+          */
+         context.setSourceAxes(HardCodedCS.GEODETIC_2D, null);
+         context.setTargetAxes(HardCodedCS.GEODETIC_3D, null);
+         mt = context.swapAndScaleAxes(MathTransforms.identity(2));
+         expected = Matrices.create(4, 3, new double[] {
+             1, 0, 0,
+             0, 1, 0,
+             0, 0, Double.NaN,
+             0, 0, 1
+         });
+         assertMatrixEquals(expected, MathTransforms.getMatrix(mt), STRICT, 
"2D → 3D");
+         /*
+          * Same transform from 2D to 3D, but this time with the height 
consumed by the parameterized operation.
+          * This is differentiated from the previous case by the fact that the 
parameterized operation is three-dimensional.
+          */
+         mt = context.swapAndScaleAxes(MathTransforms.identity(3));
+         expected = Matrices.create(4, 3, new double[] {
+             1, 0, 0,
+             0, 1, 0,
+             0, 0, 0,
+             0, 0, 1
+         });
+         assertMatrixEquals(expected, MathTransforms.getMatrix(mt), STRICT, 
"2D → 3D");
+         /*
+          * Test error message when adding a dimension that is not ellipsoidal 
height.
+          */
+         context.setSourceAxes(HardCodedCS.CARTESIAN_2D, null);
+         context.setTargetAxes(HardCodedCS.CARTESIAN_3D, null);
+         var e = assertThrows(InvalidGeodeticParameterException.class,
+                 () -> context.swapAndScaleAxes(MathTransforms.identity(2)),
+                 "Should not have accepted the given coordinate systems.");
+         assertMessageContains(e, "2D → tr(2D → 2D) → 3D");
+     }
+ }
diff --cc 
endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/MathTransformFactoryMock.java
index 188bd52c8c,0a9459255d..b5ef45d8ea
--- 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/MathTransformFactoryMock.java
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/MathTransformFactoryMock.java
@@@ -23,12 -24,11 +23,12 @@@ import org.opengis.metadata.citation.Ci
  import org.opengis.parameter.ParameterValueGroup;
  import org.opengis.referencing.crs.CoordinateReferenceSystem;
  import org.opengis.referencing.cs.CoordinateSystem;
++import org.opengis.referencing.operation.Matrix;
  import org.opengis.referencing.operation.MathTransform;
  import org.opengis.referencing.operation.MathTransformFactory;
--import org.opengis.referencing.operation.Matrix;
++import org.opengis.referencing.operation.Conversion;
  import org.opengis.referencing.operation.OperationMethod;
  import org.opengis.referencing.operation.SingleOperation;
- import org.apache.sis.referencing.operation.DefaultOperationMethod;
  import org.apache.sis.referencing.operation.provider.AbstractProvider;
  
  // Test dependencies
@@@ -90,7 -93,7 +90,7 @@@ public final class MathTransformFactory
       */
      @Override
      public Set<OperationMethod> getAvailableMethods(Class<? extends 
SingleOperation> type) {
--        return type.isInstance(method) ? Set.of(method) : Set.of();
++        return type.isAssignableFrom(Conversion.class) ? Set.of(method) : 
Set.of();
      }
  
      /**
@@@ -127,9 -162,10 +127,9 @@@
       * @throws FactoryException if the provider cannot create the transform.
       */
      @Override
 -    @Deprecated
      public MathTransform createParameterizedTransform(ParameterValueGroup 
parameters) throws FactoryException {
          lastParameters = parameters;
-         return ((MathTransformProvider) method).createMathTransform(this, 
parameters);
+         return method.createMathTransform(this, parameters);
      }
  
      /**
diff --cc 
incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/ShapefileStore.java
index b22fa3710b,2457bb271c..453f5f56b7
--- 
a/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/ShapefileStore.java
+++ 
b/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/ShapefileStore.java
@@@ -104,20 -104,22 +104,21 @@@ import org.apache.sis.storage.shapefile
  import org.apache.sis.storage.shapefile.shp.ShapeWriter;
  import org.apache.sis.storage.shapefile.shx.IndexWriter;
  import org.apache.sis.util.ArraysExt;
+ import org.apache.sis.util.Utilities;
  import org.apache.sis.util.collection.BackingStoreException;
  
 -// Specific to the geoapi-3.1 and geoapi-4.0 branches:
 -import org.opengis.util.CodeList;
 -import org.opengis.feature.Feature;
 -import org.opengis.feature.FeatureType;
 -import org.opengis.feature.PropertyType;
 -import org.opengis.feature.AttributeType;
 -import org.opengis.filter.Expression;
 -import org.opengis.filter.Filter;
 -import org.opengis.filter.Literal;
 -import org.opengis.filter.LogicalOperator;
 -import org.opengis.filter.LogicalOperatorName;
 -import org.opengis.filter.SpatialOperatorName;
 -import org.opengis.filter.ValueReference;
 +// Specific to the main branch:
 +import org.apache.sis.feature.AbstractFeature;
 +import org.apache.sis.feature.DefaultFeatureType;
 +import org.apache.sis.feature.AbstractIdentifiedType;
 +import org.apache.sis.feature.DefaultAttributeType;
 +import org.apache.sis.filter.Expression;
 +import org.apache.sis.filter.Filter;
 +import org.apache.sis.pending.geoapi.filter.Literal;
 +import org.apache.sis.pending.geoapi.filter.LogicalOperator;
 +import org.apache.sis.pending.geoapi.filter.LogicalOperatorName;
 +import org.apache.sis.pending.geoapi.filter.SpatialOperatorName;
 +import org.apache.sis.pending.geoapi.filter.ValueReference;
  
  
  /**


Reply via email to