You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2023/05/03 17:19:33 UTC

[sis] branch geoapi-4.0 updated (d8bbaf214a -> 31126879d2)

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

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


    from d8bbaf214a Clarify which `Feature` instance is used for the different projection types. Other opportunistic clarifications as we tried to verify the behavior. Replace `ExpressionOperation` by `LinkOperation` when applicable.
     new f308b11bc8 Rename `ProjectionType.COMPLETING` ad `COMPUTING`.
     new 31126879d2 Retrofit `GroupAsPolylineOperation` together with all other feature operations. It required a generalization for working on attributes as well as associations.

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../apache/sis/feature/DefaultAssociationRole.java |   8 +-
 .../org/apache/sis/feature/FeatureOperations.java  |  51 ++++-
 .../main/java/org/apache/sis/feature/Features.java |  39 +++-
 .../sis/feature/GroupAsPolylineOperation.java      | 248 +++++++++++++++++++++
 .../apache/sis/internal/feature/Geometries.java    |  33 ---
 .../sis/internal/feature/GeometryWrapper.java      |   6 +-
 .../apache/sis/internal/feature/esri/Wrapper.java  |   4 +-
 .../sis/internal/feature/j2d/PointWrapper.java     |   2 +-
 .../apache/sis/internal/feature/j2d/Wrapper.java   |   2 +-
 .../apache/sis/internal/feature/jts/Wrapper.java   |   2 +-
 .../sis/internal/feature/GeometriesTestCase.java   |   2 +-
 .../java/org/apache/sis/storage/FeatureQuery.java  |  10 +-
 .../org/apache/sis/storage/FeatureQueryTest.java   |   2 +-
 .../storage/gpx/GroupAsPolylineOperation.java      | 211 ------------------
 .../org/apache/sis/internal/storage/gpx/Types.java |  31 ++-
 15 files changed, 371 insertions(+), 280 deletions(-)
 create mode 100644 core/sis-feature/src/main/java/org/apache/sis/feature/GroupAsPolylineOperation.java
 delete mode 100644 storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/GroupAsPolylineOperation.java


[sis] 01/02: Rename `ProjectionType.COMPLETING` ad `COMPUTING`.

Posted by de...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit f308b11bc89a4c20ef5f09f78383c8e4029b23cf
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Wed May 3 11:16:33 2023 +0200

    Rename `ProjectionType.COMPLETING` ad `COMPUTING`.
---
 .../src/main/java/org/apache/sis/storage/FeatureQuery.java     | 10 +++++-----
 .../src/test/java/org/apache/sis/storage/FeatureQueryTest.java |  2 +-
 2 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/storage/sis-storage/src/main/java/org/apache/sis/storage/FeatureQuery.java b/storage/sis-storage/src/main/java/org/apache/sis/storage/FeatureQuery.java
index 46947ce71e..1d34f86d1d 100644
--- a/storage/sis-storage/src/main/java/org/apache/sis/storage/FeatureQuery.java
+++ b/storage/sis-storage/src/main/java/org/apache/sis/storage/FeatureQuery.java
@@ -460,7 +460,7 @@ public class FeatureQuery extends Query implements Cloneable, Serializable {
 
         /*
          * The expression is evaluated every times that the property value is requested.
-         * This projection type is similar to {@link #COMPLETING}, except that the features
+         * This projection type is similar to {@link #COMPUTING}, except that the features
          * given in calls to {@link Expression#apply(Object)} are the same instances than
          * the ones used by {@link #STORED}.
          *
@@ -470,7 +470,7 @@ public class FeatureQuery extends Query implements Cloneable, Serializable {
          * </div>
          *
          * <h4>Feature instances in expression evaluation</h4>
-         * The combination of deferred calculation (like {@link #COMPLETING}) and usage of feature instances
+         * The combination of deferred calculation (like {@link #COMPUTING}) and usage of feature instances
          * from the <em>source</em> {@link FeatureSet} (like {@link #STORED}) may cause this projection type
          * to retain the source feature instances for a longer time than other types.
          *
@@ -494,7 +494,7 @@ public class FeatureQuery extends Query implements Cloneable, Serializable {
          * The features given in calls to {@link Expression#apply(Object)} are instances from the <em>target</em>
          * {@link FeatureSet}, after filtering. The instances from the source {@code FeatureSet} are no longer
          * available when the expression is executed. Consequently, all fields that are necessary for computing
-         * a {@code COMPLETING} field shall have been first copied in {@link #STORED} fields.
+         * a {@code COMPUTING} field shall have been first copied in {@link #STORED} fields.
          *
          * <div class="note"><b>Note on naming:</b>
          * verb tense <i>-ing</i> instead of <i>-ed</i> is for emphasizing that the data used for computation
@@ -502,7 +502,7 @@ public class FeatureQuery extends Query implements Cloneable, Serializable {
          *
          * @see FeatureOperations#expression(Map, Function, AttributeType)
          */
-        COMPLETING
+        COMPUTING
     }
 
     /**
@@ -794,7 +794,7 @@ public class FeatureQuery extends Query implements Cloneable, Serializable {
              * We need to keep the property name computed by `fex.expectedType(…)` for the operation result,
              * because that name is the name of the link to create if the operation is `ValueReference`.
              */
-            if (item.type == ProjectionType.COMPLETING && resultType instanceof AttributeTypeBuilder<?>) {
+            if (item.type == ProjectionType.COMPUTING && resultType instanceof AttributeTypeBuilder<?>) {
                 final var ab = (AttributeTypeBuilder<?>) resultType;
                 final AttributeType<?> storedType = ab.build();
                 if (ftb.properties().remove(resultType)) {
diff --git a/storage/sis-storage/src/test/java/org/apache/sis/storage/FeatureQueryTest.java b/storage/sis-storage/src/test/java/org/apache/sis/storage/FeatureQueryTest.java
index fb70453eb6..32ce1bd5fd 100644
--- a/storage/sis-storage/src/test/java/org/apache/sis/storage/FeatureQueryTest.java
+++ b/storage/sis-storage/src/test/java/org/apache/sis/storage/FeatureQueryTest.java
@@ -329,7 +329,7 @@ public final class FeatureQueryTest extends TestCase {
      * Shortcut for creating expression for a projection computed on-the-fly.
      */
     private static FeatureQuery.NamedExpression virtualProjection(final Expression<Feature, ?> expression, final String alias) {
-        return new FeatureQuery.NamedExpression(expression, Names.createLocalName(null, null, alias), FeatureQuery.ProjectionType.COMPLETING);
+        return new FeatureQuery.NamedExpression(expression, Names.createLocalName(null, null, alias), FeatureQuery.ProjectionType.COMPUTING);
     }
 
     /**


[sis] 02/02: Retrofit `GroupAsPolylineOperation` together with all other feature operations. It required a generalization for working on attributes as well as associations.

Posted by de...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 31126879d2c75a84d68146c449b9673eb4608f10
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Wed May 3 19:18:09 2023 +0200

    Retrofit `GroupAsPolylineOperation` together with all other feature operations.
    It required a generalization for working on attributes as well as associations.
---
 .../apache/sis/feature/DefaultAssociationRole.java |   8 +-
 .../org/apache/sis/feature/FeatureOperations.java  |  51 ++++-
 .../main/java/org/apache/sis/feature/Features.java |  39 +++-
 .../sis/feature/GroupAsPolylineOperation.java      | 248 +++++++++++++++++++++
 .../apache/sis/internal/feature/Geometries.java    |  33 ---
 .../sis/internal/feature/GeometryWrapper.java      |   6 +-
 .../apache/sis/internal/feature/esri/Wrapper.java  |   4 +-
 .../sis/internal/feature/j2d/PointWrapper.java     |   2 +-
 .../apache/sis/internal/feature/j2d/Wrapper.java   |   2 +-
 .../apache/sis/internal/feature/jts/Wrapper.java   |   2 +-
 .../sis/internal/feature/GeometriesTestCase.java   |   2 +-
 .../storage/gpx/GroupAsPolylineOperation.java      | 211 ------------------
 .../org/apache/sis/internal/storage/gpx/Types.java |  31 ++-
 13 files changed, 365 insertions(+), 274 deletions(-)

diff --git a/core/sis-feature/src/main/java/org/apache/sis/feature/DefaultAssociationRole.java b/core/sis-feature/src/main/java/org/apache/sis/feature/DefaultAssociationRole.java
index b7f0bbbbc3..690fbee171 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/feature/DefaultAssociationRole.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/feature/DefaultAssociationRole.java
@@ -153,12 +153,12 @@ public class DefaultAssociationRole extends FieldType implements FeatureAssociat
      *     String    namespace = "My model";
      *     GenericName nameOfA = Names.createTypeName(namespace, ":", "Feature type A");
      *     GenericName nameOfB = Names.createTypeName(namespace, ":", "Feature type B");
-     *     FeatureType typeA = new DefaultFeatureType(nameOfA, false, null,
-     *         new DefaultAssociationRole(Names.createLocalName("Association to B"), nameOfB),
+     *     FeatureType typeA = new DefaultFeatureType(Map.of(NAME_KEY, nameOfA), false, null,
+     *         new DefaultAssociationRole(Map.of(NAME_KEY, "Association to B"), nameOfB, 1, 1),
      *         // More properties if desired.
      *     );
-     *     FeatureType typeB = new DefaultFeatureType(nameOfB, false, null,
-     *         new DefaultAssociationRole(Names.createLocalName("Association to A"), featureA),
+     *     FeatureType typeB = new DefaultFeatureType(Map.of(NAME_KEY, nameOfB), false, null,
+     *         new DefaultAssociationRole(Map.of(NAME_KEY, "Association to A"), featureA, 1, 1),
      *         // More properties if desired.
      *     );
      *     }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/feature/FeatureOperations.java b/core/sis-feature/src/main/java/org/apache/sis/feature/FeatureOperations.java
index db15ddcb83..7986a36733 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/feature/FeatureOperations.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/feature/FeatureOperations.java
@@ -27,6 +27,7 @@ import org.apache.sis.util.Static;
 import org.apache.sis.util.UnconvertibleObjectException;
 import org.apache.sis.util.collection.WeakHashSet;
 import org.apache.sis.util.resources.Errors;
+import org.apache.sis.setup.GeometryLibrary;
 
 // Branch-dependent imports
 import org.opengis.feature.Feature;
@@ -249,7 +250,7 @@ public final class FeatureOperations extends Static {
      *
      * <h4>Read/write behavior</h4>
      * This operation is read-only. Calls to {@code Attribute.setValue(Envelope)} will result in an
-     * {@link IllegalStateException} to be thrown.
+     * {@link UnsupportedOperationException} to be thrown.
      *
      * @param  identification      the name and other information to be given to the operation.
      * @param  crs                 the Coordinate Reference System in which to express the envelope, or {@code null}.
@@ -265,12 +266,54 @@ public final class FeatureOperations extends Static {
         return POOL.unique(new EnvelopeOperation(identification, crs, geometryAttributes));
     }
 
+    /**
+     * Creates a single geometry from a sequence of points or polylines stored in another property.
+     * When evaluated, this operation reads a feature property containing a sequence of {@code Point}s or {@code Polyline}s.
+     * Those geometries shall be instances of the specified geometry library (e.g. JTS or ESRI).
+     * The merged geometry is usually a {@code Polyline},
+     * unless the sequence of source geometries is empty or contains a single element.
+     * The merged geometry is re-computed every time that the operation is evaluated.
+     *
+     * <h4>Examples</h4>
+     * <p><i>Polylines created from points:</i>
+     * a boat that record it's position every hour.
+     * The input is a list of all positions stored in an attribute with [0 … ∞] multiplicity.
+     * This operation will extract each position and create a line as a new attribute.</p>
+     *
+     * <p><i>Polylines created from other polylines:</i>
+     * a boat that record track every hour.
+     * The input is a list of all tracks stored in an attribute with [0 … ∞] multiplicity.
+     * This operation will extract each track and create a polyline as a new attribute.</p>
+     *
+     * <h4>Read/write behavior</h4>
+     * This operation is read-only. Calls to {@code Attribute.setValue(…)}
+     * will result in an {@link UnsupportedOperationException} to be thrown.
+     *
+     * @param  identification  the name of the operation, together with optional information.
+     * @param  library         the library providing the implementations of geometry objects to read and write.
+     * @param  components      attribute, association or operation providing the geometries to group as a polyline.
+     * @return a feature operation which computes its values by merging points or polylines.
+     *
+     * @since 1.4
+     */
+    public static Operation groupAsPolyline(final Map<String,?> identification, final GeometryLibrary library,
+                                            final PropertyType components)
+    {
+        ArgumentChecks.ensureNonNull("library", library);
+        ArgumentChecks.ensureNonNull("components", components);
+        return POOL.unique(GroupAsPolylineOperation.create(identification, library, components));
+    }
+
     /**
      * Creates an operation which delegates the computation to a given expression.
      * The {@code expression} argument should generally be an instance of
      * {@link org.opengis.filter.Expression},
      * but more generic functions are accepted as well.
      *
+     * <h4>Read/write behavior</h4>
+     * This operation is read-only. Calls to {@code Attribute.setValue(…)}
+     * will result in an {@link UnsupportedOperationException} to be thrown.
+     *
      * @param  <V>             the type of values computed by the expression and assigned to the feature property.
      * @param  identification  the name of the operation, together with optional information.
      * @param  expression      the expression to evaluate on feature instances.
@@ -284,7 +327,7 @@ public final class FeatureOperations extends Static {
                                          final AttributeType<? super V> resultType)
     {
         ArgumentChecks.ensureNonNull("expression", expression);
-        ArgumentChecks.ensureNonNull("result", resultType);
+        ArgumentChecks.ensureNonNull("resultType", resultType);
         return POOL.unique(ExpressionOperation.create(identification, expression, resultType));
     }
 
@@ -295,6 +338,10 @@ public final class FeatureOperations extends Static {
      * This method casts or converts the expression to the expected type by a call to
      * {@link Expression#toValueType(Class)}.
      *
+     * <h4>Read/write behavior</h4>
+     * This operation is read-only. Calls to {@code Attribute.setValue(…)}
+     * will result in an {@link UnsupportedOperationException} to be thrown.
+     *
      * @param  <V>             the type of values computed by the expression and assigned to the feature property.
      * @param  identification  the name of the operation, together with optional information.
      * @param  expression      the expression to evaluate on feature instances.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/feature/Features.java b/core/sis-feature/src/main/java/org/apache/sis/feature/Features.java
index 4d6eca4a0c..cbf2f98185 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/feature/Features.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/feature/Features.java
@@ -49,7 +49,7 @@ import org.opengis.feature.PropertyType;
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Johann Sorel (Geomatys)
  * @author  Alexis Manin (Geomatys)
- * @version 1.1
+ * @version 1.4
  * @since   0.5
  */
 public final class Features extends Static {
@@ -138,13 +138,42 @@ public final class Features extends Static {
      *
      * @since 1.1
      */
+    @SuppressWarnings("unchecked")
     public static Optional<AttributeType<?>> toAttribute(IdentifiedType type) {
-        if (!(type instanceof AttributeType<?>)) {
+        return toIdentifiedType(type, (Class) AttributeType.class);
+    }
+
+    /**
+     * Returns the given type as a {@link FeatureAssociationRole} by casting if possible, or by getting the result type
+     * of an operation. More specifically this method returns the first of the following types which apply:
+     *
+     * <ul>
+     *   <li>If the given type is an instance of {@link FeatureAssociationRole}, then it is returned as-is.</li>
+     *   <li>If the given type is an instance of {@link Operation} and the {@linkplain Operation#getResult()
+     *       result type} is an {@link FeatureAssociationRole}, then that result type is returned.</li>
+     *   <li>If the given type is an instance of {@link Operation} and the {@linkplain Operation#getResult()
+     *       result type} is another operation, then the above check is performed recursively.</li>
+     * </ul>
+     *
+     * @param  type  the data type to express as an attribute type.
+     * @return the association role, or empty if this method cannot find any.
+     *
+     * @since 1.4
+     */
+    public static Optional<FeatureAssociationRole> toAssociation(IdentifiedType type) {
+        return toIdentifiedType(type, FeatureAssociationRole.class);
+    }
+
+    /**
+     * Implementation of {@link #toAttribute(IdentifiedType)} and {@link #toAssociation(IdentifiedType)}.
+     */
+    private static <T> Optional<T> toIdentifiedType(IdentifiedType type, final Class<T> target) {
+        if (!target.isInstance(type)) {
             if (!(type instanceof Operation)) {
                 return Optional.empty();
             }
             type = ((Operation) type).getResult();
-            if (!(type instanceof AttributeType<?>)) {
+            if (!target.isInstance(type)) {
                 if (!(type instanceof Operation)) {
                     return Optional.empty();
                 }
@@ -154,14 +183,14 @@ public final class Features extends Static {
                  * would be thread freeze, we check as a safety.
                  */
                 final Map<IdentifiedType,Boolean> done = new IdentityHashMap<>(4);
-                while (!((type = ((Operation) type).getResult()) instanceof AttributeType<?>)) {
+                while (!target.isInstance(type = ((Operation) type).getResult())) {
                     if (!(type instanceof Operation) || done.put(type, Boolean.TRUE) != null) {
                         return Optional.empty();
                     }
                 }
             }
         }
-        return Optional.of((AttributeType<?>) type);
+        return Optional.of(target.cast(type));
     }
 
     /**
diff --git a/core/sis-feature/src/main/java/org/apache/sis/feature/GroupAsPolylineOperation.java b/core/sis-feature/src/main/java/org/apache/sis/feature/GroupAsPolylineOperation.java
new file mode 100644
index 0000000000..ee206a37d1
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/feature/GroupAsPolylineOperation.java
@@ -0,0 +1,248 @@
+/*
+ * 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.feature;
+
+import java.util.Map;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.EnumMap;
+import org.opengis.parameter.ParameterDescriptorGroup;
+import org.opengis.parameter.ParameterValueGroup;
+import org.apache.sis.internal.feature.AttributeConvention;
+import org.apache.sis.internal.feature.FeatureUtilities;
+import org.apache.sis.internal.feature.Geometries;
+import org.apache.sis.internal.feature.GeometryWrapper;
+import org.apache.sis.internal.feature.Resources;
+import org.apache.sis.setup.GeometryLibrary;
+
+// Branch-dependent imports
+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.
+ * This is the implementation of {@link FeatureOperations#groupAsPolyline FeatureOperations.groupAsPolyline(…)}.
+ *
+ * @author  Johann Sorel (Geomatys)
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   0.8
+ */
+final class GroupAsPolylineOperation extends AbstractOperation {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = -1995248173704801739L;
+
+    /**
+     * The parameter descriptor for the "Group polylines" operation, which does not take any parameter.
+     */
+    private static final ParameterDescriptorGroup EMPTY_PARAMS = FeatureUtilities.parameters("GroupAsPolyline");
+
+    /**
+     * Name of the property to follow in order to get the geometries to add to a polyline.
+     * This property can be an attribute, operation or feature association,
+     * usually with [0 … ∞] multiplicity.
+     */
+    private final String propertyName;
+
+    /**
+     * Whether the property giving components is an association to feature instances.
+     */
+    private final boolean isFeatureAssociation;
+
+    /**
+     * The geometry library.
+     */
+    private final Geometries<?> geometries;
+
+    /**
+     * The {@link #resultType} for each library, created when first needed.
+     * Used for sharing the same instance for all operations using the same library.
+     */
+    private static final EnumMap<GeometryLibrary, DefaultAttributeType<?>> TYPES = new EnumMap<>(GeometryLibrary.class);
+
+    /**
+     * Returns an operation which will group into a single geometry all geometries contained in the specified property.
+     *
+     * @param  identification  the name of the operation, together with optional information.
+     * @param  library         the library providing the implementations of geometry objects to read and write.
+     * @param  components      attribute, association or operation providing the geometries to group as a polyline.
+     */
+    static Operation create(final Map<String,?> identification, final GeometryLibrary library, PropertyType components) {
+        FeatureAssociationRole association = Features.toAssociation(components).orElse(null);
+        if (association != null && association.getMaximumOccurs() == 1) {
+            components = association;
+        } else {
+            association = null;
+            AttributeType<?> attribute = Features.toAttribute(components).orElse(null);
+            if (attribute == null) {
+                throw new IllegalArgumentException(Resources.format(Resources.Keys.IllegalPropertyType_2,
+                                                   components.getName(), components.getClass()));
+            }
+            if (attribute.getMaximumOccurs() <= 1) {
+                return new LinkOperation(identification, components);
+            }
+            components = attribute;
+        }
+        return new GroupAsPolylineOperation(identification, Geometries.implementation(library), components, association != null);
+    }
+
+    /**
+     * Creates an operation which will group into a single polyline all geometries contained in the specified property.
+     * This constructor shall be invoked only after the {@code source} is known to contain collection, i.e. the maximum
+     * number of occurrences of attribute values or feature instances is greater than 1.
+     */
+    private GroupAsPolylineOperation(final Map<String,?> identification, final Geometries<?> geometries,
+                                     final PropertyType components, final boolean isFeatureAssociation)
+    {
+        super(identification);
+        this.geometries = geometries;
+        this.propertyName = components.getName().toString();
+        this.isFeatureAssociation = isFeatureAssociation;
+    }
+
+    /**
+     * Returns an empty parameter descriptor group.
+     */
+    @Override
+    public ParameterDescriptorGroup getParameters() {
+        return EMPTY_PARAMS;
+    }
+
+    /**
+     * Returns the expected result type.
+     */
+    @Override
+    public final AttributeType<?> getResult() {
+        synchronized (TYPES) {
+            return TYPES.computeIfAbsent(geometries.library, (library) -> {
+                var name = Map.of(AbstractIdentifiedType.NAME_KEY, AttributeConvention.ENVELOPE_PROPERTY);
+                return new DefaultAttributeType<>(name, geometries.polylineClass, 1, 1, null);
+            });
+        }
+    }
+
+    /**
+     * Executes the operation on the specified feature.
+     */
+    @Override
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    public final Property apply(Feature feature, ParameterValueGroup parameters) {
+        return new Result<>(getResult(), feature);
+    }
+
+
+    /**
+     * The attribute resulting from execution of the {@link GroupAsPolylineOperation}.
+     * The value is computed when first requested, then cached for this {@code Result} instance only.
+     * Note that the cache is not used when {@link #apply(Feature, ParameterValueGroup)} is invoked,
+     * causing a new value to be computed again. The intent is to behave as if the operation has been
+     * executed at {@code apply(…)} invocation time, even if we deferred the actual execution.
+     *
+     * @param <G> the root geometry class (implementation-dependent).
+     */
+    private final class Result<G> extends OperationResult<G> {
+        /**
+         * For cross-version compatibility.
+         */
+        private static final long serialVersionUID = 5558751012506417903L;
+
+        /**
+         * The result, computed when first needed.
+         */
+        private transient G geometry;
+
+        /**
+         * Creates a new result for an execution on the given feature.
+         * The actual computation is deferred to the first call of {@link #getValue()}.
+         */
+        Result(final AttributeType<G> resultType, final Feature feature) {
+            super(resultType, feature);
+        }
+
+        /**
+         * Computes the geometry from all points or polylines found in the associated feature.
+         *
+         * @throws ClassCastException if a feature, a property value or a geometry is not of the expected class.
+         */
+        @Override
+        public G getValue() {
+            if (geometry == null) {
+                geometry = compute();
+            }
+            return geometry;
+        }
+
+        /**
+         * Computes the geometry when first needed.
+         */
+        private G compute() {
+            /*
+             * Cast to `Collection` should be safe if the constructor
+             * ensured that `Features.getMaximumOccurs(property) > 1`.
+             */
+            Iterator<?> paths = ((Collection<?>) feature.getPropertyValue(propertyName)).iterator();
+            if (isFeatureAssociation) {
+                final Iterator<?> it = paths;
+                paths = new Iterator<Object>() {
+                    @Override public boolean hasNext() {
+                        return it.hasNext();
+                    }
+
+                    @Override public Object next() {
+                        return ((Feature) it.next()).getPropertyValue(AttributeConvention.GEOMETRY);
+                    }
+                };
+            }
+            while (paths.hasNext()) {
+                GeometryWrapper<?> first = geometries.castOrWrap(paths.next());
+                if (first != null) {
+                    final Object geom = first.mergePolylines(paths);
+                    return getType().getValueClass().cast(geom);
+                }
+            }
+            return null;
+        }
+    }
+
+    /**
+     * Computes a hash-code value for this operation.
+     */
+    @Override
+    public int hashCode() {
+        return super.hashCode() + propertyName.hashCode() + geometries.hashCode();
+    }
+
+    /**
+     * Compares this operation with the given object for equality.
+     */
+    @Override
+    public boolean equals(final Object obj) {
+        if (super.equals(obj)) {
+            final GroupAsPolylineOperation that = (GroupAsPolylineOperation) obj;
+            return propertyName.equals(that.propertyName) &&
+                   geometries.equals(that.geometries);
+        }
+        return false;
+    }
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Geometries.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Geometries.java
index 8fe1787c4a..ffd04dfef6 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Geometries.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Geometries.java
@@ -19,7 +19,6 @@ package org.apache.sis.internal.feature;
 import java.io.Serializable;
 import java.nio.ByteBuffer;
 import java.util.Optional;
-import java.util.Iterator;
 import java.util.logging.Logger;
 import org.opengis.geometry.Envelope;
 import org.opengis.geometry.DirectPosition;
@@ -37,7 +36,6 @@ import org.apache.sis.internal.system.Loggers;
 import org.apache.sis.math.Vector;
 import org.apache.sis.setup.GeometryLibrary;
 import org.apache.sis.util.resources.Errors;
-import org.apache.sis.util.Classes;
 
 
 /**
@@ -566,37 +564,6 @@ public abstract class Geometries<G> implements Serializable {
         return result;
     }
 
-    /**
-     * Merges a sequence of points or polylines into a single polyline instances.
-     * Each previous polyline will be a separated path in the new polyline instances.
-     * The implementation returned by this method is an instance of {@link #rootClass}.
-     *
-     * <p>Contrarily to other methods in this class, this method does <strong>not</strong> unwrap
-     * the geometries contained in {@link GeometryWrapper}. It is caller responsibility to do so
-     * if needed.</p>
-     *
-     * @param  paths  the points or polylines to merge in a single polyline object.
-     * @return the merged polyline, or {@code null} if the given iterator has no element.
-     * @throws ClassCastException if collection elements are not instances of a supported library,
-     *         or not all elements are instances of the same library.
-     */
-    public static Object mergePolylines(final Iterator<?> paths) {
-        while (paths.hasNext()) {
-            final Object first = paths.next();
-            if (first != null) {
-                final Optional<GeometryWrapper<?>> w = wrap(first);
-                if (w.isPresent()) return w.get().mergePolylines(paths);
-                /*
-                 * Use the same exception type than `mergePolylines(…)` implementations.
-                 * Also the same type than exception occurring elsewhere in the code of
-                 * the caller (GroupAsPolylineOperation).
-                 */
-                throw new ClassCastException(Errors.format(Errors.Keys.UnsupportedType_1, Classes.getClass(first)));
-            }
-        }
-        return null;
-    }
-
     /**
      * Creates a wrapper for the given geometry instance.
      * The given object shall be an instance of {@link #rootClass}.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/GeometryWrapper.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/GeometryWrapper.java
index 9644e35484..096e0d1314 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/GeometryWrapper.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/GeometryWrapper.java
@@ -57,7 +57,7 @@ import org.opengis.filter.InvalidFilterValueException;
  * change without warning in future Apache SIS version.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.4
  *
  * @param  <G>  root class of geometry instances of the underlying library (i.e. {@link Geometries#rootClass}).
  *              This is not necessarily the class of the wrapped geometry returned by {@link #implementation()}.
@@ -185,10 +185,10 @@ public abstract class GeometryWrapper<G> extends AbstractGeometry implements Geo
      * (it is caller responsibility to unwrap if needed).</p>
      *
      * @param  paths  the points or polylines to merge in a single polyline instance.
-     * @return the merged polyline (may be the wrapper geometry but never {@code null}).
+     * @return the merged polyline (may be the underlying geometry of {@code this} but never {@code null}).
      * @throws ClassCastException if collection elements are not instances of the point or geometry class.
      */
-    protected abstract G mergePolylines(final Iterator<?> paths);
+    public abstract G mergePolylines(final Iterator<?> paths);
 
     /**
      * Applies a filter predicate between this geometry and another geometry.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/esri/Wrapper.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/esri/Wrapper.java
index c4c5d63885..68799cd376 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/esri/Wrapper.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/esri/Wrapper.java
@@ -158,7 +158,7 @@ final class Wrapper extends GeometryWithCRS<Geometry> {
      * @throws ClassCastException if an element in the iterator is not an ESRI geometry.
      */
     @Override
-    protected Geometry mergePolylines(final Iterator<?> polylines) {
+    public Geometry mergePolylines(final Iterator<?> polylines) {
         final Polyline path = new Polyline();
         boolean lineTo = false;
 add:    for (Geometry next = geometry;;) {
@@ -181,7 +181,7 @@ add:    for (Geometry next = geometry;;) {
                 lineTo = false;
             }
             /*
-             * 'polylines.hasNext()' check is conceptually part of 'for' instruction,
+             * `polylines.hasNext()` check is conceptually part of `for` instruction,
              * except that we need to skip this condition during the first iteration.
              */
             do if (!polylines.hasNext()) break add;
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/PointWrapper.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/PointWrapper.java
index 7a8369092d..10db10fd4b 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/PointWrapper.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/PointWrapper.java
@@ -119,7 +119,7 @@ final class PointWrapper extends GeometryWithCRS<Shape> {
      * @throws ClassCastException if an element in the iterator is not a {@link Shape} or a {@link Point2D}.
      */
     @Override
-    protected Shape mergePolylines(final Iterator<?> polylines) {
+    public Shape mergePolylines(final Iterator<?> polylines) {
         return Wrapper.mergePolylines(point, polylines);
     }
 
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/Wrapper.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/Wrapper.java
index 8b545d752e..8326eef294 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/Wrapper.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/Wrapper.java
@@ -155,7 +155,7 @@ final class Wrapper extends GeometryWithCRS<Shape> {
      * @throws ClassCastException if an element in the iterator is not a {@link Shape} or a {@link Point2D}.
      */
     @Override
-    protected Shape mergePolylines(final Iterator<?> polylines) {
+    public Shape mergePolylines(final Iterator<?> polylines) {
         return mergePolylines(geometry, polylines);
     }
 
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/jts/Wrapper.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/jts/Wrapper.java
index 547543d23e..b4c76fea54 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/jts/Wrapper.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/jts/Wrapper.java
@@ -281,7 +281,7 @@ final class Wrapper extends GeometryWrapper<Geometry> {
      * @throws ClassCastException if an element in the iterator is not a JTS geometry.
      */
     @Override
-    protected Geometry mergePolylines(final Iterator<?> polylines) {
+    public Geometry mergePolylines(final Iterator<?> polylines) {
         final List<Coordinate> coordinates = new ArrayList<>();
         final List<Geometry> lines = new ArrayList<>();
         boolean isFloat = true;
diff --git a/core/sis-feature/src/test/java/org/apache/sis/internal/feature/GeometriesTestCase.java b/core/sis-feature/src/test/java/org/apache/sis/internal/feature/GeometriesTestCase.java
index a73c1f30e3..43503a539f 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/internal/feature/GeometriesTestCase.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/internal/feature/GeometriesTestCase.java
@@ -120,7 +120,7 @@ public abstract class GeometriesTestCase extends TestCase {
     }
 
     /**
-     * Tests {@link Geometries#mergePolylines(Iterator)} (or actually tests its strategy).
+     * Tests {@link GeometryWrapper#mergePolylines(Iterator)} (or actually tests its strategy).
      * This method verifies the polylines by a call to {@link GeometryWrapper#getEnvelope()}.
      * Subclasses should perform more extensive tests by verifying the {@link #geometry} field.
      */
diff --git a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/GroupAsPolylineOperation.java b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/GroupAsPolylineOperation.java
deleted file mode 100644
index 6303da7ccc..0000000000
--- a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/GroupAsPolylineOperation.java
+++ /dev/null
@@ -1,211 +0,0 @@
-/*
- * 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.internal.storage.gpx;
-
-import java.util.Map;
-import java.util.Iterator;
-import java.util.Collection;
-import org.opengis.parameter.ParameterDescriptorGroup;
-import org.opengis.parameter.ParameterValueGroup;
-import org.apache.sis.feature.AbstractAttribute;
-import org.apache.sis.feature.AbstractOperation;
-import org.apache.sis.feature.DefaultAttributeType;
-import org.apache.sis.internal.feature.AttributeConvention;
-import org.apache.sis.internal.feature.FeatureUtilities;
-import org.apache.sis.internal.feature.Geometries;
-import org.apache.sis.util.resources.Errors;
-
-// Branch-dependent imports
-import org.opengis.feature.Feature;
-import org.opengis.feature.Property;
-import org.opengis.feature.Attribute;
-import org.opengis.feature.AttributeType;
-
-
-/**
- * Creates a single (Multi){@code Polyline} instance from a sequence of points or polylines stored in another property.
- * This base class expects a sequence of {@code Point} or {@code Polyline} instances as input.
- * The single (Multi){@code Polyline} instance is re-computed every time this property is requested.
- *
- * <h2>Examples</h2>
- * <p><i>Polylines created from points:</i>
- * a boat that record it's position every hour.
- * The list of all positions is stored in an attribute with [0 … ∞] multiplicity.
- * This class will extract each position and create a line as a new attribute.
- * Any change applied to the positions will be visible on the line.</p>
- *
- * <p><i>Polylines created from other polylines:</i>
- * a boat that record track every hour.
- * The list of all tracks is stored in an attribute with [0 … ∞] multiplicity.
- * This class will extract each track and create a polyline as a new attribute.
- * Any change applied to the tracks will be visible on the polyline.</p>
- *
- * @author  Johann Sorel (Geomatys)
- * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
- * @since   0.8
- */
-final class GroupAsPolylineOperation extends AbstractOperation {
-    /**
-     * For cross-version compatibility.
-     */
-    private static final long serialVersionUID = 7898989085371304159L;
-
-    /**
-     * The parameter descriptor for the "Group polylines" operation, which does not take any parameter.
-     */
-    private static final ParameterDescriptorGroup EMPTY_PARAMS = FeatureUtilities.parameters("GroupPolylines");
-
-    /**
-     * Name of the property to follow in order to get the geometries to add to a polyline.
-     * This property shall be a feature association, usually with [0 … ∞] multiplicity.
-     */
-    private final String association;
-
-    /**
-     * The expected result type to be returned by {@link #getResult()}.
-     */
-    @SuppressWarnings("serial")
-    private final AttributeType<?> result;
-
-    /**
-     * Creates a new operation which will look for geometries in the given feature association.
-     *
-     * @param  identification  name and other information to be given to this operation.
-     * @param  association     name of the property to follow in order to get the geometries to add to a polyline.
-     * @param  result          the expected result type to be returned by {@link #getResult()}.
-     */
-    GroupAsPolylineOperation(final Map<String,?> identification, final String association, final AttributeType<?> result) {
-        super(identification);
-        this.association = association;
-        this.result = result;
-    }
-
-    /**
-     * Creates the {@code result} argument for the constructor. This creation is provided in a separated method
-     * because the same instance will be shared by many {@code GroupAsPolylineOperation} instances.
-     *
-     * @param  geometries  accessor to the geometry implementation in use (Java2D, ESRI or JTS).
-     */
-    static <G> AttributeType<? extends G> getResult(final Geometries<G> geometries) {
-        return new DefaultAttributeType<>(Map.of(NAME_KEY, AttributeConvention.ENVELOPE_PROPERTY),
-                                          geometries.polylineClass, 1, 1, null);
-    }
-
-    /**
-     * Returns an empty parameter descriptor group.
-     */
-    @Override
-    public ParameterDescriptorGroup getParameters() {
-        return EMPTY_PARAMS;
-    }
-
-    /**
-     * Returns the expected result type.
-     */
-    @Override
-    public final AttributeType<?> getResult() {
-        return result;
-    }
-
-    /**
-     * Executes the operation on the specified feature with the specified parameters.
-     * If the geometries have changed since last time this method has been invoked,
-     * the result will be recomputed.
-     */
-    @Override
-    @SuppressWarnings({"rawtypes", "unchecked"})
-    public final Property apply(Feature feature, ParameterValueGroup parameters) {
-        return new Result(feature, association, result);
-    }
-
-
-    /**
-     * The attribute resulting from execution if the {@link GroupAsPolylineOperation}.
-     * The value is computed when first requested, then cached for this {@code Result} instance only.
-     * Note that the cache is not used when {@link #apply(Feature, ParameterValueGroup)} is invoked,
-     * causing a new value to be computed again. The intent is to behave as if the operation has been
-     * executed at {@code apply(…)} invocation time, even if we deferred the actual execution.
-     *
-     * @param  <G>  the root geometry class (implementation-dependent).
-     */
-    private static final class Result<G> extends AbstractAttribute<G> {
-        /**
-         * For cross-version compatibility.
-         */
-        private static final long serialVersionUID = -8872834506769732436L;
-
-        /**
-         * The feature on which to execute the operation.
-         */
-        @SuppressWarnings("serial")     // Most SIS implementations are serializable.
-        private final Feature feature;
-
-        /**
-         * Name of the property to follow in order to get the geometries to add to a polyline.
-         * This property shall be a feature association, usually with [0 … ∞] multiplicity.
-         */
-        private final String association;
-
-        /**
-         * The result, computed when first needed.
-         */
-        private transient G geometry;
-
-        /**
-         * Creates a new result for an execution on the given feature.
-         * The actual computation is deferred to the first call of {@link #getValue()}.
-         */
-        Result(final Feature feature, final String association, final AttributeType<G> result) {
-            super(result);
-            this.feature = feature;
-            this.association = association;
-        }
-
-        /**
-         * Computes the geometry from all points or polylines found in the associated feature.
-         *
-         * @throws ClassCastException if a feature, a property value or a geometry is not of the expected class.
-         *         This exception should not happen since we use {@link #feature} in contexts where types are known.
-         */
-        @Override
-        public G getValue() {
-            if (geometry == null) {
-                final Iterator<?> it = ((Collection<?>) feature.getPropertyValue(association)).iterator();
-                final Object geom = Geometries.mergePolylines(new Iterator<Object>() {
-                    @Override public boolean hasNext() {
-                        return it.hasNext();
-                    }
-
-                    @Override public Object next() {
-                        return ((Feature) it.next()).getPropertyValue(AttributeConvention.GEOMETRY);
-                    }
-                });
-                geometry = getType().getValueClass().cast(geom);
-            }
-            return geometry;
-        }
-
-        /**
-         * Does not allow modification of this attribute.
-         */
-        @Override
-        public void setValue(G value) {
-            throw new UnsupportedOperationException(Errors.format(Errors.Keys.UnmodifiableObject_1, Attribute.class));
-        }
-    }
-}
diff --git a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Types.java b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Types.java
index bdefc58aad..c552af357d 100644
--- a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Types.java
+++ b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Types.java
@@ -21,7 +21,6 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.HashMap;
 import java.time.temporal.Temporal;
-import org.opengis.util.ScopedName;
 import org.opengis.util.GenericName;
 import org.opengis.util.NameFactory;
 import org.opengis.util.FactoryException;
@@ -35,6 +34,7 @@ import org.apache.sis.storage.FeatureNaming;
 import org.apache.sis.storage.IllegalNameException;
 import org.apache.sis.referencing.CommonCRS;
 import org.apache.sis.feature.AbstractIdentifiedType;
+import org.apache.sis.feature.DefaultAssociationRole;
 import org.apache.sis.feature.FeatureOperations;
 import org.apache.sis.feature.builder.FeatureTypeBuilder;
 import org.apache.sis.feature.builder.PropertyTypeBuilder;
@@ -47,8 +47,8 @@ import org.apache.sis.util.ResourceInternationalString;
 import org.apache.sis.util.iso.DefaultNameFactory;
 
 // Branch-dependent imports
-import org.opengis.feature.AttributeType;
 import org.opengis.feature.FeatureType;
+import org.opengis.feature.Operation;
 
 
 /**
@@ -57,7 +57,8 @@ import org.opengis.feature.FeatureType;
  * nevertheless allows definition of alternative {@code Types} with names created by different factories.
  *
  * @author  Johann Sorel (Geomatys)
- * @version 0.8
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
  * @since   0.8
  */
 final class Types {
@@ -132,8 +133,7 @@ final class Types {
     {
         geometries = Geometries.implementation(library);
         final Map<String,InternationalString[]> resources = new HashMap<>();
-        final ScopedName    geomName = AttributeConvention.GEOMETRY_PROPERTY;
-        final Map<String,?> geomInfo = Map.of(AbstractIdentifiedType.NAME_KEY, geomName);
+        final Map<String,?> geomInfo = Map.of(AbstractIdentifiedType.NAME_KEY, AttributeConvention.GEOMETRY_PROPERTY);
         final Map<String,?> envpInfo = Map.of(AbstractIdentifiedType.NAME_KEY, AttributeConvention.ENVELOPE_PROPERTY);
         /*
          * The parent of all FeatureTypes to be created in this constructor.
@@ -180,7 +180,7 @@ final class Types {
          * └──────────────────┴────────────────┴───────────────────────┴──────────────┘
          */
         builder.clear().setSuperTypes(parent).setNameSpace(Tags.PREFIX).setName("WayPoint");
-        builder.addAttribute(GeometryType.POINT).setName(geomName)
+        builder.addAttribute(GeometryType.POINT).setName(AttributeConvention.GEOMETRY_PROPERTY)
                 .setCRS(CommonCRS.WGS84.normalizedGeographic())
                 .addRole(AttributeRole.DEFAULT_GEOMETRY);
         builder.setDefaultMultiplicity(0, 1);
@@ -221,8 +221,7 @@ final class Types {
          * │ rtept          │ WayPoint       │ gpx:wptType           │   [0 … ∞]    │
          * └────────────────┴────────────────┴───────────────────────┴──────────────┘
          */
-        final AttributeType<?> groupResult = GroupAsPolylineOperation.getResult(geometries);
-        GroupAsPolylineOperation groupOp = new GroupAsPolylineOperation(geomInfo, Tags.ROUTE_POINTS, groupResult);
+        Operation groupOp = groupAsPolyline(geomInfo, Tags.ROUTE_POINTS, wayPoint);
         builder.clear().setSuperTypes(parent).setNameSpace(Tags.PREFIX).setName("Route");
         builder.addProperty(groupOp);
         builder.addProperty(FeatureOperations.envelope(envpInfo, null, groupOp));
@@ -247,7 +246,7 @@ final class Types {
          * │ trkpt          │ WayPoint │ gpx:wptType │   [0 … ∞]    │
          * └────────────────┴──────────┴─────────────┴──────────────┘
          */
-        groupOp = new GroupAsPolylineOperation(geomInfo, Tags.TRACK_POINTS, groupResult);
+        groupOp = groupAsPolyline(geomInfo, Tags.TRACK_POINTS, wayPoint);
         builder.clear().setSuperTypes(parent).setNameSpace(Tags.PREFIX).setName("TrackSegment");
         builder.addProperty(groupOp);
         builder.addProperty(FeatureOperations.envelope(envpInfo, null, groupOp));
@@ -272,7 +271,7 @@ final class Types {
          * │ trkseg         │ TrackSegment   │ gpx:trksegType        │   [0 … ∞]    │
          * └────────────────┴────────────────┴───────────────────────┴──────────────┘
          */
-        groupOp = new GroupAsPolylineOperation(geomInfo, Tags.TRACK_SEGMENTS, groupResult);
+        groupOp = groupAsPolyline(geomInfo, Tags.TRACK_SEGMENTS, trackSegment);
         builder.clear().setSuperTypes(parent).setNameSpace(Tags.PREFIX).setName("Track");
         builder.addProperty(groupOp);
         builder.addProperty(FeatureOperations.envelope(envpInfo, null, groupOp));
@@ -317,4 +316,16 @@ final class Types {
         }
         return builder.build();
     }
+
+    /**
+     * Creates a new operation which will group the geometries in the given property into a single polyline.
+     *
+     * @param geomInfo    the name of the operation, together with optional information.
+     * @param components  name of the property providing the geometries to group as a polyline.
+     * @param type        type of the property identified by {@code components}.
+     */
+    private Operation groupAsPolyline(final Map<String,?> geomInfo, final String components, final FeatureType type) {
+        var c = new DefaultAssociationRole(Map.of(DefaultAssociationRole.NAME_KEY, components), type, 1, 1);
+        return FeatureOperations.groupAsPolyline(geomInfo, geometries.library, c);
+    }
 }