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 95d808c4049730bd9a3e0c99b23d39197ebebaf6
Merge: 3d377745a9 3abad8c520
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Wed Jun 11 13:06:46 2025 +0200

    Merge branch 'geoapi-3.1'.

 README.md                                          |   4 +-
 .../org/apache/sis/cloud/aws/s3/FileService.java   |   2 +-
 .../sis/coverage/grid/ResampledGridCoverage.java   |   2 +-
 .../apache/sis/feature/AbstractIdentifiedType.java |  90 ++-
 .../org/apache/sis/feature/AbstractOperation.java  |  67 +-
 .../apache/sis/feature/DefaultAssociationRole.java |  15 +-
 .../apache/sis/feature/DefaultAttributeType.java   |  15 +-
 .../org/apache/sis/feature/EnvelopeOperation.java  | 194 +++--
 .../org/apache/sis/feature/FeatureOperations.java  |  18 +-
 .../main/org/apache/sis/feature/Features.java      |   4 +-
 .../sis/feature/GroupAsPolylineOperation.java      |  40 +-
 .../main/org/apache/sis/feature/LinkOperation.java |  15 +
 .../apache/sis/feature/StringJoinOperation.java    |  80 ++-
 .../feature/builder/AssociationRoleBuilder.java    |   2 +-
 .../sis/feature/builder/AttributeTypeBuilder.java  |   2 +-
 .../feature/builder/CharacteristicTypeBuilder.java |   2 +-
 .../sis/feature/builder/FeatureTypeBuilder.java    |   6 +-
 .../sis/feature/builder/OperationWrapper.java      |  37 +
 .../sis/feature/builder/PropertyTypeBuilder.java   |  13 +
 .../apache/sis/feature/builder/TypeBuilder.java    |   2 +-
 .../org/apache/sis/feature/internal/Resources.java |   5 +
 .../sis/feature/internal/Resources.properties      |   1 +
 .../sis/feature/internal/Resources_fr.properties   |   1 +
 .../feature/privy/FeatureProjectionBuilder.java    |  51 +-
 .../main/org/apache/sis/xml/XML.java               |   2 +-
 ...g.opengis.referencing.operation.OperationMethod |   2 +
 .../main/module-info.java                          |   2 +
 .../apache/sis/io/wkt/GeodeticObjectParser.java    |   2 +-
 .../org/apache/sis/parameter/ParameterFormat.java  |   2 +-
 .../org/apache/sis/parameter/Parameterized.java    |   3 +
 .../sis/referencing/cs/CoordinateSystems.java      |  63 +-
 .../sis/referencing/cs/DefaultCompoundCS.java      |   7 +-
 .../sis/referencing/datum/BursaWolfParameters.java |   2 +-
 .../referencing/datum/DefaultDatumEnsemble.java    |   2 +-
 .../sis/referencing/datum/DefaultEllipsoid.java    |  55 +-
 .../referencing/datum/DefaultGeodeticDatum.java    |   2 +-
 .../org/apache/sis/referencing/datum/Sphere.java   |  15 +-
 .../internal/ParameterizedTransformBuilder.java    | 235 +++---
 .../operation/CoordinateOperationFinder.java       | 210 ++----
 .../operation/CoordinateOperationRegistry.java     |   2 +-
 .../referencing/operation/DefaultConversion.java   |   2 +-
 .../DefaultCoordinateOperationFactory.java         |  11 +-
 .../operation/MathTransformContext.java            |  90 ++-
 .../operation/matrix/GeneralMatrix.java            |  22 +-
 .../sis/referencing/operation/matrix/Matrices.java |   3 +
 .../operation/provider/AbstractProvider.java       |  52 +-
 .../GeocentricAffineBetweenGeographic.java         |  53 +-
 .../operation/provider/GeocentricToGeographic.java |   4 +-
 .../provider/GeocentricToTopocentric.java          |  16 +-
 .../provider/GeocentricTranslation3D.java          |   1 +
 .../operation/provider/Geographic2Dto3D.java       |  26 +-
 .../operation/provider/Geographic3Dto2D.java       |  19 +-
 .../operation/provider/GeographicToGeocentric.java |  40 +-
 .../operation/provider/MapProjection.java          |  28 +-
 ...{Geographic2Dto3D.java => Spherical2Dto3D.java} |  59 +-
 ...{Geographic2Dto3D.java => Spherical3Dto2D.java} |  63 +-
 .../operation/transform/AbstractMathTransform.java | 183 ++++-
 .../operation/transform/CartesianToPolar.java      |   6 +-
 .../operation/transform/CartesianToSpherical.java  |   6 +-
 .../operation/transform/ConcatenatedTransform.java |  24 +-
 .../operation/transform/ContextualParameters.java  |  80 ++-
 .../transform/CoordinateSystemTransform.java       |  34 +-
 .../CoordinateSystemTransformBuilder.java          | 350 ++++++---
 .../operation/transform/CopyTransform.java         |  25 +-
 .../operation/transform/DatumShiftTransform.java   |   3 +-
 .../transform/DefaultMathTransformFactory.java     |   8 +-
 .../transform/EllipsoidToCentricTransform.java     | 787 ++++++++++++---------
 .../transform/EllipsoidToRadiusTransform.java      | 505 +++++++++++++
 .../transform/InterpolatedGeocentricTransform.java |   9 +-
 .../operation/transform/LinearTransform1D.java     |   5 +-
 .../operation/transform/MathTransforms.java        |  20 +-
 .../operation/transform/MolodenskyTransform.java   |   1 +
 .../operation/transform/OnewayLinearTransform.java | 190 +++++
 .../operation/transform/PolarToCartesian.java      |   6 +-
 .../operation/transform/PoleRotation.java          |  13 +-
 .../operation/transform/SphericalToCartesian.java  |   8 +-
 .../operation/transform/TransformSeparator.java    |   6 +-
 .../org/apache/sis/referencing/privy/Formulas.java |  26 +-
 .../referencing/privy/ReferencingUtilities.java    |  27 +
 .../ParameterizedTransformBuilderTest.java         |   2 +-
 .../operation/CoordinateOperationFinderTest.java   |  64 ++
 .../provider/GeocentricTranslationTest.java        |  10 +-
 .../operation/provider/Geographic3Dto2DTest.java   |   5 +-
 .../operation/provider/ProvidersTest.java          |   2 +
 .../transform/EllipsoidToCentricTransformTest.java | 135 ++--
 .../transform/EllipsoidToRadiusTransformTest.java  | 162 +++++
 .../EllipsoidToSphericalTransformTest.java         | 198 ++++++
 .../operation/transform/MathTransformWrapper.java  |   4 +-
 .../transform/TransformSeparatorTest.java          |   4 +-
 .../apache/sis/storage/geotiff/GeoTiffStore.java   |   2 +-
 .../apache/sis/storage/sql/feature/Relation.java   |   2 +-
 .../main/org/apache/sis/storage/gpx/Store.java     |  17 -
 .../main/org/apache/sis/storage/gpx/Types.java     |  26 +-
 .../apache/sis/io/stream/FileCacheByteChannel.java |  24 +-
 .../main/org/apache/sis/storage/FeatureQuery.java  |  12 +-
 .../main/org/apache/sis/storage/FeatureSet.java    |   1 -
 .../main/org/apache/sis/storage/FeatureSubset.java |   4 +-
 .../org/apache/sis/storage/StorageConnector.java   |   2 +-
 .../sis/storage/UnsupportedQueryException.java     |  12 +
 .../sis/storage/base/FeatureCatalogBuilder.java    |  81 ---
 .../apache/sis/storage/base/MetadataBuilder.java   |   3 -
 .../org/apache/sis/storage/FeatureQueryTest.java   |   2 +-
 .../sis/storage/base/MetadataBuilderTest.java      |   4 +-
 .../main/org/apache/sis/pending/jdk/JDK21.java     |  24 +-
 .../org/apache/sis/util/privy/CollectionsExt.java  |   8 +-
 netbeans-project/nbproject/project.xml             |   2 +
 optional/src/org.apache.sis.gui/bundle/README      |   2 +-
 settings.gradle.kts                                |   1 +
 108 files changed, 3550 insertions(+), 1350 deletions(-)

diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractIdentifiedType.java
index a4062967b0,b71469591d..95f2e249eb
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractIdentifiedType.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractIdentifiedType.java
@@@ -204,11 -215,13 +217,13 @@@ public class AbstractIdentifiedType imp
       */
      @SuppressWarnings("this-escape")
      protected AbstractIdentifiedType(final Map<String,?> identification) 
throws IllegalArgumentException {
-         // Implicit null value check.
-         Object value = identification.get(NAME_KEY);
 -        final IdentifiedType inheritFrom = 
Containers.property(identification, INHERIT_FROM_KEY, IdentifiedType.class);
++        final AbstractIdentifiedType inheritFrom = 
Containers.property(identification, INHERIT_FROM_KEY, 
AbstractIdentifiedType.class);
+         Object value = identification.get(NAME_KEY);    // Implicit null 
value check.
          if (value == null) {
-             throw new 
IllegalArgumentException(Errors.forProperties(identification)
-                     .getString(Errors.Keys.MissingValueForProperty_1, 
NAME_KEY));
+             if (inheritFrom == null || (name = inheritFrom.getName()) == 
null) {
+                 throw new 
IllegalArgumentException(Errors.forProperties(identification)
+                         .getString(Errors.Keys.MissingValueForProperty_1, 
NAME_KEY));
+             }
          } else if (value instanceof String) {
              name = createName(DefaultNameFactory.provider(), (String) value);
          } else if (value instanceof GenericName) {
@@@ -216,12 -229,12 +231,12 @@@
          } else {
              throw illegalPropertyType(identification, NAME_KEY, value);
          }
-         definition  = Types.toInternationalString(identification, 
DEFINITION_KEY);
-         designation = Types.toInternationalString(identification, 
DESIGNATION_KEY);
-         description = Types.toInternationalString(identification, 
DESCRIPTION_KEY);
+         definition  = toInternationalString(identification, DEFINITION_KEY,  
inheritFrom);
+         designation = toInternationalString(identification, DESIGNATION_KEY, 
inheritFrom);
+         description = toInternationalString(identification, DESCRIPTION_KEY, 
inheritFrom);
          value = identification.get(DEPRECATED_KEY);
          if (value == null) {
-             deprecated = false;
 -            deprecated = (inheritFrom instanceof Deprecable) ? ((Deprecable) 
inheritFrom).isDeprecated() : false;
++            deprecated = (inheritFrom != null) && inheritFrom.isDeprecated();
          } else if (value instanceof Boolean) {
              deprecated = (Boolean) value;
          } else {
@@@ -229,6 -242,29 +244,29 @@@
          }
      }
  
+     /**
+      * Returns an international string for the values in the given properties 
map, or {@code null} if none.
+      *
+      * @param  identification  the map from which to get the string values 
for an international string.
+      * @param  prefix          the prefix of keys to use for creating the 
international string.
+      * @param  inheritFrom     the type from which to inherit a value if none 
is specified in the map, or {@code null}.
+      * @return the international string, or {@code null} if the given map is 
null or does not contain values
+      *         associated to keys starting with the given prefix.
+      */
+     private static InternationalString toInternationalString(
 -            final Map<String,?> identification, final String prefix, final 
IdentifiedType inheritFrom)
++            final Map<String,?> identification, final String prefix, final 
AbstractIdentifiedType inheritFrom)
+     {
+         InternationalString i18n = 
Types.toInternationalString(identification, prefix);
+         if (i18n == null && inheritFrom != null) {
+             switch (prefix) {
+                 case DEFINITION_KEY:  i18n = inheritFrom.getDefinition(); 
break;
+                 case DESIGNATION_KEY: i18n = 
inheritFrom.getDesignation().orElse(null); break;
+                 case DESCRIPTION_KEY: i18n = 
inheritFrom.getDescription().orElse(null); break;
+             }
+         }
+         return i18n;
+     }
+ 
      /**
       * Returns the exception to be thrown when a property is of illegal type.
       */
diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractOperation.java
index 8f1c56dd77,6c76e4c245..088a19ecee
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractOperation.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/AbstractOperation.java
@@@ -47,14 -57,11 +46,11 @@@ import org.apache.sis.parameter.Default
   * <div class="note"><b>Example:</b> a mutator operation may raise the height 
of a dam. This changes
   * may affect other properties like the watercourse and the reservoir 
associated with the dam.</div>
   *
 - * The value is computed, or the operation is executed, by {@link 
#apply(Feature, ParameterValueGroup)}.
 - * If the value is modifiable, new value can be set by call to {@link 
Attribute#setValue(Object)}.
 + * The value is computed, or the operation is executed, by {@code 
apply(Feature, ParameterValueGroup)}.
 + * If the value is modifiable, new value can be set by call to {@code 
Attribute.setValue(Object)}.
   *
-  * <div class="warning"><b>Warning:</b> this class is experimental and may 
change after we gained more
-  * experience on this aspect of ISO 19109.</div>
-  *
   * @author  Martin Desruisseaux (Geomatys)
-  * @version 0.8
+  * @version 1.5
   *
   * @see DefaultFeatureType
   *
@@@ -212,7 -210,36 +203,39 @@@ public abstract class AbstractOperatio
       * @return the names of feature properties needed by this operation for 
performing its task.
       */
      public Set<String> getDependencies() {
-         return Collections.emptySet();
+         return Set.of();
+     }
+ 
+     /**
+      * Returns the same operation but using different properties as inputs.
+      * The keys in the given map should be values returned by {@link 
#getDependencies()},
+      * and the associated values shall be the properties to use instead of 
the current dependencies.
+      * If any key in the given map is not a member of the {@linkplain 
#getDependencies() dependency set},
+      * then the entry is ignored. Conversely, if any member of the dependency 
set is not contained in the
+      * given map, then the associated dependency is unchanged.
+      *
++     * <div class="warning"><b>Warning:</b> In a future SIS version, the 
return type may be changed
++     * to {@code org.opengis.feature.Operation}. This change is pending 
GeoAPI revision.</div>
++     *
+      * <h4>Purpose</h4>
+      * This method is needed by {@link 
org.apache.sis.feature.builder.FeatureTypeBuilder} when some properties
+      * are operations inherited from another feature type. Even if the 
dependencies are properties of the same
+      * name, some {@link DefaultAttributeType#characteristics() 
characteristics} may be different.
+      * For example, the <abbr>CRS</abbr> may change as a result of a change 
of <abbr>CRS</abbr>.
+      *
+      * <h4>Default implementation</h4>
+      * The default implementation returns {@code this}.
+      * This is consistent with the default implementation of {@link 
#getDependencies()} returning an empty set.
+      *
+      * @param  dependencies  the new properties to use as operation inputs.
+      * @return the new operation, or {@code this} if unchanged.
+      *
+      * @see #INHERIT_FROM_KEY
+      *
+      * @since 1.5
+      */
 -    public Operation updateDependencies(final Map<String, PropertyType> 
dependencies) {
++    public AbstractOperation updateDependencies(final Map<String, 
AbstractIdentifiedType> dependencies) {
+         return this;
      }
  
      /**
diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/EnvelopeOperation.java
index ee076b85f4,fc464e7c87..8addd7e879
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/EnvelopeOperation.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/EnvelopeOperation.java
@@@ -37,9 -35,20 +35,10 @@@ import org.apache.sis.geometry.GeneralE
  import org.apache.sis.geometry.wrapper.Geometries;
  import org.apache.sis.geometry.wrapper.GeometryWrapper;
  import org.apache.sis.util.privy.CollectionsExt;
- import org.apache.sis.referencing.CRS;
  import org.apache.sis.util.resources.Errors;
+ import org.apache.sis.referencing.CRS;
+ import org.apache.sis.pending.jdk.JDK21;
  
 -// Specific to the geoapi-3.1 and geoapi-4.0 branches:
 -import org.opengis.feature.Attribute;
 -import org.opengis.feature.AttributeType;
 -import org.opengis.feature.Feature;
 -import org.opengis.feature.FeatureInstantiationException;
 -import org.opengis.feature.IdentifiedType;
 -import org.opengis.feature.Operation;
 -import org.opengis.feature.Property;
 -import org.opengis.feature.PropertyType;
 -
  
  /**
   * An operation computing the envelope that encompass all geometries found in 
a list of attributes.
@@@ -123,48 -157,78 +146,78 @@@ final class EnvelopeOperation extends A
       * @param identification      the name and other information to be given 
to this operation.
       * @param targetCRS           the coordinate reference system of 
envelopes to computes, or {@code null}.
       * @param geometryAttributes  the operation or attribute type from which 
to get geometry values.
+      * @param inheritFrom         the existing operation from which to 
inherit attributes, or {@code null}.
       */
-     EnvelopeOperation(final Map<String,?> identification, 
CoordinateReferenceSystem targetCRS,
-             final AbstractIdentifiedType[] geometryAttributes) throws 
FactoryException
+     EnvelopeOperation(final Map<String,?> identification,
+                       CoordinateReferenceSystem targetCRS,
 -                      final PropertyType[] geometryAttributes,
++                      final AbstractIdentifiedType[] geometryAttributes,
+                       final EnvelopeOperation inheritFrom)
+             throws FactoryException
      {
          super(identification);
-         String defaultGeometry = null;
+         explicitCRS = (targetCRS != null);      // Whether the CRS was 
specified by the user or inferred automatically.
+         boolean characterizedByCRS = false;     // Whether "sis:crs" 
characteristics exist, possibly with null values.
+         String defaultGeometry = null;          // Attribute name of the 
target of the "sis:geometry" property.
+         boolean defaultIsFirst = true;          // Whether the default 
geometry is the first entry in the `names` map.
          /*
-          * Get all property names without duplicated values. If a property is 
a link to an attribute,
-          * then the key will be the name of the referenced attribute instead 
of the operation name.
-          * The intent is to avoid querying the same geometry twice if the 
attribute is also specified
-          * explicitly in the array of properties.
-          *
-          * The map values will be the default Coordinate Reference System, or 
null if none.
+          * Get all property names without duplicated values, including the 
targets of links.
+          * The map values will be the default Coordinate Reference Systems, 
or null if none.
           */
-         boolean characterizedByCRS = false;
-         final Map<String,CoordinateReferenceSystem> names = new 
LinkedHashMap<>(4);
-         for (final AbstractIdentifiedType property : geometryAttributes) {
-             final Optional<DefaultAttributeType<?>> at = 
Features.toAttribute(property);
-             if (at.isPresent() && 
Geometries.isKnownType(at.get().getValueClass())) {
-                 final GenericName name = property.getName();
-                 final String attributeName = (property instanceof 
LinkOperation)
-                                              ? ((LinkOperation) 
property).referentName : name.toString();
-                 final boolean isDefault = 
AttributeConvention.GEOMETRY_PROPERTY.equals(name);
-                 if (isDefault) {
-                     defaultGeometry = attributeName;
+         final var names = new LinkedHashMap<String, 
CoordinateReferenceSystem>(4);
+         for (int i=0; i < geometryAttributes.length; i++) {
+             final String propertyName;          // Name of 
`geometryAttributes[i]`, possibly inherited.
+             final String attributeName;         // Name of the property after 
following the link.
+             CoordinateReferenceSystem attributeCRS = null;
 -            final PropertyType property = geometryAttributes[i];
++            final AbstractIdentifiedType property = geometryAttributes[i];
+             if (property == null && inheritFrom != null) {
+                 /*
+                  * When this constructor is invoked by 
`updateDependencies(Map)`, a null property means to inherit
+                  * the property at the same index from the previous 
operation. The caller is responsible to ensure
+                  * that the indexes match.
+                  */
+                 propertyName = attributeName = inheritFrom.attributeNames[i];
+                 if (inheritFrom.attributeToCRS != null) {
+                     final CoordinateOperation op = 
inheritFrom.attributeToCRS[i];
+                     if (op != null) {
+                         attributeCRS = op.getSourceCRS();
+                         characterizedByCRS = true;
+                     }
                  }
-                 CoordinateReferenceSystem attributeCRS = null;
+             } else {
 -                final AttributeType<?> at = 
Features.toAttribute(property).orElse(null);
++                final DefaultAttributeType<?> at = 
Features.toAttribute(property).orElse(null);
+                 if (at == null || 
!Geometries.isKnownType(at.getValueClass())) {
+                     continue;   // Not a geometry property. Ignore as per 
method contract.
+                 }
+                 /*
+                  * If a property is a link to an attribute, then the key will 
be the name of the referenced
+                  * attribute instead of the operation name. This is for 
avoiding to query the same geometry
+                  * twice when the attribute is also specified explicitly in 
the array of properties.
+                  */
+                 propertyName  = property.getName().toString();
+                 attributeName = 
Features.getLinkTarget(property).orElse(propertyName);
                  /*
-                  * Set `characterizedByCRS` to true if we find at least one 
attribute which may have the
-                  * "CRS" characteristic. Note that we cannot rely on 
`attributeCRS` being non-null
+                  * Set `characterizedByCRS` to `true` if we find at least one 
attribute which have the
+                  * "sis:crs" characteristic. Note that we cannot rely on 
`attributeCRS` being non-null
                   * because an attribute may be characterized by a CRS without 
providing default CRS.
                   */
-                 final DefaultAttributeType<?> ct = 
at.get().characteristics().get(AttributeConvention.CRS);
 -                final AttributeType<?> ct = 
at.characteristics().get(AttributeConvention.CRS);
++                final DefaultAttributeType<?> ct = 
at.characteristics().get(AttributeConvention.CRS);
                  if (ct != null && 
CoordinateReferenceSystem.class.isAssignableFrom(ct.getValueClass())) {
-                     attributeCRS = (CoordinateReferenceSystem) 
ct.getDefaultValue();              // May still null.
-                     if (targetCRS == null && isDefault) {
-                         targetCRS = attributeCRS;
-                     }
+                     attributeCRS = (CoordinateReferenceSystem) 
ct.getDefaultValue();    // May still be null.
                      characterizedByCRS = true;
                  }
-                 names.putIfAbsent(attributeName, attributeCRS);
              }
+             /*
+              * If the user did not specify a CRS explicitly, take the CRS of 
the default geometry.
+              * If there is no default geometry, the CRS of the first geometry 
will be taken in next loop.
+              */
+             if (AttributeConvention.GEOMETRY.equals(propertyName)) {
+                 defaultGeometry = attributeName;
+                 defaultIsFirst = names.isEmpty();
+                 if (targetCRS == null) {
+                     targetCRS = attributeCRS;
+                 }
+             }
+             names.putIfAbsent(attributeName, attributeCRS);
          }
          /*
           * Copy the names in an array with the default geometry first. If 
possible, find the coordinate operations
@@@ -233,11 -293,37 +282,37 @@@
       */
      @Override
      @SuppressWarnings("ReturnOfCollectionOrArrayField")
-     public synchronized Set<String> getDependencies() {
-         if (dependencies == null) {
-             dependencies = CollectionsExt.immutableSet(true, attributeNames);
+     public Set<String> getDependencies() {
+         Set<String> cached = dependencies;
+         if (cached == null) {
+             // Not really a problem if computed twice concurrently.
+             dependencies = cached = CollectionsExt.immutableSet(true, 
attributeNames);
+         }
+         return cached;
+     }
+ 
+     /**
+      * Returns the same operation but using different properties as inputs.
+      *
+      * @param  dependencies  the new properties to use as operation inputs.
+      * @return the new operation, or {@code this} if unchanged.
+      */
+     @Override
 -    public Operation updateDependencies(final Map<String, PropertyType> 
dependencies) {
++    public AbstractOperation updateDependencies(final Map<String, 
AbstractIdentifiedType> dependencies) {
+         boolean foundAny = false;
 -        final var geometryAttributes = new 
PropertyType[attributeNames.length];
++        final var geometryAttributes = new 
AbstractIdentifiedType[attributeNames.length];
+         for (int i=0; i < geometryAttributes.length; i++) {
+             foundAny |= (geometryAttributes[i] = 
dependencies.get(attributeNames[i])) != null;
+         }
+         if (foundAny) try {
+             var op = new EnvelopeOperation(inherit(), explicitCRS ? targetCRS 
: null, geometryAttributes, this);
+             if (!equals(op)) {
+                 return FeatureOperations.POOL.unique(op);
+             }
+         } catch (FactoryException e) {
 -            throw new FeatureInstantiationException(e.getMessage(), e);
++            throw new IllegalStateException(e.getMessage(), e);
          }
-         return dependencies;
+         return this;
      }
  
      /**
@@@ -326,7 -412,7 +401,7 @@@
                           * a CRS characteristic is associated to a particular 
feature, setting `op` to null
                           * will cause a new coordinate operation to be 
searched.
                           */
-                         final AbstractAttribute<?> at = 
((AbstractAttribute<?>) feature.getProperty(attributeNames[i]))
 -                        final var at = ((Attribute<?>) 
feature.getProperty(attributeNames[i]))
++                        final var at = ((AbstractAttribute<?>) 
feature.getProperty(attributeNames[i]))
                                  
.characteristics().get(AttributeConvention.CRS);
                          final Object geomCRS;
                          if (at != null && (geomCRS = at.getValue()) != null) {
diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/FeatureOperations.java
index 4df8b93098,1b9bde711c..07d883ef4b
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/FeatureOperations.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/FeatureOperations.java
@@@ -257,11 -250,11 +257,11 @@@ public final class FeatureOperations ex
       * @return an operation which will compute the envelope encompassing all 
geometries in the given attributes.
       * @throws FactoryException if a coordinate operation to the target CRS 
cannot be created.
       */
 -    public static Operation envelope(final Map<String,?> identification, 
final CoordinateReferenceSystem crs,
 -            final PropertyType... geometryAttributes) throws FactoryException
 +    public static AbstractOperation envelope(final Map<String,?> 
identification, final CoordinateReferenceSystem crs,
 +            final AbstractIdentifiedType... geometryAttributes) throws 
FactoryException
      {
          ArgumentChecks.ensureNonNull("geometryAttributes", 
geometryAttributes);
-         return POOL.unique(new EnvelopeOperation(identification, crs, 
geometryAttributes));
+         return POOL.unique(new EnvelopeOperation(identification, crs, 
geometryAttributes, null));
      }
  
      /**
diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/GroupAsPolylineOperation.java
index 69481d4ba1,7f603f4ca3..64d4fbe988
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/GroupAsPolylineOperation.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/GroupAsPolylineOperation.java
@@@ -28,7 -28,16 +28,8 @@@ import org.apache.sis.geometry.wrapper.
  import org.apache.sis.geometry.wrapper.GeometryType;
  import org.apache.sis.geometry.wrapper.GeometryWrapper;
  import org.apache.sis.setup.GeometryLibrary;
+ import org.apache.sis.util.privy.CollectionsExt;
  
 -// Specific to the geoapi-3.1 and geoapi-4.0 branches:
 -import org.opengis.feature.Feature;
 -import org.opengis.feature.Property;
 -import org.opengis.feature.PropertyType;
 -import org.opengis.feature.AttributeType;
 -import org.opengis.feature.FeatureAssociationRole;
 -import org.opengis.feature.Operation;
 -
  
  /**
   * Creates a single (Multi){@code Polyline} instance from a sequence of 
points or polylines stored in another property.
@@@ -89,8 -98,7 +90,7 @@@ final class GroupAsPolylineOperation ex
              }
              isFeatureAssociation = false;
          } else {
-             isFeatureAssociation = (components instanceof 
DefaultAssociationRole)
-                     && ((DefaultAssociationRole) 
components).getMaximumOccurs() == 1;
 -            isFeatureAssociation = (components instanceof 
FeatureAssociationRole);
++            isFeatureAssociation = (components instanceof 
DefaultAssociationRole);
              if (!isFeatureAssociation) {
                  throw new 
IllegalArgumentException(Resources.format(Resources.Keys.IllegalPropertyType_2,
                                                     components.getName(), 
components.getClass()));
@@@ -121,6 -129,32 +121,32 @@@
          return EMPTY_PARAMS;
      }
  
+     /**
+      * Returns the names of feature properties that this operation needs for 
performing its task.
+      */
+     @Override
+     public Set<String> getDependencies() {
+         return Set.of(propertyName);
+     }
+ 
+     /**
+      * Returns the same operation but using different properties as inputs.
+      *
+      * @param  dependencies  the new properties to use as operation inputs.
+      * @return the new operation, or {@code this} if unchanged.
+      */
+     @Override
 -    public Operation updateDependencies(final Map<String, PropertyType> 
dependencies) {
 -        final PropertyType target = dependencies.get(propertyName);
++    public AbstractOperation updateDependencies(final Map<String, 
AbstractIdentifiedType> dependencies) {
++        final AbstractIdentifiedType target = dependencies.get(propertyName);
+         if (target != null) {
+             final AbstractOperation op = create(inherit(), 
geometries.library, target);
+             if (!equals(op)) {
+                 return FeatureOperations.POOL.unique(op);
+             }
+         }
+         return this;
+     }
+ 
      /**
       * Returns the expected result type.
       */
diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/LinkOperation.java
index 02a3a25491,63da5f3233..7a092d80e0
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/LinkOperation.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/LinkOperation.java
@@@ -94,6 -104,21 +94,21 @@@ final class LinkOperation extends Abstr
          return Set.of(referentName);
      }
  
+     /**
+      * Returns the same operation but using different properties as inputs.
+      *
+      * @param  dependencies  the new properties to use as operation inputs.
+      * @return the new operation, or {@code this} if unchanged.
+      */
+     @Override
 -    public Operation updateDependencies(final Map<String, PropertyType> 
dependencies) {
 -        final PropertyType target = dependencies.get(referentName);
++    public AbstractOperation updateDependencies(final Map<String, 
AbstractIdentifiedType> dependencies) {
++        final AbstractIdentifiedType target = dependencies.get(referentName);
+         if (target == null || target.equals(result)) {
+             return this;
+         }
+         return FeatureOperations.POOL.unique(new LinkOperation(inherit(), 
target));
+     }
+ 
      /**
       * Returns the property from the referenced attribute of feature 
association.
       *
diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/StringJoinOperation.java
index bb3d2431a0,c1c035a7dd..3c62aa7964
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/StringJoinOperation.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/StringJoinOperation.java
@@@ -174,7 -196,8 +183,8 @@@ final class StringJoinOperation extend
       */
      @SuppressWarnings({"rawtypes", "unchecked"})                              
          // Generic array creation.
      StringJoinOperation(final Map<String,?> identification, final String 
delimiter,
-             final String prefix, final String suffix, final 
AbstractIdentifiedType[] singleAttributes)
 -            final String prefix, final String suffix, final PropertyType[] 
singleAttributes,
++            final String prefix, final String suffix, final 
AbstractIdentifiedType[] singleAttributes,
+             final StringJoinOperation inheritFrom)
      {
          super(identification);
          attributeNames = new String[singleAttributes.length];
@@@ -192,15 -215,21 +202,21 @@@
               * which may in turn produce an AttributeType. We do not accept 
more complex
               * combinations (e.g. operation producing an association).
               */
 -            IdentifiedType propertyType = singleAttributes[i];
 +            AbstractIdentifiedType propertyType = singleAttributes[i];
-             ArgumentChecks.ensureNonNullElement("singleAttributes", i, 
propertyType);
+             if (inheritFrom == null) {
+                 ArgumentChecks.ensureNonNullElement("singleAttributes", i, 
propertyType);
+             } else if (propertyType == null) {
+                 attributeNames[i] = inheritFrom.attributeNames[i];
+                 converters[i] = inheritFrom.converters[i];
+                 continue;
+             }
              final GenericName name = propertyType.getName();
              int maximumOccurs = 0;                              // May be a 
bitwise combination; need only to know if > 1.
 -            PropertyNotFoundException cause = null;             // In case of 
failure to find "sis:identifier" property.
 -            final boolean isAssociation = (propertyType instanceof 
FeatureAssociationRole);
 +            IllegalArgumentException cause = null;              // In case of 
failure to find "sis:identifier" property.
 +            final boolean isAssociation = (propertyType instanceof 
DefaultAssociationRole);
              if (isAssociation) {
-                 final DefaultAssociationRole role = (DefaultAssociationRole) 
propertyType;
 -                final var role = (FeatureAssociationRole) propertyType;
 -                final FeatureType ft = role.getValueType();
++                final var role = (DefaultAssociationRole) propertyType;
 +                final DefaultFeatureType ft = role.getValueType();
                  maximumOccurs = role.getMaximumOccurs();
                  try {
                      propertyType = 
ft.getProperty(AttributeConvention.IDENTIFIER);
@@@ -282,12 -326,25 +313,25 @@@
      }
  
      /**
-      * Returns the name of the properties from which to get the values to 
concatenate.
-      * This is the same information as {@link #getDependencies()}, only in a 
different
-      * kind of collection.
+      * Returns the same operation but using different properties as inputs.
+      *
+      * @param  dependencies  the new properties to use as operation inputs.
+      * @return the new operation, or {@code this} if unchanged.
       */
-     final List<String> getAttributeNames() {
-         return UnmodifiableArrayList.wrap(attributeNames);
+     @Override
 -    public Operation updateDependencies(final Map<String, PropertyType> 
dependencies) {
++    public AbstractOperation updateDependencies(final Map<String, 
AbstractIdentifiedType> dependencies) {
+         boolean hasNonNull = false;
 -        final var singleAttributes = new PropertyType[attributeNames.length];
++        final var singleAttributes = new 
AbstractIdentifiedType[attributeNames.length];
+         for (int i=0; i < singleAttributes.length; i++) {
+             hasNonNull |= (singleAttributes[i] = 
dependencies.get(attributeNames[i])) != null;
+         }
+         if (hasNonNull) {
+             final var op = new StringJoinOperation(inherit(), delimiter, 
prefix, suffix, singleAttributes, this);
+             if (!(Arrays.equals(op.attributeNames, attributeNames) && 
Arrays.equals(op.converters, converters))) {
+                 return FeatureOperations.POOL.unique(op);
+             }
+         }
+         return this;
      }
  
      /**
diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/FeatureTypeBuilder.java
index 467fc3877f,8a22418d3f..9e687ebe24
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/FeatureTypeBuilder.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/FeatureTypeBuilder.java
@@@ -946,7 -923,7 +946,7 @@@ public class FeatureTypeBuilder extend
              int identifierCursor = 0;
              for (int i=0; i<numSpecified; i++) {
                  final PropertyTypeBuilder builder = properties.get(i);
-                 final AbstractIdentifiedType instance = builder.build();
 -                final PropertyType instance = builder.buildForFeature();
++                final AbstractIdentifiedType instance = 
builder.buildForFeature();
                  propertyTypes[propertyCursor] = instance;
                  /*
                   * Collect the attributes to use as identifier components 
while we loop over all properties.
diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/OperationWrapper.java
index 0b69adb84f,7a6a656f7e..8f533bf737
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/OperationWrapper.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/OperationWrapper.java
@@@ -16,12 -16,14 +16,14 @@@
   */
  package org.apache.sis.feature.builder;
  
+ import java.util.HashMap;
  import java.util.Objects;
  import org.opengis.util.GenericName;
+ import org.apache.sis.feature.AbstractOperation;
  import org.apache.sis.util.resources.Errors;
  
 -// Specific to the geoapi-3.1 and geoapi-4.0 branches:
 -import org.opengis.feature.PropertyType;
 +// Specific to the main branch:
 +import org.apache.sis.feature.AbstractIdentifiedType;
  
  
  /**
@@@ -56,6 -58,41 +58,41 @@@ final class OperationWrapper extends Pr
          return operation;
      }
  
+     /**
+      * Returns the operation or an updated version of the operation.
+      * Updated versions are created for some kinds of operation, described 
below.
+      * Otherwise, this method returns the same value as {@link #build()}.
+      *
+      * <h4>Updated operations</h4>
+      * If the operation is a link to another property of the feature to 
build, the result type
+      * of the original operation is replaced by the target of the link in the 
feature to build.
+      * Even if the attribute name is the same, sometime the value class or 
some characteristics
+      * are different. Similar updates may also be applied to other kinds of 
operation.
+      *
+      * @throws IllegalStateException if the builder contains inconsistent 
information.
+      */
+     @Override
 -    final PropertyType buildForFeature() {
++    final AbstractIdentifiedType buildForFeature() {
+         final FeatureTypeBuilder owner = owner();
+         if (operation instanceof AbstractOperation) {
+             final var op = (AbstractOperation) operation;
 -            final var dependencies = new HashMap<String, PropertyType>();
++            final var dependencies = new HashMap<String, 
AbstractIdentifiedType>();
+             for (final String name : op.getDependencies()) {
+                 final PropertyTypeBuilder target;
+                 try {
+                     target = owner.getProperty(name);
+                 } catch (IllegalArgumentException e) {
+                     throw new IllegalStateException(e.getMessage(), e);
+                 }
+                 if (target != null) {
+                     dependencies.put(name, target.build());
+                 }
+             }
+             return op.updateDependencies(dependencies);
+         }
+         return operation;
+     }
+ 
      /**
       * Do not allow a change of multiplicity.
       */
diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/PropertyTypeBuilder.java
index d062c56da1,214fd41d2a..84753e8ddc
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/PropertyTypeBuilder.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/builder/PropertyTypeBuilder.java
@@@ -294,8 -294,21 +294,21 @@@ public abstract class PropertyTypeBuild
       * @throws IllegalStateException if the builder contains inconsistent 
information.
       */
      @Override
 -    public abstract PropertyType build() throws IllegalStateException;
 +    public abstract AbstractIdentifiedType build() throws 
IllegalStateException;
  
+     /**
+      * Builds the final property type to use in {@code FeatureType}.
+      * This method is invoked by {@link FeatureTypeBuilder#build()}.
+      * Subclasses can assume that the {@linkplain 
FeatureTypeBuilder#properties property} list is complete
+      * and use that information for refreshing some information such as the 
targets of the links.
+      *
+      * @return the property type.
+      * @throws IllegalStateException if the builder contains inconsistent 
information.
+      */
 -    PropertyType buildForFeature() throws IllegalStateException {
++    AbstractIdentifiedType buildForFeature() throws IllegalStateException {
+         return build();
+     }
+ 
      /**
       * Flags this builder as a disposed one. The builder should not be used 
anymore after this method call.
       */
diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureProjectionBuilder.java
index d3d6af82db,51ab93d85f..8951b1e8bb
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureProjectionBuilder.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/privy/FeatureProjectionBuilder.java
@@@ -201,8 -205,8 +203,8 @@@ public final class FeatureProjectionBui
       * @param  deferred  where to add operation's dependencies, or {@code 
null} for not collecting dependencies.
       * @return builder for the projected property, or {@code null} if it 
cannot be resolved.
       */
-     private PropertyTypeBuilder addPropertyResult(AbstractIdentifiedType 
property, final List<String> deferred) {
 -    private PropertyTypeBuilder addPropertyResult(PropertyType property, 
final Collection<String> deferred) {
 -        if (property instanceof Operation) {
++    private PropertyTypeBuilder addPropertyResult(AbstractIdentifiedType 
property, final Collection<String> deferred) {
 +        if (property instanceof AbstractOperation) {
              final GenericName name = property.getName();
              do {
                  if (deferred != null) {
@@@ -446,12 -448,15 +446,15 @@@
                       * We cannot change the type of an operation (unless we 
replace the operation
                       * by a stored attribute). Therefore, we only check type 
compatibility.
                       */
 -                    final var result = ((Operation) property).getResult();
 -                    if (result instanceof AttributeType<?>) {
 -                        final Class<?> c = ((AttributeType<?>) 
result).getValueClass();
 +                    final var result = ((AbstractOperation) 
property).getResult();
 +                    if (result instanceof DefaultAttributeType<?>) {
 +                        final Class<?> c = ((DefaultAttributeType<?>) 
result).getValueClass();
                          final Class<?> r = type.apply(c);
                          if (r != null) {
-                             // We can be lenient for link operation, but must 
be strict for other operations.
+                             /*
+                              * We can be lenient for link operation, but must 
be strict for other operations.
+                              * Example: a link to a geometry, but relaxing 
the `Polygon` type to `Geometry`.
+                              */
                              if (Features.getLinkTarget(property).isPresent() 
? r.isAssignableFrom(c) : r.equals(c)) {
                                  return true;
                              }
@@@ -628,8 -633,9 +631,9 @@@
       * The elements added into {@code deferred} are {@linkplain #source} 
properties.
       *
       * @param  deferred  where to add missing transitive dependencies (source 
properties).
+      * @throws UnsupportedOperationException if there is an attempt to rename 
a property which is used by an operation.
       */
 -    private void resolveDependencies(final List<PropertyType> deferred) {
 +    private void resolveDependencies(final List<AbstractIdentifiedType> 
deferred) {
          final var it = dependencies.entrySet().iterator();
          while (it.hasNext()) {
              final Map.Entry<String, List<Item>> entry = it.next();
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/DefaultDatumEnsemble.java
index 7297a5f299,ebffe6fdfe..10c416cf6e
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/DefaultDatumEnsemble.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/DefaultDatumEnsemble.java
@@@ -87,7 -91,7 +87,7 @@@ public class DefaultDatumEnsemble<D ext
       *     <td>{@link Identifier} (optionally as array)</td>
       *     <td>{@link #getIdentifiers()}</td>
       *   </tr><tr>
--     *     <td>{@value 
org.opengis.referencing.IdentifiedObject#DOMAINS_KEY}</td>
++     *     <td>{@code "domains"}</td>
       *     <td>{@link org.opengis.referencing.ObjectDomain} (optionally as 
array)</td>
       *     <td>{@link #getDomains()}</td>
       *   </tr><tr>
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/DefaultEllipsoid.java
index 0549fa5a02,4f8a8ae51f..38b503c9ef
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/DefaultEllipsoid.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/datum/DefaultEllipsoid.java
@@@ -551,6 -556,41 +556,41 @@@ public class DefaultEllipsoid extends A
          return flattening(other).subtract(flattening(this)).doubleValue();
      }
  
+     /**
+      * Returns the properties to use for the ellipsoid created by {@link 
#convertTo(Unit)}.
+      *
+      * @param  target  the desired unit of measurement.
+      * @return properties of the derived ellipsoid to create.
+      */
+     final Map<String,?> properties(final Unit<Length> target) {
+         return Map.of(NAME_KEY, '“' + getName().getCode() + "” converted to " 
+ target,
 -                      DOMAINS_KEY, getDomains());
++                      "domains", getDomains());
+     }
+ 
+     /**
+      * Returns an ellipsoid of the same shape as this ellipsoid but using the 
specified unit of measurement.
+      * If the given unit of measurement is equivalent to the unit used by 
this ellipsoid, then this method
+      * returns {@code this}. Otherwise, a new ellipsoid with an arbitrary 
name is returned.
+      *
+      * @param  target  the desired unit of measurement.
+      * @return ellipsoid of the same shape using the given unit of 
measurement.
+      *
+      * @see #getAxisUnit()
+      * @since 1.5
+      */
+     public DefaultEllipsoid convertTo(final Unit<Length> target) {
+         final UnitConverter c = unit.getConverterTo(target);
+         if (c.isIdentity()) {
+             return this;
+         }
+         return new DefaultEllipsoid(properties(target),
+                 c.convert(semiMajorAxis),
+                 c.convert(semiMinorAxis),
+                 inverseFlattening,
+                 ivfDefinitive,
+                 target);
+     }
+ 
      /**
       * Compares this ellipsoid with the specified object for equality.
       *
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/CoordinateOperationFinder.java
index 50910e58a2,a8f1ed8f1b..fe690092d8
--- 
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
@@@ -509,73 -506,46 +502,46 @@@ public class CoordinateOperationFinder 
          final CoordinateSystem targetCS = targetCRS.getCoordinateSystem();
          final GeodeticDatum sourceDatum = PseudoDatum.of(sourceCRS);
          final GeodeticDatum targetDatum = PseudoDatum.of(targetCRS);
-         Matrix datumShift = null;
-         /*
-          * If the prime meridian is not the same, we will concatenate a 
longitude rotation before or after datum shift
-          * (that concatenation will be performed by the 
`MathTransformContext` builder created below).
-          * Actually we do not know if the longitude rotation should be before 
or after datum shift. But this ambiguity
-          * can usually be ignored because Bursa-Wolf parameters are always 
used with source and target prime meridians
-          * set to Greenwich in EPSG dataset 8.9.  For safety, the SIS's 
DefaultGeodeticDatum class ensures that if the
-          * prime meridians are not the same, then the target meridian must be 
Greenwich.
-          */
-         final MathTransformFactory mtFactory = 
factorySIS.getMathTransformFactory();
-         final var context = new MathTransformContext(mtFactory, sourceDatum, 
targetDatum);
-         context.setSourceAxes(sourceCS, sourceDatum.getEllipsoid());
-         context.setTargetAxes(targetCS, targetDatum.getEllipsoid());
          /*
-          * If both CRS use the same datum and the same prime meridian, then 
the coordinate operation is only axis
-          * swapping, unit conversion or change of coordinate system type 
(Ellipsoidal ↔ Cartesian ↔ Spherical).
-          * Otherwise (if the datum are not the same), we will need to perform 
a scale, translation and rotation
-          * in Cartesian space using the Bursa-Wolf parameters. If the user 
does not require the best accuracy,
-          * then the Molodensky approximation may be used for avoiding the 
conversion step to geocentric CRS.
+          * Find the type of operation depending on whether there is a change 
of geodetic reference frame (datum).
+          * The `DATUM_SHIFT` and `ELLIPSOID_CHANGE` identifiers mean that 
there is a datum change, and all other
+          * identifiers mean that the coordinate operation is only a change of 
coordinate system type (Ellipsoidal
+          * ↔ Cartesian ↔ Spherical), axis swapping and unit conversions.
           */
-         Identifier identifier;
-         boolean isGeographicToGeocentric = false;
+         final Matrix datumShift;
+         final Identifier identifier;
+         final MathTransform transform;
+         ParameterValueGroup parameters;
+         final Optional<OperationMethod> method;
          final Optional<GeodeticDatum> commonDatum = 
PseudoDatum.ofOperation(sourceCRS, targetCRS);
          if (commonDatum.isPresent()) {
-             final boolean isGeocentricToGeographic;
-             isGeographicToGeocentric = (sourceCS instanceof EllipsoidalCS && 
targetCS instanceof CartesianCS);
-             isGeocentricToGeographic = (sourceCS instanceof CartesianCS && 
targetCS instanceof EllipsoidalCS);
              /*
-              * Above booleans should never be true at the same time. If it 
nevertheless happen (we are paranoiac;
-              * maybe a lazy user implemented all interfaces in a single 
class), do not apply any geographic ↔
-              * geocentric conversion. Instead, do as if the coordinate system 
types were the same.
+              * Coordinate system change (including change in the number of 
dimensions) without datum shift.
+              * May contain the addition of ellipsoidal height or spherical 
radius, which need an ellipsoid.
               */
-             if (isGeocentricToGeographic ^ isGeographicToGeocentric) {
-                 identifier = GEOCENTRIC_CONVERSION;
-             } else {
-                 identifier = AXIS_CHANGES;
-             }
+             final boolean isGeographic = (sourceCS instanceof EllipsoidalCS);
+             identifier = isGeographic != (targetCS instanceof EllipsoidalCS) 
? GEOCENTRIC_CONVERSION : AXIS_CHANGES;
 -            final var builder = 
factorySIS.getMathTransformFactory().builder(Constants.COORDINATE_SYSTEM_CONVERSION);
++            final var builder = 
CoordinateOperations.builder(factorySIS.getMathTransformFactory(), 
Constants.COORDINATE_SYSTEM_CONVERSION);
+             final var ellipsoid = (isGeographic ? sourceDatum : 
targetDatum).getEllipsoid();
+             builder.setSourceAxes(sourceCS, ellipsoid);
+             builder.setTargetAxes(targetCS, ellipsoid);
+             transform  = builder.create();
+             method     = builder.getMethod();
+             parameters = builder.parameters();
+             datumShift = null;
          } else {
-             identifier = ELLIPSOID_CHANGE;
-             if (sourceDatum instanceof DefaultGeodeticDatum) {
-                 datumShift = ((DefaultGeodeticDatum) 
sourceDatum).getPositionVectorTransformation(targetDatum, areaOfInterest);
-                 if (datumShift != null) {
-                     identifier = DATUM_SHIFT;
-                 }
-             }
-         }
-         /*
-          * Conceptually, all transformations below could be done by first 
converting from source coordinate
-          * system to geocentric Cartesian coordinates (X,Y,Z), apply an 
affine transform represented by the
-          * datum shift matrix, then convert from the (X′,Y′,Z′) coordinates 
to the target coordinate system.
-          * However, there are two exceptions to this path:
-          *
-          *   1) In the particular where both the source and target CS are 
ellipsoidal, we may use the
-          *      Molodensky approximation as a shortcut (if the desired 
accuracy allows).
-          *
-          *   2) Even if we really go through the XYZ coordinates without 
Molodensky approximation, there is
-          *      at least 9 different ways to name this operation depending on 
whether the source and target
-          *      CRS are geocentric or geographic, 2- or 3-dimensional, 
whether there is a translation or not,
-          *      the rotation sign, etc. We try to use the most specific name 
if we can find one, and fallback
-          *      on an arbitrary name only in last resort.
-          */
-         MathTransform before = null, after = null;
-         ParameterValueGroup parameters;
-         OperationMethod method = null;
-         if (identifier == DATUM_SHIFT || identifier == ELLIPSOID_CHANGE) {
              /*
-              * If the transform can be represented by a single coordinate 
operation, returns that operation.
+              * Conceptually, all transformations below could be done by first 
converting from source coordinate
+              * system to geocentric Cartesian coordinates (X,Y,Z), apply an 
affine transform represented by the
+              * datum shift matrix, then convert from the (X′,Y′,Z′) 
coordinates to the target coordinate system.
+              * However, there are exceptions to this path:
+              *
+              *   1) Conversion from ellipsoidal to spherical CS can skip the 
Cartesian step for performance.
+              *   2) Transformation between ellipsoidal CS may use the 
Molodensky approximation as a shortcut.
+              *   3) Even when really going through the XYZ coordinates, the 
name of that operation depends on
+              *      whether the source and target CRS are geocentric or 
geographic, 2- or 3-dimensional,
+              *      whether there is a translation, the rotation sign, etc.
+              *
               * Possible operations are:
               *
               *    - Position Vector transformation (in geocentric, 
geographic-2D or geographic-3D domains)
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/MathTransformContext.java
index 977d7c478c,1e8f15335d..c610643d16
--- 
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
@@@ -92,12 -110,12 +110,12 @@@ final class MathTransformContext extend
       * @return a conversion from the given source to the given target 
coordinate system.
       * @throws FactoryException if the conversion cannot be created.
       */
-     final MathTransform createCoordinateSystemChange(final CoordinateSystem 
source,
-                                                      final CoordinateSystem 
target,
-                                                      final Ellipsoid 
ellipsoid)
+     private MathTransform createCoordinateSystemChange(final CoordinateSystem 
source,
+                                                        final CoordinateSystem 
target,
+                                                        final Ellipsoid 
ellipsoid)
              throws FactoryException
      {
-         final var builder = CoordinateOperations.builder(getFactory(), 
Constants.COORDINATE_SYSTEM_CONVERSION);
 -        final var builder = 
factory.builder(Constants.COORDINATE_SYSTEM_CONVERSION);
++        final var builder = CoordinateOperations.builder(factory, 
Constants.COORDINATE_SYSTEM_CONVERSION);
          builder.setSourceAxes(source, ellipsoid);
          builder.setTargetAxes(target, ellipsoid);
          return builder.create();
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/CoordinateSystemTransformBuilder.java
index e01b832660,82a6d02100..225b068543
--- 
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
@@@ -201,12 -250,52 +253,52 @@@ final class CoordinateSystemTransformBu
  
      /**
       * Implementation of {@code create(…)} for a single component.
-      * This implementation can handle changes of coordinate system type 
between
+      * This implementation can handle changes of coordinate system type 
between {@link EllipsoidalCS},
       * {@link CartesianCS}, {@link SphericalCS}, {@link CylindricalCS} and 
{@link PolarCS}.
+      *
+      * @param  stepSource  source coordinate system of the step to build.
+      * @param  stepTarget  target coordinate system of the step to build.
+      * @return transform between the given coordinate systems (never null in 
current implementation).
+      * @throws IllegalArgumentException if the <abbr>CS</abbr> are not 
compatible, or axes do not match.
+      * @throws IncommensurableException if the units are not compatible, or 
the conversion is non-linear.
+      * @throws FactoryException if a factory method failed.
       */
      private MathTransform single(final CoordinateSystem stepSource,
-                                  final CoordinateSystem stepTarget) throws 
FactoryException
+                                  final CoordinateSystem stepTarget)
+             throws FactoryException, IncommensurableException
      {
+         /*
+          * Cases that require an ellipsoid. All those cases are delegated to 
another operation method
+          * in the transform factory. The check for axis order and unit of 
measurement will be done by
+          * public methods of the factory, which may invoke this 
`CoordinateSystemTransformBuilder`
+          * recursively but with a different pair of coordinate systems.
+          */
+         if (ellipsoid != null) {
+             if (stepSource instanceof EllipsoidalCS) {
+                 if (stepTarget instanceof EllipsoidalCS) {
+                     return addOrRemoveVertical(stepSource, stepTarget, 
Geographic2Dto3D.NAME, Geographic3Dto2D.NAME);
+                 }
+                 if ((stepTarget instanceof CartesianCS || stepTarget 
instanceof SphericalCS)) {
 -                    final var context = 
factory.builder(GeographicToGeocentric.NAME);
++                    final var context = CoordinateOperations.builder(factory, 
GeographicToGeocentric.NAME);
+                     context.setSourceAxes(stepSource, ellipsoid);
+                     context.setTargetAxes(stepTarget, null);
+                     return delegate(context);
+                 }
+             } else if (stepTarget instanceof EllipsoidalCS) {
+                 if ((stepSource instanceof CartesianCS || stepSource 
instanceof SphericalCS)) {
 -                    final var context = 
factory.builder(GeocentricToGeographic.NAME);
++                    final var context = CoordinateOperations.builder(factory, 
GeocentricToGeographic.NAME);
+                     context.setSourceAxes(stepSource, null);
+                     context.setTargetAxes(stepTarget, ellipsoid);
+                     return delegate(context);
+                 }
+             } else if (stepSource instanceof SphericalCS && stepTarget 
instanceof SphericalCS) {
+                 return addOrRemoveVertical(stepSource, stepTarget, 
Spherical2Dto3D.NAME, Spherical3Dto2D.NAME);
+             }
+         }
+         /*
+          * Cases that can be done without ellipsoid. Change of axis order and 
unit of measurement
+          * needs to be done here. There is no 
`CoordinateSystemTransformBuilder` recursive calls.
+          */
          int passthrough = 0;
          CoordinateSystemTransform kernel = null;
          if (stepSource instanceof CartesianCS) {
@@@ -228,32 -317,122 +320,122 @@@
                  passthrough = 1;
              }
          }
-         Exception cause = null;
-         try {
-             if (kernel == null) {
-                 return 
factory.createAffineTransform(CoordinateSystems.swapAndScaleAxes(stepSource, 
stepTarget));
-             } else if (stepSource.getDimension() == 
kernel.getSourceDimensions() + passthrough &&
-                        stepTarget.getDimension() == 
kernel.getTargetDimensions() + passthrough)
+         final MathTransform normalized, result;
+         final OperationMethod method;
+         if (kernel == null) {
+             method = Affine.provider();
+             result = 
factory.createAffineTransform(CoordinateSystems.swapAndScaleAxes(stepSource, 
stepTarget));
+             normalized = result;
+         } else {
+             if (stepSource.getDimension() != kernel.getSourceDimensions() + 
passthrough ||
+                 stepTarget.getDimension() != kernel.getTargetDimensions() + 
passthrough)
              {
-                 final MathTransform tr = (passthrough == 0)
-                         ? kernel.completeTransform(factory)
-                         : kernel.passthrough(factory);
-                 final MathTransform before = factory.createAffineTransform(
-                         CoordinateSystems.swapAndScaleAxes(stepSource,
-                         CoordinateSystems.replaceAxes(stepSource, 
AxesConvention.NORMALIZED)));
-                 final MathTransform after  = factory.createAffineTransform(
-                         CoordinateSystems.swapAndScaleAxes(
-                         CoordinateSystems.replaceAxes(stepTarget, 
AxesConvention.NORMALIZED), stepTarget));
-                 final MathTransform result = 
factory.createConcatenatedTransform(before,
-                                              
factory.createConcatenatedTransform(tr, after));
-                 provider = (passthrough == 0 ? kernel.method : 
kernel.method3D);
-                 return result;
+                 throw new 
OperationNotFoundException(operationNotFound(stepSource, stepTarget));
+             }
+             final MathTransform before, after;
+             if (passthrough == 0) {
+                 method     = kernel.method;
+                 normalized = kernel.completeTransform(factory);
+             } else {
+                 method     = kernel.method3D;
+                 normalized = kernel.passthrough(factory);
+             }
+             /*
+              * Adjust for axis order an units of measurement.
+              */
+             before = factory.createAffineTransform(
+                     CoordinateSystems.swapAndScaleAxes(stepSource,
+                     CoordinateSystems.replaceAxes(stepSource, 
AxesConvention.NORMALIZED)));
+             after  = factory.createAffineTransform(
+                     CoordinateSystems.swapAndScaleAxes(
+                     CoordinateSystems.replaceAxes(stepTarget, 
AxesConvention.NORMALIZED), stepTarget));
+             result = factory.createConcatenatedTransform(before,
+                      factory.createConcatenatedTransform(normalized, after));
+         }
+         setParameters(normalized, method, null);
+         return result;
+     }
+ 
+     /**
+      * Adds or removes the ellipsoidal height or spherical radius dimension.
+      *
+      * @param  stepSource  source coordinate system of the step to build.
+      * @param  stepTarget  target coordinate system of the step to build.
+      * @param  add         the operation method for adding the vertical 
dimension.
+      * @param  remove      the operation method for removing the vertical 
dimension.
+      * @return transform adding or removing a vertical coordinate.
+      * @throws IllegalArgumentException if the <abbr>CS</abbr> are not 
compatible, or axes do not match.
+      * @throws IncommensurableException if the units are not compatible, or 
the conversion is non-linear.
+      * @throws FactoryException if a factory method failed.
+      *
+      * @see 
org.apache.sis.referencing.internal.ParameterizedTransformBuilder#addOrRemoveVertical
+      */
+     private MathTransform addOrRemoveVertical(final CoordinateSystem 
stepSource,
+                                               final CoordinateSystem 
stepTarget,
+                                               final String add, final String 
remove)
+             throws FactoryException, IncommensurableException
+     {
+         final int change = stepTarget.getDimension() - 
stepSource.getDimension();
+         if (change != 0) {
+             final String method = change < 0 ? remove : add;
 -            final var context = factory.builder(method);
++            final var context = CoordinateOperations.builder(factory, method);
+             context.setSourceAxes(stepSource, ellipsoid);
+             context.setTargetAxes(stepTarget, ellipsoid);
+             return delegate(context);
+         }
+         // No change in the number of dimensions. Maybe there is axis 
swapping and unit conversions.
+         MathTransform step = 
factory.createAffineTransform(CoordinateSystems.swapAndScaleAxes(stepSource, 
stepTarget));
+         setParameters(step, Affine.provider(), null);
+         return step;
+     }
+ 
+     /**
+      * Delegates the transform creation to another builder, then remember the 
operation method which was used.
+      *
+      * @param  context  an initialized context on which to invoke the {@code 
create()} method.
+      * @return result of {@code context.create()}.
+      * @throws FactoryException if the given context cannot create the 
transform.
+      */
 -    private MathTransform delegate(final MathTransform.Builder context) 
throws FactoryException {
++    private MathTransform delegate(final MathTransformBuilder context) throws 
FactoryException {
+         final MathTransform step = context.create();
+         setParameters(step, context.getMethod().orElse(null), 
context.parameters());
+         return step;
+     }
+ 
+     /**
+      * Remembers the operation method and parameters for the given transform.
+      *
+      * @param  result  the transform that has been created.
+      * @param  method  the method, or {@code null} if unspecified.
+      * @param  values  the parameter values, or {@code null} for inferring 
from the method.
+      */
+     private void setParameters(final MathTransform result, final 
OperationMethod method, final ParameterValueGroup values) {
+         final byte type;
+         if (result.isIdentity()) {
+             type = IDENTITY;
+         } else if (MathTransforms.isLinear(result)) {
+             type = LINEAR;
+         } else {
+             type = CONVERSION;
+         }
+         if (parametersType < type) {
+             parametersType = type;
+             provider = method;
+             parameters= values;
+             if (result instanceof Parameterized) {
+                 parameterized = (Parameterized) result;
              }
-         } catch (IllegalArgumentException | IncommensurableException e) {
-             cause = e;
          }
-         throw new 
OperationNotFoundException(Resources.format(Resources.Keys.CoordinateOperationNotFound_2,
+     }
+ 
+     /**
+      * Returns the error message for an operation not found between the 
coordinate systems.
+      */
+     private static String operationNotFound(final CoordinateSystem stepSource,
+                                             final CoordinateSystem stepTarget)
+     {
+         return Resources.format(Resources.Keys.CoordinateOperationNotFound_2,
                  WKTUtilities.toType(CoordinateSystem.class, 
stepSource.getClass()),
-                 WKTUtilities.toType(CoordinateSystem.class, 
stepTarget.getClass())), cause);
+                 WKTUtilities.toType(CoordinateSystem.class, 
stepTarget.getClass()));
      }
  }
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/EllipsoidToCentricTransform.java
index 1b72fb547c,7c6fd5656a..49cc90c86c
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/EllipsoidToCentricTransform.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/EllipsoidToCentricTransform.java
@@@ -500,10 -631,10 +631,10 @@@ public class EllipsoidToCentricTransfor
          final double h;
          switch (dim) {
              default: throw mismatchedDimension("point", 
getSourceDimensions(), dim);
-             case 3:  wh = true;  h = point.getOrdinate(2); break;
 -            case 3:  wh = true;  h = point.getCoordinate(VERTICAL_DIM); break;
++            case 3:  wh = true;  h = point.getOrdinate(VERTICAL_DIM); break;
              case 2:  wh = false; h = 0; break;
          }
 -        return transform(point.getCoordinate(0), point.getCoordinate(1), h, 
null, 0, true, wh);
 +        return transform(point.getOrdinate(0), point.getOrdinate(1), h, null, 
0, true, wh);
      }
  
      /**
@@@ -848,9 -1027,9 +1027,9 @@@
           */
          @Override
          public Matrix derivative(final DirectPosition point) throws 
TransformException {
-             final double[] coordinate = point.getCoordinate();
-             ArgumentChecks.ensureDimensionMatches("point", 3, coordinate);
-             return this.transform(coordinate, 0, coordinate, 0, true);
 -            final double[] coordinates = point.getCoordinates();
++            final double[] coordinates = point.getCoordinate();
+             ArgumentChecks.ensureDimensionMatches("point", NUM_CENTRIC_DIM, 
coordinates);
+             return this.transform(coordinates, 0, coordinates, 0, true);
          }
  
          /**
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/OnewayLinearTransform.java
index 0000000000,c5f4f31a45..08e43d6e1d
mode 000000,100644..100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/OnewayLinearTransform.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/operation/transform/OnewayLinearTransform.java
@@@ -1,0 -1,212 +1,190 @@@
+ /*
+  * 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.io.Serializable;
 -import java.nio.DoubleBuffer;
 -import java.nio.FloatBuffer;
+ import org.opengis.geometry.DirectPosition;
+ import org.opengis.parameter.ParameterValueGroup;
+ import org.opengis.parameter.ParameterDescriptorGroup;
+ import org.opengis.referencing.operation.Matrix;
+ import org.opengis.referencing.operation.MathTransform;
+ import org.opengis.referencing.operation.TransformException;
+ 
+ 
+ /**
+  * A transform which is linear in the forward direction, but non-linear in 
the inverse direction.
+  * This case happens when the original transform is non-linear, but the 
inverse of that transform
+  * just drops the non-linear dimension. We want the inverse of the inverse to 
return the original
+  * transform.
+  *
+  * <p>Subclasses must implement {@link #inverse()}. That information is not 
stored as a field in this
+  * {@code OnewayLinearTransform} class because subclasses typically need a 
specific inverse subclass.
+  * Implementations should also override {@link #getContextualParameters()} 
and related methods.</p>
+  *
+  * @author  Martin Desruisseaux (Geomatys)
+  */
+ abstract class OnewayLinearTransform extends AbstractMathTransform.Inverse 
implements Serializable {
+     /**
+      * Serial number for inter-operability with different versions.
+      */
+     private static final long serialVersionUID = -3677320306734738831L;
+ 
+     /**
+      * The transform on which to delegate all operations except inverse.
+      */
+     @SuppressWarnings("serial")     // Most SIS implementations are 
serializable.
+     protected final LinearTransform delegate;
+ 
+     /**
+      * Creates a new instance which will delegate most operations to the 
given transform.
+      *
+      * @param delegate  the transform on which to delegate all operations 
except inverse.
+      */
+     protected OnewayLinearTransform(final LinearTransform delegate) {
+         this.delegate = delegate;
+     }
+ 
+     /**
+      * Case where {@code delegate} is the result of a concatenation of a 
kernel with normalization
+      * and denormalization matrices. Because of optimization, the result of 
the concatenation is a
+      * single {@link LinearTransform} with no information about the steps 
that produced the result.
+      * This class keeps (indirectly) a reference to the contextual parameters.
+      */
+     static final class Concatenated extends OnewayLinearTransform {
+         /** Serial number for inter-operability with different versions. */
+         private static final long serialVersionUID = -4439900049126605063L;
+ 
+         /**
+          * The kernel of {@link #delegate}, without the normalization and 
denormalization.
+          * Used mostly for Well Known Text formatting. May be {@code null} if 
none.
+          */
+         @SuppressWarnings("serial")     // Most SIS implementations are 
serializable.
+         private final AbstractMathTransform kernel;
+ 
+         /**
+          * The original transform for which this inverse is created.
+          */
+         @SuppressWarnings("serial")     // Most SIS implementations are 
serializable.
+         private final MathTransform inverse;
+ 
+         /**
+          * Creates a new one-way linear transform.
+          *
+          * @param delegate  the transform on which to delegate all operations 
except inverse.
+          * @param kernel    the kernel of {@code delegate}, without the 
normalization and denormalization.
+          * @param inverse   the original transform for which this inverse is 
created.
+          */
+         Concatenated(final LinearTransform delegate, final 
AbstractMathTransform kernel, final MathTransform inverse) {
+             super(delegate);
+             this.kernel  = kernel;
+             this.inverse = inverse;
+         }
+ 
+         /**
+          * Returns the descriptor of the parameters returned by {@link 
#getParameterValues()}.
+          * See that latter method for more information.
+          */
+         @Override
+         public ParameterDescriptorGroup getParameterDescriptors() {
+             final ParameterValueGroup parameters = getParameterValues();
+             return (parameters != null) ? parameters.getDescriptor() : null;
+         }
+ 
+         /**
+          * Returns the contextual parameters of the {@linkplain #kernel} as 
the parameters of this
+          * concatenated transform. The contextual parameters describes a 
kernel operation together
+          * with its normalization and denormalization matrices. Since those 3 
transforms have been
+          * combined into a single transform (which is {@link #delegate}), the 
contextual parameters
+          * of the {@linkplain #kernel} applies to the parameters of this 
concatenated transform.
+          */
+         @Override
+         public ParameterValueGroup getParameterValues() {
+             return (kernel != null) ? kernel.getContextualParameters() : null;
+         }
+ 
+         /**
+          * Returns the original transform for which this transform is the 
inverse.
+          */
+         @Override
+         public MathTransform inverse() {
+             return inverse;
+         }
+     }
+ 
+     /**
+      * Returns whether the {@code tr} transform is null or is the actual 
implementation of {@code wrapper}.
+      * This method is used for assertions.
+      *
+      * @param  tr       the transform which is expected to be null or wrapped.
+      * @param  wrapper  the transform which is potentially a wrapper for 
{@code delegate}.
+      * @return whether {@code tr} is null or the implementation of {@code 
wrapper}.
+      */
+     static boolean isNullOrDelegate(final MathTransform tr, final 
MathTransform wrapper) {
+         return (tr == null) || (wrapper instanceof OnewayLinearTransform && 
((OnewayLinearTransform) wrapper).delegate == tr);
+     }
+ 
+     /**
+      * Computes the derivative at the given location.
+      */
+     @Override
+     public Matrix derivative(final DirectPosition point) throws 
TransformException {
+         return delegate.derivative(point);
+     }
+ 
+     /**
+      * Transforms the given array of point and optionally computes the 
derivative.
+      * The implementation delegates to the {@link #delegate} linear transform.
+      */
+     @Override
+     public Matrix transform(final double[] srcPts, final int srcOff,
+                             final double[] dstPts, final int dstOff,
+                             final boolean derivate) throws TransformException
+     {
+         if (delegate instanceof AbstractMathTransform) {
+             return ((AbstractMathTransform) delegate).transform(srcPts, 
srcOff, dstPts, dstOff, derivate);
+         }
+         if (dstPts != null) {
+             delegate.transform(srcPts, srcOff, dstPts, dstOff, 1);
+         }
+         return derivate ? delegate.derivative(null) : null;      // Position 
of a linear transform can be null.
+     }
+ 
+     @Override
+     public DirectPosition transform(DirectPosition source, DirectPosition 
target) throws TransformException {
+         return delegate.transform(source, target);
+     }
+ 
+     @Override
+     public void transform(double[] srcPts, int srcOff, double[] dstPts, int 
dstOff, int numPts) throws TransformException {
+         delegate.transform(srcPts, srcOff, dstPts, dstOff, numPts);
+     }
+ 
+     @Override
+     public void transform(float[] srcPts, int srcOff, float[] dstPts, int 
dstOff, int numPts) throws TransformException {
+         delegate.transform(srcPts, srcOff, dstPts, dstOff, numPts);
+     }
+ 
+     @Override
+     public void transform(double[] srcPts, int srcOff, float[] dstPts, int 
dstOff, int numPts) throws TransformException {
+         delegate.transform(srcPts, srcOff, dstPts, dstOff, numPts);
+     }
+ 
+     @Override
+     public void transform(float[] srcPts, int srcOff, double[] dstPts, int 
dstOff, int numPts) throws TransformException {
+         delegate.transform(srcPts, srcOff, dstPts, dstOff, numPts);
+     }
 -
 -    @Override
 -    public int transform(DoubleBuffer source, DoubleBuffer target) throws 
TransformException {
 -        return delegate.transform(source, target);
 -    }
 -
 -    @Override
 -    public int transform(FloatBuffer source, FloatBuffer target) throws 
TransformException {
 -        return delegate.transform(source, target);
 -    }
 -
 -    @Override
 -    public int transform(FloatBuffer source, DoubleBuffer target) throws 
TransformException {
 -        return delegate.transform(source, target);
 -    }
 -
 -    @Override
 -    public int transform(DoubleBuffer source, FloatBuffer target) throws 
TransformException {
 -        return delegate.transform(source, target);
 -    }
+ }
diff --cc 
endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/privy/ReferencingUtilities.java
index 384290d9da,a2afff8b30..d14bc2d6f5
--- 
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
@@@ -52,15 -53,10 +53,16 @@@ 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.parameter.DefaultParameterDescriptorGroup;
  
 -// Specific to the geoapi-3.1 and geoapi-4.0 branches:
 -import org.opengis.referencing.datum.DatumEnsemble;
 +// Specific to the main branch:
 +import java.util.Collection;
 +import java.util.NoSuchElementException;
 +import org.opengis.referencing.ReferenceIdentifier;
 +import org.apache.sis.referencing.datum.DefaultDatumEnsemble;
 +import org.apache.sis.pending.geoapi.referencing.MissingMethods;
 +import org.apache.sis.metadata.privy.Identifiers;
 +import org.apache.sis.xml.NilObject;
  
  
  /**
@@@ -608,6 -560,31 +610,31 @@@ single: if (crs instanceof SingleCRS) 
          return mapping;
      }
  
+     /**
+      * Returns a parameter descriptor group with the same parameters as the 
given group, but a different name.
+      * If the given code is equal to the current group code, then the {@code 
parameters} instance is returned.
+      * Otherwise, a new group is created with the same name {@linkplain 
Identifier#getAuthority() authority}
+      * and {@linkplain Identifier#getCodeSpace() code space} as the given 
parameter group, but with the
+      * {@linkplain Identifier#getCode() code} replaced by the given value.
+      *
+      * <p><b>Examples:</b> this method can be used for creating the 
parameters of an inverse operation
+      * in the common case where the inverse has the same parameters than the 
forward operation.</p>
+      *
+      * @param  parameters  the parameter group to rename, or {@code null}.
+      * @param  code        the new name of the group, in the same code space 
as the given parameters.
+      * @return a group with the same parameters but with a different name, or 
{@code null} if the given group is null.
+      */
+     public static ParameterDescriptorGroup rename(final 
ParameterDescriptorGroup parameters, final String code) {
+         if (parameters != null) {
 -            Identifier name = parameters.getName();
++            ReferenceIdentifier name = parameters.getName();
+             if (!code.equals(name.getCode())) {
+                 name = new ImmutableIdentifier(name.getAuthority(), 
name.getCodeSpace(), code);
+                 return new 
DefaultParameterDescriptorGroup(Map.of(ParameterDescriptorGroup.NAME_KEY, 
name), parameters);
+             }
+         }
+         return parameters;
+     }
+ 
      /**
       * Returns short names for all axes of the given CRS. This method uses 
short names like "Latitude" or "Height",
       * even if the full ISO 19111 names are "Geodetic latitude" or 
"Ellipsoidal height". This is suitable as header
diff --cc 
endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/EllipsoidToCentricTransformTest.java
index 15f34db204,17f1206d96..e3487b5486
--- 
a/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/EllipsoidToCentricTransformTest.java
+++ 
b/endorsed/src/org.apache.sis.referencing/test/org/apache/sis/referencing/operation/transform/EllipsoidToCentricTransformTest.java
@@@ -39,9 -38,14 +38,11 @@@ import org.apache.sis.referencing.datum
  import static org.apache.sis.test.Assertions.assertSerializedEquals;
  import 
org.apache.sis.referencing.operation.provider.GeocentricTranslationTest;
  
 -// Specific to the geoapi-3.1 and geoapi-4.0 branches:
 -import org.opengis.test.ToleranceModifier;
 -
  
  /**
-  * Tests {@link EllipsoidToCentricTransform}.
+  * Tests {@link EllipsoidToCentricTransform} from geographic to geocentric 
coordinates.
+  * When a test provides hard-coded expected results, those results are in 
Cartesian coordinates.
+  * See {@link #targetType} for more information.
   *
   * @author  Martin Desruisseaux (IRD, Geomatys)
   */
@@@ -121,10 -144,8 +141,9 @@@ public class EllipsoidToCentricTransfor
          tolerance  = GeocentricTranslationTest.precision(1);        // 
Required precision for (λ,φ)
          zTolerance = Formulas.LINEAR_TOLERANCE / 2;                 // 
Required precision for h
          zDimension = new int[] {2};                                 // 
Dimension of h where to apply zTolerance
 +        tolerance  = 1E-4;                                          // Other 
SIS branches use a stricter threshold.
          verifyTransform(GeocentricTranslationTest.samplePoint(2),   // X = 
3771793.968,  Y = 140253.342,  Z = 5124304.349 metres
                          GeocentricTranslationTest.samplePoint(1));  // 
53°48'33.820"N, 02°07'46.380"E, 73.00 metres
-         loggings.assertNoUnexpectedLog();
      }
  
      /**
@@@ -138,10 -159,9 +157,9 @@@
          final double delta = toRadians(100.0 / 60) / 1852;          // 
Approximately 100 metres
          derivativeDeltas  = new double[] {delta, delta, 100};       // (Δλ, 
Δφ, Δh)
          tolerance         = Formulas.LINEAR_TOLERANCE;
 -        toleranceModifier = ToleranceModifier.PROJECTION;
 +//      toleranceModifier = ToleranceModifier.PROJECTION;
-         createGeodeticConversion(CommonCRS.WGS84.ellipsoid(), true);
+         createGeodeticConversion(HardCodedDatum.WGS84.getEllipsoid(), true);
          verifyInDomain(CoordinateDomain.GEOGRAPHIC, 306954540);
-         loggings.assertNoUnexpectedLog();
      }
  
      /**
@@@ -160,9 -181,8 +179,8 @@@
          final double delta = toRadians(100.0 / 60) / 1852;
          derivativeDeltas  = new double[] {delta, delta, 100};
          tolerance         = Formulas.LINEAR_TOLERANCE;
 -        toleranceModifier = ToleranceModifier.PROJECTION;
 -        verifyInverse(40, 30, 10000);
 +//      toleranceModifier = ToleranceModifier.PROJECTION;
 +        verifyInverse(new double[] {40, 30, 10000});
-         loggings.assertNoUnexpectedLog();
      }
  
      /**
@@@ -181,17 -200,20 +198,20 @@@
           * Derivative of the direct transform.
           */
          tolerance = 1E-2;
-         derivativeDeltas = new double[] {toRadians(1.0 / 60) / 1852};         
  // Approximately one metre.
+         derivativeDeltas = new double[] {
+             toRadians(1.0 / 60) / 1852,             // Approximately one 
metre.
+             toRadians(1.0 / 60) / 1852,
+             1
+         };
 -        verifyDerivative(point.getCoordinates());
 +        verifyDerivative(point.getCoordinate());
          /*
           * Derivative of the inverse transform.
           */
          point = transform.transform(point, null);
          transform = transform.inverse();
          tolerance = 1E-8;
-         derivativeDeltas = new double[] {1};                                  
  // Approximately one metre.
+         derivativeDeltas = new double[] {1,1,1};    // Approximately one 
metre.
 -        verifyDerivative(point.getCoordinates());
 +        verifyDerivative(point.getCoordinate());
-         loggings.assertNoUnexpectedLog();
      }
  
      /**
diff --cc 
endorsed/src/org.apache.sis.storage.xml/main/org/apache/sis/storage/gpx/Types.java
index 99f32801a2,99cfd33640..6c279b1227
--- 
a/endorsed/src/org.apache.sis.storage.xml/main/org/apache/sis/storage/gpx/Types.java
+++ 
b/endorsed/src/org.apache.sis.storage.xml/main/org/apache/sis/storage/gpx/Types.java
@@@ -41,12 -40,12 +40,12 @@@ import org.apache.sis.feature.builder.P
  import org.apache.sis.feature.builder.AttributeRole;
  import org.apache.sis.feature.privy.AttributeConvention;
  import org.apache.sis.geometry.wrapper.Geometries;
- import org.apache.sis.storage.base.FeatureCatalogBuilder;
+ import org.apache.sis.storage.base.MetadataBuilder;
  import org.apache.sis.util.iso.DefaultNameFactory;
  
 -// Specific to the geoapi-3.1 and geoapi-4.0 branches:
 -import org.opengis.feature.FeatureType;
 -import org.opengis.feature.Operation;
 +// Specific to the main branch:
 +import org.apache.sis.feature.AbstractOperation;
 +import org.apache.sis.feature.DefaultFeatureType;
  
  
  /**
diff --cc 
endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureQuery.java
index 7add475ead,4412e94419..a0a15a293b
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureQuery.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureQuery.java
@@@ -43,18 -43,24 +43,19 @@@ import org.apache.sis.pending.jdk.JDK19
  import org.apache.sis.util.ArgumentChecks;
  import org.apache.sis.util.CharSequences;
  import org.apache.sis.util.Emptiable;
+ import org.apache.sis.util.UnconvertibleObjectException;
  import org.apache.sis.util.iso.Names;
  
 -// Specific to the geoapi-3.1 and geoapi-4.0 branches:
 -import org.opengis.feature.Feature;
 -import org.opengis.feature.FeatureType;
 -import org.opengis.feature.Attribute;
 -import org.opengis.feature.AttributeType;
 -import org.opengis.feature.Operation;
 -import org.opengis.feature.PropertyNotFoundException;
 -import org.opengis.filter.FilterFactory;
 -import org.opengis.filter.Filter;
 -import org.opengis.filter.Expression;
 -import org.opengis.filter.InvalidFilterValueException;
 -import org.opengis.filter.Literal;
 -import org.opengis.filter.ValueReference;
 -import org.opengis.filter.SortBy;
 -import org.opengis.filter.SortProperty;
 +// Specific to the main branch:
 +import org.apache.sis.feature.AbstractFeature;
 +import org.apache.sis.feature.AbstractAttribute;
 +import org.apache.sis.feature.DefaultFeatureType;
 +import org.apache.sis.filter.Filter;
 +import org.apache.sis.filter.Expression;
 +import org.apache.sis.pending.geoapi.filter.Literal;
 +import org.apache.sis.pending.geoapi.filter.ValueReference;
 +import org.apache.sis.pending.geoapi.filter.SortBy;
 +import org.apache.sis.pending.geoapi.filter.SortProperty;
  
  
  /**
@@@ -621,9 -621,12 +622,12 @@@ public class FeatureQuery extends Quer
           *
           * @param  builder  the builder where to add the property.
           * @return whether the property has been successfully added.
+          * @throws InvalidFilterValueException if {@linkplain #expression} is 
invalid.
+          * @throws PropertyNotFoundException if the property was not found in 
{@code builder.source()}.
+          * @throws UnconvertibleObjectException if the property default value 
cannot be converted to the expected type.
           */
          final boolean addTo(final FeatureProjectionBuilder builder) {
 -            final FeatureExpression<? super Feature, ?> fex = 
FeatureExpression.castOrCopy(expression);
 +            final FeatureExpression<? super AbstractFeature, ?> fex = 
FeatureExpression.castOrCopy(expression);
              if (fex != null) {
                  final FeatureProjectionBuilder.Item item = 
fex.expectedType(builder);
                  if (item != null) {
@@@ -745,10 -748,14 +749,14 @@@
       *   <li>Otherwise the localized string "Unnamed #1" with increasing 
numbers.</li>
       * </ul>
       *
-      * @param sourceType  the feature type to project.
-      * @param locale      locale for error messages, or {@code null} for the 
default locale.
+      * @param  sourceType  the feature type to project.
+      * @param  locale      locale for error messages, or {@code null} for the 
default locale.
+      * @throws InvalidFilterValueException if an {@linkplain 
NamedExpression#expression expression} is invalid.
+      * @throws PropertyNotFoundException if a property referenced by an 
expression was not found in {@code sourceType}.
+      * @throws UnconvertibleObjectException if a property default value 
cannot be converted to the expected type.
+      * @throws UnsupportedOperationException if there is an attempt to rename 
a property which is used by an operation.
       */
 -    final Optional<FeatureProjection> project(final FeatureType sourceType, 
final Locale locale) {
 +    final Optional<FeatureProjection> project(final DefaultFeatureType 
sourceType, final Locale locale) {
          if (projection == null) {
              return Optional.empty();
          }
diff --cc 
endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
index b62caeb9ec,476d084f46..a307bc79a2
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
@@@ -1881,10 -1875,8 +1880,8 @@@ public class MetadataBuilder 
       * @param  occurrences  number of instances of the given feature type, or 
a negative value if unknown.
       *         Note that ISO-19115 considers 0 as an invalid value. 
Consequently, if 0, the feature is not added.
       * @return the name of the added feature (even if not added to the 
metadata), or {@code null} if none.
-      *
-      * @see FeatureCatalogBuilder#define(DefaultFeatureType)
       */
 -    public final GenericName addFeatureType(final FeatureType type, final 
long occurrences) {
 +    public final GenericName addFeatureType(final DefaultFeatureType type, 
final long occurrences) {
          if (type == null) {
              return null;
          }
diff --cc 
endorsed/src/org.apache.sis.storage/test/org/apache/sis/storage/base/MetadataBuilderTest.java
index b353796add,9ef0405554..dbbd6193b1
--- 
a/endorsed/src/org.apache.sis.storage/test/org/apache/sis/storage/base/MetadataBuilderTest.java
+++ 
b/endorsed/src/org.apache.sis.storage/test/org/apache/sis/storage/base/MetadataBuilderTest.java
@@@ -158,8 -154,8 +158,8 @@@ public final class MetadataBuilderTest 
              assertTrue(metadata.getContentInfo().isEmpty());
          } else {
              final ContentInformation content = 
getSingleton(metadata.getContentInfo());
-             assertInstanceOf(DefaultFeatureCatalogueDescription.class, 
content);
-             final DefaultFeatureTypeInfo info = 
getSingleton(((DefaultFeatureCatalogueDescription) 
content).getFeatureTypeInfo());
 -            final var catalog = 
assertInstanceOf(FeatureCatalogueDescription.class, content);
 -            final FeatureTypeInfo info = 
getSingleton(catalog.getFeatureTypeInfo());
++            final var catalog = 
assertInstanceOf(DefaultFeatureCatalogueDescription.class, content);
++            final DefaultFeatureTypeInfo info = 
getSingleton(catalog.getFeatureTypeInfo());
              assertEquals(expected, info.getFeatureInstanceCount(), 
errorMessage);
          }
      }


Reply via email to