Move the `Joiner` inner class to a top-level class.
There is no code change (other than move) in this commit.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/isoline/Fragments.java b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/isoline/Fragments.java
index da97430..698fc1b 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/isoline/Fragments.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/isoline/Fragments.java
@@ -37,11 +37,12 @@
  * of {@code double[]} arrays: because even indices become odd and odd indices become even, points order are
  * implicitly reverted without the need to rewrite all {@code double[]} array contents.
  *
- * @see Tracer.Level#partialPaths
- *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 1.3
- * @since   1.1
+ *
+ * @see Tracer.Level#partialPaths
+ *
+ * @since 1.1
  * @module
  */
 @SuppressWarnings({"CloneableImplementsClone", "serial"})           // Not intended to be cloned or serialized.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/isoline/Joiner.java b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/isoline/Joiner.java
new file mode 100644
index 0000000..b9ccfcf
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/isoline/Joiner.java
@@ -0,0 +1,168 @@
+/*
+ * 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.processing.isoline;
+
+import org.apache.sis.internal.feature.j2d.PathBuilder;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.TransformException;
+
+
+/**
+ * Assembles arbitrary amount of {@link PolylineBuffer}s in a single Java2D {@link Shape} for an isoline level.
+ * This class extends {@link PathBuilder} with two additional features: remove spikes caused by ambiguities,
+ * then apply a {@link MathTransform} on all coordinate values.
+ *
+ * <h2>Spikes</h2>
+ * If the shape delimited by given polylines has a part with zero width or height ({@literal i.e.} a spike),
+ * truncates the polylines for removing that spike. This situation happens when some pixel values are exactly
+ * equal to isoline value, as in the picture below:
+ *
+ * {@preformat text
+ *     ●╌╌╌╲╌╌○╌╌╌╌╌╌○╌╌╌╌╌╌○╌╌╌╌╌╌○
+ *     ╎    ╲ ╎      ╎      ╎      ╎
+ *     ╎     ╲╎      ╎   →  ╎      ╎
+ *     ●╌╌╌╌╌╌●──────●──────●⤸╌╌╌╌╌○
+ *     ╎     ╱╎      ╎   ←  ╎      ╎
+ *     ╎    ╱ ╎      ╎      ╎      ╎
+ *     ●╌╌╌╱╌╌○╌╌╌╌╌╌○╌╌╌╌╌╌○╌╌╌╌╌╌○
+ * }
+ *
+ * The spike may appear or not depending on the convention adopted for strictly equal values.
+ * In above picture, the spike appears because the convention used in this implementation is:
+ *
+ * <ul>
+ *   <li>○: {@literal pixel value < isoline value}.</li>
+ *   <li>●: {@literal pixel value ≥ isoline value}.</li>
+ * </ul>
+ *
+ * If the following convention was used instead, the spike would not appear in above figure
+ * (but would appear in different situations):
+ *
+ * <ul>
+ *   <li>○: {@literal pixel value ≤ isoline value}.</li>
+ *   <li>●: {@literal pixel value > isoline value}.</li>
+ * </ul>
+ *
+ * This class detects and removes those spikes for avoiding convention-dependent results.
+ * We assume that spikes can appear only at the junction between two {@link PolylineBuffer} instances.
+ * Rational: having a spike require that we move forward then backward on the same coordinates,
+ * which is possible only with a non-null {@link PolylineBuffer#opposite} field.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.3
+ * @since   1.1
+ * @module
+ */
+final class Joiner extends PathBuilder {
+    /**
+     * Final transform to apply on coordinates, or {@code null} if none.
+     */
+    private final MathTransform gridToCRS;
+
+    /**
+     * Creates an initially empty set of isoline shapes.
+     */
+    Joiner(final MathTransform gridToCRS) {
+        this.gridToCRS = gridToCRS;
+    }
+
+    /**
+     * Detects and removes spikes for avoiding convention-dependent results.
+     * See {@link Joiner} class-javadoc for a description of the problem.
+     *
+     * <p>We perform the analysis in this method instead of in {@link #filterFull(double[], int)} on the
+     * the assumption that spikes can appear only between two calls to {@code append(…)} (because having
+     * a spike requires that we move forward then backward on the same coordinates, which happen only with
+     * two distinct {@link PolylineBuffer} instances). It reduce the amount of coordinates to examine since
+     * we can check only the extremities instead of looking for spikes anywhere in the array.</p>
+     *
+     * @param  coordinates  the coordinates to filter. Values can be modified in-place.
+     * @param  lower        index of first coordinate to filter. Always even.
+     * @param  upper        index after the last coordinate to filter. Always even.
+     * @return number of valid coordinates after filtering.
+     */
+    @Override
+    protected int filterChunk(final double[] coordinates, final int lower, final int upper) {
+        int spike0 = lower;         // Will be index where (x,y) become different than (xo,yo).
+        int spike1 = lower;         // Idem, but searching forward instead of backward.
+        if (spike1 < upper) {
+            final double xo = coordinates[spike1++];
+            final double yo = coordinates[spike1++];
+            int equalityMask = 3;                   // Bit 1 set if (x == xo), bit 2 set if (y == yo).
+            while (spike1 < upper) {
+                final int before = equalityMask;
+                if (coordinates[spike1++] != xo) equalityMask &= ~1;
+                if (coordinates[spike1++] != yo) equalityMask &= ~2;
+                if (equalityMask == 0) {
+                    equalityMask = before;                  // For keeping same search criterion.
+                    spike1 -= PolylineBuffer.DIMENSION;      // Restore previous position before mismatch.
+                    break;
+                }
+            }
+            while (spike0 > 0) {
+                if (coordinates[--spike0] != yo) equalityMask &= ~2;
+                if (coordinates[--spike0] != xo) equalityMask &= ~1;
+                if (equalityMask == 0) {
+                    spike0 += PolylineBuffer.DIMENSION;
+                    break;
+                }
+            }
+        }
+        /*
+         * Here we have range of indices where the polygon has a width or height of zero.
+         * Search for a common point, then truncate at that point. Indices are like below:
+         *
+         *     0       spike0    lower          spike1         upper
+         *     ●────●────●────●────●────●────●────●────●────●────●
+         *                    └╌╌╌╌remove╌╌╌╌┘
+         * where:
+         *  - `lower` and `spike0` are inclusive.
+         *  - `upper` and `spike1` are exclusive.
+         *  - the region to remove are sowewhere between `spike0` and `spike1`.
+         */
+        final int limit = spike1;
+        int base;
+        while ((base = spike0 + 2*PolylineBuffer.DIMENSION) < limit) {    // Spikes exist only with at least 3 points.
+            final double xo = coordinates[spike0++];
+            final double yo = coordinates[spike0++];
+            spike1 = limit;
+            do {
+                if (coordinates[spike1 - 2] == xo && coordinates[spike1 - 1] == yo) {
+                    /*
+                     * Remove points between the common point (xo,yo). The common point is kept on the
+                     * left side (`spike0` is already after that point) and removed on the right side.
+                     */
+                    System.arraycopy(coordinates, spike1, coordinates, spike0, upper - spike1);
+                    return upper - (spike1 - spike0);
+                }
+            } while ((spike1 -= PolylineBuffer.DIMENSION) > base);
+        }
+        return upper;       // Nothing to remove.
+    }
+
+    /**
+     * Applies user-specified coordinate transform on all points of the whole polyline.
+     * This method is invoked after {@link #filterChunk(double[], int, int)}.
+     */
+    @Override
+    protected int filterFull(final double[] coordinates, final int upper) throws TransformException {
+        if (gridToCRS != null) {
+            gridToCRS.transform(coordinates, 0, coordinates, 0, upper / PolylineBuffer.DIMENSION);
+        }
+        return upper;
+    }
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/isoline/Tracer.java b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/isoline/Tracer.java
index 17c777e..5bfbbac 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/processing/isoline/Tracer.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/processing/isoline/Tracer.java
@@ -699,147 +699,6 @@
     }
 
     /**
-     * Assembles arbitrary amount of {@link PolylineBuffer}s in a single Java2D {@link Shape} for an isoline level.
-     * This class extends {@link PathBuilder} with two additional features: remove spikes caused by ambiguities,
-     * then apply a {@link MathTransform} on all coordinate values.
-     *
-     * <h2>Spikes</h2>
-     * If the shape delimited by given polylines has a part with zero width or height ({@literal i.e.} a spike),
-     * truncates the polylines for removing that spike. This situation happens when some pixel values are exactly
-     * equal to isoline value, as in the picture below:
-     *
-     * {@preformat text
-     *     ●╌╌╌╲╌╌○╌╌╌╌╌╌○╌╌╌╌╌╌○╌╌╌╌╌╌○
-     *     ╎    ╲ ╎      ╎      ╎      ╎
-     *     ╎     ╲╎      ╎   →  ╎      ╎
-     *     ●╌╌╌╌╌╌●──────●──────●⤸╌╌╌╌╌○
-     *     ╎     ╱╎      ╎   ←  ╎      ╎
-     *     ╎    ╱ ╎      ╎      ╎      ╎
-     *     ●╌╌╌╱╌╌○╌╌╌╌╌╌○╌╌╌╌╌╌○╌╌╌╌╌╌○
-     * }
-     *
-     * The spike may appear or not depending on the convention adopted for strictly equal values.
-     * In above picture, the spike appears because the convention used in this implementation is:
-     *
-     * <ul>
-     *   <li>○: {@literal pixel value < isoline value}.</li>
-     *   <li>●: {@literal pixel value ≥ isoline value}.</li>
-     * </ul>
-     *
-     * If the following convention was used instead, the spike would not appear in above figure
-     * (but would appear in different situations):
-     *
-     * <ul>
-     *   <li>○: {@literal pixel value ≤ isoline value}.</li>
-     *   <li>●: {@literal pixel value > isoline value}.</li>
-     * </ul>
-     *
-     * This class detects and removes those spikes for avoiding convention-dependent results.
-     * We assume that spikes can appear only at the junction between two {@link PolylineBuffer} instances.
-     * Rational: having a spike require that we move forward then backward on the same coordinates,
-     * which is possible only with a non-null {@link PolylineBuffer#opposite} field.
-     */
-    private static final class Joiner extends PathBuilder {
-        /**
-         * Final transform to apply on coordinates, or {@code null} if none.
-         */
-        private final MathTransform gridToCRS;
-
-        /**
-         * Creates an initially empty set of isoline shapes.
-         */
-        Joiner(final MathTransform gridToCRS) {
-            this.gridToCRS = gridToCRS;
-        }
-
-        /**
-         * Detects and removes spikes for avoiding convention-dependent results.
-         * See {@link Joiner} class-javadoc for a description of the problem.
-         *
-         * <p>We perform the analysis in this method instead of in {@link #filterFull(double[], int)} on the
-         * the assumption that spikes can appear only between two calls to {@code append(…)} (because having
-         * a spike requires that we move forward then backward on the same coordinates, which happen only with
-         * two distinct {@link PolylineBuffer} instances). It reduce the amount of coordinates to examine since
-         * we can check only the extremities instead of looking for spikes anywhere in the array.</p>
-         *
-         * @param  coordinates  the coordinates to filter. Values can be modified in-place.
-         * @param  lower        index of first coordinate to filter. Always even.
-         * @param  upper        index after the last coordinate to filter. Always even.
-         * @return number of valid coordinates after filtering.
-         */
-        @Override
-        protected int filterChunk(final double[] coordinates, final int lower, final int upper) {
-            int spike0 = lower;         // Will be index where (x,y) become different than (xo,yo).
-            int spike1 = lower;         // Idem, but searching forward instead of backward.
-            if (spike1 < upper) {
-                final double xo = coordinates[spike1++];
-                final double yo = coordinates[spike1++];
-                int equalityMask = 3;                   // Bit 1 set if (x == xo), bit 2 set if (y == yo).
-                while (spike1 < upper) {
-                    final int before = equalityMask;
-                    if (coordinates[spike1++] != xo) equalityMask &= ~1;
-                    if (coordinates[spike1++] != yo) equalityMask &= ~2;
-                    if (equalityMask == 0) {
-                        equalityMask = before;                  // For keeping same search criterion.
-                        spike1 -= PolylineBuffer.DIMENSION;      // Restore previous position before mismatch.
-                        break;
-                    }
-                }
-                while (spike0 > 0) {
-                    if (coordinates[--spike0] != yo) equalityMask &= ~2;
-                    if (coordinates[--spike0] != xo) equalityMask &= ~1;
-                    if (equalityMask == 0) {
-                        spike0 += PolylineBuffer.DIMENSION;
-                        break;
-                    }
-                }
-            }
-            /*
-             * Here we have range of indices where the polygon has a width or height of zero.
-             * Search for a common point, then truncate at that point. Indices are like below:
-             *
-             *     0       spike0    lower          spike1         upper
-             *     ●────●────●────●────●────●────●────●────●────●────●
-             *                    └╌╌╌╌remove╌╌╌╌┘
-             * where:
-             *  - `lower` and `spike0` are inclusive.
-             *  - `upper` and `spike1` are exclusive.
-             *  - the region to remove are sowewhere between `spike0` and `spike1`.
-             */
-            final int limit = spike1;
-            int base;
-            while ((base = spike0 + 2*PolylineBuffer.DIMENSION) < limit) {    // Spikes exist only with at least 3 points.
-                final double xo = coordinates[spike0++];
-                final double yo = coordinates[spike0++];
-                spike1 = limit;
-                do {
-                    if (coordinates[spike1 - 2] == xo && coordinates[spike1 - 1] == yo) {
-                        /*
-                         * Remove points between the common point (xo,yo). The common point is kept on the
-                         * left side (`spike0` is already after that point) and removed on the right side.
-                         */
-                        System.arraycopy(coordinates, spike1, coordinates, spike0, upper - spike1);
-                        return upper - (spike1 - spike0);
-                    }
-                } while ((spike1 -= PolylineBuffer.DIMENSION) > base);
-            }
-            return upper;       // Nothing to remove.
-        }
-
-        /**
-         * Applies user-specified coordinate transform on all points of the whole polyline.
-         * This method is invoked after {@link #filterChunk(double[], int, int)}.
-         */
-        @Override
-        protected int filterFull(final double[] coordinates, final int upper) throws TransformException {
-            if (gridToCRS != null) {
-                gridToCRS.transform(coordinates, 0, coordinates, 0, upper / PolylineBuffer.DIMENSION);
-            }
-            return upper;
-        }
-    }
-
-    /**
      * Writes all given polylines to the specified path builder. Null {@code PolylineBuffer} instances are ignored.
      * {@code PolylineBuffer} instances at even index are written with their points in reverse order.
      * All given polylines are cleared by this method.