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 c2f143b493c5b269220ad38d4f9458103373c886
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Thu Dec 11 11:15:07 2025 +0100

    Add a `stream(Collection<DirectPosition>)` method in the 
`GridCoverage.Evaluator` interface.
    Refactor `DefaultEvaluator` implementation for creating a `Spliterator` for 
each subsequence
    of points on the same slice and same tile.
---
 .../org/apache/sis/coverage/BandedCoverage.java    |  60 +-
 .../coverage/grid/BandAggregateGridCoverage.java   |  14 +-
 .../sis/coverage/grid/BufferedGridCoverage.java    |  38 +-
 .../sis/coverage/grid/ConvertedGridCoverage.java   |  25 +-
 .../apache/sis/coverage/grid/DefaultEvaluator.java | 458 +++++++-----
 .../apache/sis/coverage/grid/EvaluatorWrapper.java |  19 +-
 .../coverage/grid/FractionalGridCoordinates.java   | 157 +---
 .../org/apache/sis/coverage/grid/GridCoverage.java |  38 +-
 .../apache/sis/coverage/grid/GridCoverage2D.java   |  46 +-
 .../sis/coverage/grid/ValuesAtPointIterator.java   | 809 +++++++++++++++++++++
 .../org/apache/sis/feature/internal/Resources.java |   4 +-
 .../sis/feature/internal/Resources.properties      |   2 +-
 .../sis/feature/internal/Resources_fr.properties   |   2 +-
 .../sis/coverage/grid/DefaultEvaluatorTest.java    | 294 ++++++++
 .../grid/FractionalGridCoordinatesTest.java        |  11 +-
 .../sis/coverage/grid/GridCoverage2DTest.java      |   4 +-
 .../main/org/apache/sis/io/CompoundFormat.java     |  39 +-
 .../main/org/apache/sis/io/package-info.java       |   2 +-
 18 files changed, 1642 insertions(+), 380 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/BandedCoverage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/BandedCoverage.java
index e735e1b0d8..895f9899c0 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/BandedCoverage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/BandedCoverage.java
@@ -18,6 +18,8 @@ package org.apache.sis.coverage;
 
 import java.util.List;
 import java.util.Optional;
+import java.util.Collection;
+import java.util.stream.Stream;
 import java.util.function.Function;
 import org.opengis.geometry.Envelope;
 import org.opengis.geometry.DirectPosition;
@@ -131,7 +133,7 @@ public abstract class BandedCoverage {
      *
      * @author  Johann Sorel (Geomatys)
      * @author  Martin Desruisseaux (Geomatys)
-     * @version 1.3
+     * @version 1.6
      *
      * @see BandedCoverage#evaluator()
      *
@@ -199,16 +201,19 @@ public abstract class BandedCoverage {
 
         /**
          * Returns a sequence of double values for a given point in the 
coverage.
-         * The CRS of the given point may be any coordinate reference system;
-         * coordinate conversions will be applied as needed.
-         * If the CRS of the point is undefined, then it is assumed to be the 
{@linkplain #getCoverage() coverage} CRS.
-         * The returned sequence includes a value for each {@linkplain 
SampleDimension sample dimension}.
+         * If the <abbr>CRS</abbr> of the point is undefined (i.e., {@code 
null}),
+         * then it is assumed to be the <abbr>CRS</abbr> of the {@linkplain 
#getCoverage() coverage}.
+         * If the <abbr>CRS</abbr> of the point is defined but different than 
the coverage <abbr>CRS</abbr>,
+         * then coordinate conversions or transformations will be applied 
automatically by this method.
          *
-         * @param  point   the position where to evaluate.
+         * <p>The returned sequence includes a value for each {@linkplain 
SampleDimension sample dimension}.
+         * For performance reason, this method may return the same array on 
every method call by overwriting
+         * previous values. Therefore, callers can assume that the array 
content is stable only until the next call
+         * to an {@code Evaluator} method or traversal of more elements in the 
{@linkplain #stream stream}.</p>
+         *
+         * @param  point  the position where to evaluate.
          * @return the sample values at the specified point, or {@code null} 
if the point is outside the coverage.
-         *         For performance reason, this method may return the same 
array
-         *         on every method call by overwriting previous values.
-         *         Callers should not assume that the array content stay valid 
for a long time.
+         *         This is not guaranteed to be a new array on each method 
call.
          * @throws PointOutsideCoverageException if the evaluation failed 
because the input point
          *         has invalid coordinates and the {@link #isNullIfOutside()} 
flag is {@code false}.
          * @throws CannotEvaluateException if the values cannot be computed at 
the specified coordinates
@@ -217,5 +222,42 @@ public abstract class BandedCoverage {
          */
         @Override
         double[] apply(DirectPosition point) throws CannotEvaluateException;
+
+        /**
+         * Returns a stream of sample values for each point of the given 
collection.
+         * The values in the returned stream are traversed in the iteration 
order of the given collection.
+         * The returned stream behave as if {@link #apply(DirectPosition)} was 
invoked for each point.
+         *
+         * <p>This method is equivalent to {@code 
points.stream().map(this::apply)}, but potentially more efficient.
+         * Therefore, the notes documented in {@link #apply(DirectPosition)} 
apply also to each elements traversed by
+         * the stream: the <abbr>CRS</abbr> of each point is handled as 
documented in {@link #apply(DirectPosition)},
+         * consumers should not assume that the content of the {@code 
double[]} arrays are stable after execution of
+         * the consumer body, and some elements provided by the stream may be 
{@code null} if a point is outside the
+         * coverage and the {@link #isNullIfOutside()} flag is {@code 
true}.</p>
+         *
+         * <h4>Parallel streams</h4>
+         * While {@code Evaluator} is not thread-safe, parallel streams may be 
supported provided that the state of
+         * this {@code Evaluator} is not modified during stream execution. A 
parallel stream can be requested by
+         * invoking this method with the {@code parallel} argument set to 
{@code true}.
+         * The {@link Stream#parallel()} method should <strong>not</strong> by 
invoked.
+         *
+         * <p>Implementations may ignore the {@code parallel} argument if they 
do not support parallel streams.
+         * It is more difficult for implementations to ignore a call to {@link 
Stream#parallel()}, which is why
+         * {@code parallel()} should not be invoked on streams returned by 
this method.</p>
+         *
+         * <h4>Exceptions</h4>
+         * {@link CannotEvaluateException} may be thrown either when this 
method is invoked,
+         * or later during the traversal, at implementation choice.
+         *
+         * @param  points   the positions where to evaluate.
+         * @param  parallel {@code true} for a parallel stream, or {@code 
false} for a sequential stream.
+         * @return a sequential or parallel stream of sample values at the 
specified positions.
+         *
+         * @since 1.6
+         */
+        default Stream<double[]> stream(Collection<? extends DirectPosition> 
points, boolean parallel) {
+            // Ignore `parallel` because we don't know if 
`apply(DirectPosition)` is thread-safe.
+            return points.stream().map(this::apply);
+        }
     }
 }
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/BandAggregateGridCoverage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/BandAggregateGridCoverage.java
index b3cb457620..7d8c259534 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/BandAggregateGridCoverage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/BandAggregateGridCoverage.java
@@ -190,7 +190,12 @@ final class BandAggregateGridCoverage extends GridCoverage 
{
          * The union of slice coordinates from all sources, or {@code null} if 
not yet computed.
          * All coverages should have the same values, but we nevertheless 
compute union in case.
          */
-        private Map<Integer,Long> slices;
+        private Map<Integer, Long> slices;
+
+        /**
+         * Result of combining the bands, recycled on each invocation of this 
method.
+         */
+        private final double[] aggregate;
 
         /**
          * Creates a new evaluator which will delegate to the evaluators of 
all given sources.
@@ -200,6 +205,7 @@ final class BandAggregateGridCoverage extends GridCoverage {
             for (int i=0; i < coverages.length; i++) {
                 sources[i] = coverages[i].evaluator();
             }
+            aggregate = new double[numBands];
         }
 
         /**
@@ -215,9 +221,9 @@ final class BandAggregateGridCoverage extends GridCoverage {
          */
         @Override
         @SuppressWarnings("ReturnOfCollectionOrArrayField")
-        public Map<Integer,Long> getDefaultSlice() {
+        public Map<Integer, Long> getDefaultSlice() {
             if (slices == null) {
-                final var c = new TreeMap<Integer,Long>();
+                final var c = new TreeMap<Integer, Long>();
                 for (final Evaluator source : sources) {
                     c.putAll(source.getDefaultSlice());
                 }
@@ -288,8 +294,8 @@ final class BandAggregateGridCoverage extends GridCoverage {
          * This method delegates to all source evaluators and merge the 
results.
          */
         @Override
+        @SuppressWarnings("ReturnOfCollectionOrArrayField")
         public double[] apply(final DirectPosition point) {
-            final double[] aggregate = new double[numBands];
             int offset = 0;
             for (int i=0; i < sources.length; i++) {
                 final double[] values = sources[i].apply(point);
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/BufferedGridCoverage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/BufferedGridCoverage.java
index e4105667ea..741fb9a20f 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/BufferedGridCoverage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/BufferedGridCoverage.java
@@ -77,7 +77,7 @@ import org.opengis.coverage.PointOutsideCoverageException;
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.4
+ * @version 1.6
  * @since   1.1
  */
 public class BufferedGridCoverage extends GridCoverage {
@@ -234,7 +234,7 @@ public class BufferedGridCoverage extends GridCoverage {
      */
     @Override
     public Evaluator evaluator() {
-        return new CellAccessor(this);
+        return new CellAccessor();
     }
 
     /**
@@ -285,13 +285,9 @@ public class BufferedGridCoverage extends GridCoverage {
 
     /**
      * Implementation of evaluator returned by {@link #evaluator()}.
+     * This evaluator gets the values directly from the {@link DataBuffer}.
      */
-    private static final class CellAccessor extends DefaultEvaluator {
-        /**
-         * A copy of {@link BufferedGridCoverage#data} reference.
-         */
-        private final DataBuffer data;
-
+    private final class CellAccessor extends DefaultEvaluator {
         /**
          * The grid lower values. Those values need to be subtracted to each
          * grid coordinate before to compute index in {@link #data} buffer.
@@ -311,13 +307,11 @@ public class BufferedGridCoverage extends GridCoverage {
         private final boolean banded;
 
         /**
-         * Creates a new evaluator for the specified coverage.
+         * Creates a new evaluator for the enclosing coverage.
          */
-        CellAccessor(final BufferedGridCoverage coverage) {
-            super(coverage);
-            final GridExtent extent = coverage.getGridGeometry().getExtent();
-            final int numBands = coverage.getSampleDimensions().size();
-            data     = coverage.data;
+        CellAccessor() {
+            final GridExtent extent = getGridGeometry().getExtent();
+            final int numBands = getSampleDimensions().size();
             banded   = data.getNumBanks() > 1;
             values   = new double[numBands];
             lower    = new long[extent.getDimension()];
@@ -329,29 +323,37 @@ public class BufferedGridCoverage extends GridCoverage {
             }
         }
 
+        /**
+         * Returns the coverage from which this evaluator is fetching sample 
values.
+         */
+        @Override
+        public final GridCoverage getCoverage() {
+            return BufferedGridCoverage.this;
+        }
+
         /**
          * Returns a sequence of double values for a given point in the 
coverage.
          * The CRS of the given point may be any coordinate reference system,
          * or {@code null} for the same CRS as the coverage.
          */
         @Override
+        @SuppressWarnings("ReturnOfCollectionOrArrayField")
         public double[] apply(final DirectPosition point) throws 
CannotEvaluateException {
             final int pos;
             try {
-                final FractionalGridCoordinates gc = toGridPosition(point);
+                final double[] gridCoords = toGridPosition(point);
                 int  i = lower.length;
                 long s = sizes[i];
                 long index = 0;
                 while (--i >= 0) {
                     final long low = lower[i];
-                    long p = gc.getCoordinateValue(i);
+                    long p = Math.round(gridCoords[i]);
                     // (p - low) may overflow, so we must test (p < low) 
before.
                     if (p < low || (p -= low) >= s) {
                         if (isNullIfOutside()) {
                             return null;
                         }
-                        throw new PointOutsideCoverageException(
-                                
gc.pointOutsideCoverage(getCoverage().gridGeometry.extent), point);
+                        throw new 
PointOutsideCoverageException(pointOutsideCoverage(point), point);
                     }
                     /*
                      * Following should never overflow, otherwise 
BufferedGridCoverage
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/ConvertedGridCoverage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/ConvertedGridCoverage.java
index 72b563138e..78c017524c 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/ConvertedGridCoverage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/ConvertedGridCoverage.java
@@ -20,6 +20,8 @@ import java.util.List;
 import java.util.Arrays;
 import java.util.ArrayList;
 import java.util.Optional;
+import java.util.Collection;
+import java.util.stream.Stream;
 import java.awt.image.RenderedImage;
 import org.opengis.geometry.DirectPosition;
 import org.opengis.referencing.operation.MathTransform1D;
@@ -268,7 +270,28 @@ final class ConvertedGridCoverage extends 
DerivedGridCoverage {
          */
         @Override
         public double[] apply(final DirectPosition point) throws 
CannotEvaluateException {
-            final double[] values = super.apply(point);
+            return convert(super.apply(point));
+        }
+
+        /**
+         * Returns a stream of double values for given points in the coverage.
+         * This method delegates to the source coverage, then converts the 
values.
+         *
+         * @param  point    the position where to evaluate.
+         * @param  parallel {@code true} for a parallel stream, or {@code 
false} for a sequential stream.
+         */
+        @Override
+        public Stream<double[]> stream(Collection<? extends DirectPosition> 
points, boolean parallel) {
+            return super.stream(points, parallel).map(this::convert);
+        }
+
+        /**
+         * Converts the given sample values.
+         *
+         * @param  values  the sample values to convert. May be null.
+         * @return the converted sample values, or {@code null} if the given 
values were null.
+         */
+        private double[] convert(final double[] values) {
             if (values != null) try {
                 final MathTransform1D[] converters = 
ConvertedGridCoverage.this.converters;
                 for (int i=0; i<converters.length; i++) {
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/DefaultEvaluator.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/DefaultEvaluator.java
index 4711de9781..bc59bf82be 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/DefaultEvaluator.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/DefaultEvaluator.java
@@ -17,10 +17,14 @@
 package org.apache.sis.coverage.grid;
 
 import java.util.Map;
+import java.util.List;
 import java.util.Arrays;
 import java.util.TreeMap;
 import java.util.Objects;
-import java.awt.image.RenderedImage;
+import java.util.Collection;
+import java.util.function.IntFunction;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 import org.opengis.util.FactoryException;
 import org.opengis.geometry.DirectPosition;
 import org.opengis.metadata.extent.GeographicBoundingBox;
@@ -30,12 +34,6 @@ import org.opengis.referencing.operation.TransformException;
 import org.opengis.referencing.operation.CoordinateOperation;
 import org.opengis.referencing.operation.NoninvertibleTransformException;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
-import org.apache.sis.coverage.SampleDimension;
-import org.apache.sis.feature.internal.Resources;
-import org.apache.sis.util.ArraysExt;
-import org.apache.sis.util.ArgumentChecks;
-import org.apache.sis.util.internal.shared.CollectionsExt;
-import org.apache.sis.image.internal.shared.ImageUtilities;
 import org.apache.sis.referencing.CRS;
 import org.apache.sis.referencing.internal.shared.DirectPositionView;
 import org.apache.sis.referencing.internal.shared.WraparoundAxesFinder;
@@ -43,11 +41,18 @@ import org.apache.sis.referencing.operation.matrix.Matrices;
 import org.apache.sis.referencing.operation.matrix.MatrixSIS;
 import org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.referencing.operation.transform.TransformSeparator;
+import org.apache.sis.geometry.CoordinateFormat;
+import org.apache.sis.feature.internal.Resources;
+import org.apache.sis.util.ArraysExt;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.StringBuilders;
 import org.apache.sis.util.logging.Logging;
+import org.apache.sis.util.internal.shared.CollectionsExt;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
 import org.opengis.coverage.CannotEvaluateException;
 import org.opengis.coverage.PointOutsideCoverageException;
+import org.opengis.coordinate.MismatchedDimensionException;
 
 
 /**
@@ -59,24 +64,21 @@ import org.opengis.coverage.PointOutsideCoverageException;
  * for each thread that need to compute sample values.
  *
  * <h2>Limitations</h2>
- * Current implementation performs nearest-neighbor sampling only. A future 
version will provide interpolations.
+ * Current implementation performs nearest-neighbor sampling only.
+ * A future version may provide interpolations.
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
  *
  * @see GridCoverage#evaluator()
+ * @see <a href="https://issues.apache.org/jira/browse/SIS-576";>SIS-576</a>
  */
-class DefaultEvaluator implements GridCoverage.Evaluator {
-    /**
-     * The coverage in which to evaluate sample values.
-     */
-    private final GridCoverage coverage;
-
+abstract class DefaultEvaluator implements GridCoverage.Evaluator {
     /**
      * The coordinate reference system of input points given to this converter,
-     * or {@code null} if assumed the same as the coverage CRS.
-     * This is used by {@link #toGridPosition(DirectPosition)} for checking if 
{@link #inputToGrid} needs
-     * to be recomputed. As long at the evaluated points have the same CRS, 
the same transform is reused.
+     * or {@code null} if assumed the same as the coverage <abbr>CRS</abbr>.
+     * Used by {@link #toGridPosition(DirectPosition)} for checking if {@link 
#inputToGrid} needs to be recomputed.
+     * As long at the evaluated points have the same <abbr>CRS</abbr>, the 
same transform is reused.
      */
     private CoordinateReferenceSystem inputCRS;
 
@@ -88,15 +90,16 @@ class DefaultEvaluator implements GridCoverage.Evaluator {
     private MathTransform inputToGrid;
 
     /**
-     * Grid coordinates after {@link #inputToGrid} conversion.
-     *
-     * @see #toGridPosition(DirectPosition)
+     * Grid coordinates, created when first needed and then recycled.
+     * This is the result of applying the {@link #inputToGrid} transform.
      */
-    private FractionalGridCoordinates.Position position;
+    private DirectPosition gridCoordinates;
 
     /**
      * Array where to store sample values computed by {@link 
#apply(DirectPosition)}.
      * For performance reasons, the same array may be recycled on every method 
call.
+     * This array shall <em>not</em> used by {@link #stream(Collection, 
boolean)},
+     * in order to allow parallel execution.
      */
     double[] values;
 
@@ -146,30 +149,20 @@ class DefaultEvaluator implements GridCoverage.Evaluator {
      * @see #getDefaultSlice()
      * @see #setDefaultSlice(Map)
      */
-    private Map<Integer,Long> slice;
+    private Map<Integer, Long> slice;
 
     /**
-     * Creates a new evaluator for the given coverage. This constructor is 
protected for allowing
-     * {@link GridCoverage} subclasses to provide their own {@code 
DefaultEvaluator} implementations.
-     * For using an evaluator, invoke {@link GridCoverage#evaluator()} instead.
-     *
-     * @param  coverage  the coverage for which to create an evaluator.
-     *
-     * @see GridCoverage#evaluator()
+     * The format to use for writing error messages for point outside grid 
domain.
+     * Created only when first needed.
      */
-    protected DefaultEvaluator(final GridCoverage coverage) {
-        this.coverage = Objects.requireNonNull(coverage);
-    }
+    private CoordinateFormat coordinateFormat;
 
     /**
-     * Returns the coverage from which this evaluator is fetching sample 
values.
-     * This is the coverage on which the {@link GridCoverage#evaluator()} 
method has been invoked.
+     * Creates a new evaluator.
      *
-     * @return the source of sample values for this evaluator.
+     * @see GridCoverage#evaluator()
      */
-    @Override
-    public final GridCoverage getCoverage() {
-        return coverage;
+    protected DefaultEvaluator() {
     }
 
     /**
@@ -185,8 +178,9 @@ class DefaultEvaluator implements GridCoverage.Evaluator {
      */
     @Override
     @SuppressWarnings("ReturnOfCollectionOrArrayField")     // Because the map 
is unmodifiable.
-    public final Map<Integer,Long> getDefaultSlice() {
+    public final Map<Integer, Long> getDefaultSlice() {
         if (slice == null) {
+            final GridCoverage coverage = getCoverage();
             final GridExtent extent = coverage.getGridGeometry().getExtent();
             slice = 
CollectionsExt.unmodifiableOrCopy(extent.getSliceCoordinates());
         }
@@ -205,13 +199,14 @@ class DefaultEvaluator implements GridCoverage.Evaluator {
      */
     @Override
     @SuppressWarnings("AssignmentToCollectionOrArrayFieldFromParameter")
-    public void setDefaultSlice(Map<Integer,Long> slice) {
+    public void setDefaultSlice(Map<Integer, Long> slice) {
         if (!Objects.equals(this.slice, slice)) {
             if (slice != null) {
                 slice = CollectionsExt.unmodifiableOrCopy(new 
TreeMap<>(slice));
+                final GridCoverage coverage = getCoverage();
                 final GridExtent extent = 
coverage.getGridGeometry().getExtent();
                 final int max = extent.getDimension() - 1;
-                for (final Map.Entry<Integer,Long> entry : slice.entrySet()) {
+                for (final Map.Entry<Integer, Long> entry : slice.entrySet()) {
                     final int dim = entry.getKey();
                     ArgumentChecks.ensureBetween("slice.key", 0, max, dim);
                     
ArgumentChecks.ensureBetween(extent.getAxisIdentification(dim, dim).toString(),
@@ -249,7 +244,8 @@ class DefaultEvaluator implements GridCoverage.Evaluator {
     public void setWraparoundEnabled(final boolean allow) {
         wraparoundAxes = 0;
         if (allow) try {
-            final WraparoundAxesFinder f = new 
WraparoundAxesFinder(coverage.getCoordinateReferenceSystem());
+            final GridCoverage coverage = getCoverage();
+            final var f = new 
WraparoundAxesFinder(coverage.getCoordinateReferenceSystem());
             if ((periods = f.periods()) != null) {
                 final GridGeometry gridGeometry = coverage.getGridGeometry();
                 final GridExtent extent = gridGeometry.getExtent();
@@ -324,53 +320,27 @@ class DefaultEvaluator implements GridCoverage.Evaluator {
 
     /**
      * Returns a sequence of double values for a given point in the coverage.
-     * The CRS of the given point may be any coordinate reference system;
-     * coordinate conversions will be applied as needed.
-     * If the CRS of the point is undefined, then it is assumed to be the 
{@linkplain #getCoverage() coverage} CRS.
-     * The returned sequence includes a value for each {@linkplain 
SampleDimension sample dimension}.
-     *
-     * <p>The default interpolation type used when accessing grid values for 
points which fall between
-     * grid cells is nearest neighbor. This default interpolation method may 
change in future version.</p>
+     * This method checks the <abbr>CRS</abbr> and performs coordinate 
operations if needed.
      *
-     * <p>The default implementation invokes {@link 
GridCoverage#render(GridExtent)} for a small region
-     * around the point. Subclasses should override with more efficient 
implementation.</p>
-     *
-     * @param  point   the position where to evaluate.
+     * @param  point  the position where to evaluate.
      * @return the sample values at the specified point, or {@code null} if 
the point is outside the coverage.
-     *         For performance reason, this method may return the same array
-     *         on every method call by overwriting previous values.
-     *         Callers should not assume that the array content stay valid for 
a long time.
      * @throws PointOutsideCoverageException if the evaluation failed because 
the input point
      *         has invalid coordinates and the {@link #isNullIfOutside()} flag 
is {@code false}.
-     * @throws CannotEvaluateException if the values cannot be computed at the 
specified coordinates
-     *         for another reason. This exception may be thrown if the 
coverage data type cannot be
-     *         converted to {@code double} by an identity or widening 
conversion.
-     *         Subclasses may relax this constraint if appropriate.
+     * @throws CannotEvaluateException if the values cannot be computed for 
another reason.
      */
     @Override
+    @SuppressWarnings("ReturnOfCollectionOrArrayField")
     public double[] apply(final DirectPosition point) throws 
CannotEvaluateException {
-        /*
-         * TODO: instead of restricting to a single point, keep the automatic 
size (1 or 2),
-         * invoke render for each slice, then interpolate. We would keep a 
value of 1 in the
-         * size array if we want to disable interpolation in some particular 
axis (e.g. time).
-         *
-         * See https://issues.apache.org/jira/browse/SIS-576
-         */
-        final GridGeometry gridGeometry = coverage.gridGeometry;
-        final long[] size = new long[gridGeometry.getDimension()];
-        Arrays.fill(size, 1);
         try {
-            final FractionalGridCoordinates gc = toGridPosition(point);
-            try {
-                final GridExtent subExtent = gc.toExtent(gridGeometry.extent, 
size, nullIfOutside);
-                if (subExtent != null) {
-                    return evaluate(coverage.render(subExtent), 0, 0);
-                }
-            } catch (ArithmeticException | IndexOutOfBoundsException | 
DisjointExtentException ex) {
-                if (!nullIfOutside) {
-                    throw (PointOutsideCoverageException) new 
PointOutsideCoverageException(
-                            gc.pointOutsideCoverage(gridGeometry.extent), 
point).initCause(ex);
-                }
+            final double[] gridCoords = toGridPosition(point);
+            final IntFunction<String> ifOutside;
+            if (nullIfOutside) {
+                ifOutside = null;
+            } else {
+                ifOutside = (index) -> pointOutsideCoverage(point);
+            }
+            if (ValuesAtPointIterator.create(getCoverage(), gridCoords, 1, 
ifOutside).tryAdvance((t) -> values = t)) {
+                return values;
             }
         } catch (PointOutsideCoverageException ex) {
             ex.setOffendingLocation(point);
@@ -382,21 +352,95 @@ class DefaultEvaluator implements GridCoverage.Evaluator {
     }
 
     /**
-     * Gets sample values from the given image at the given index. This method 
does not verify
-     * explicitly if the coordinates are out of bounds; we rely on the checks 
performed by the
-     * image and sample model implementations.
+     * Returns a stream of sample values for each point of the given 
collection.
+     * The values in the returned stream are traversed in the iteration order 
of the given collection.
+     * The returned stream behave as if {@link #apply(DirectPosition)} was 
invoked for each point.
+     * {@link CannotEvaluateException} may be thrown when this method is 
invoked or during the traversal.
      *
-     * @param  data  the data from which to get the sample values.
-     * @param  x     column index of the value to get.
-     * @param  y     row index of the value to get.
-     * @return the sample values. The same array may be recycled on every 
method call.
-     * @throws ArithmeticException if an integer overflow occurred while 
computing indices.
-     * @throws IndexOutOfBoundsException if a coordinate is out of bounds.
+     * @param  points   the positions where to evaluate.
+     * @param  parallel {@code true} for a parallel stream, or {@code false} 
for a sequential stream.
+     * @return the sample values at the specified positions.
      */
-    final double[] evaluate(final RenderedImage data, final int x, final int 
y) {
-        final int tx = ImageUtilities.pixelToTileX(data, x);
-        final int ty = ImageUtilities.pixelToTileY(data, y);
-        return values = data.getTile(tx, ty).getPixel(x, y, values);
+    @Override
+    public Stream<double[]> stream(final Collection<? extends DirectPosition> 
points, final boolean parallel) {
+        final GridCoverage coverage = getCoverage();
+        final int dimension = coverage.gridGeometry.getDimension();
+        double[] coordinates = ArraysExt.EMPTY_DOUBLE;
+        MathTransform toGrid = inputToGrid;
+        int srcDim = (toGrid == null) ? 0 : toGrid.getSourceDimensions();
+        int inputCoordinateOffset = 0;
+        int firstCoordToTransform = 0;
+        int numPointsToTransform  = 0;
+        try {
+            /*
+             * Convert the "real world " coordinates to grid coordinates.
+             * The CRS of each point is verified. Consecutive points with
+             * the same CRS will be transformed in a single operation.
+             */
+            for (final DirectPosition point : points) {
+                if (setInputCRS(point.getCoordinateReferenceSystem())) {
+                    if (numPointsToTransform > 0) {     // Because `toGrid` 
may be null.
+                        assert toGrid.getTargetDimensions() == dimension;
+                        toGrid.transform(coordinates, firstCoordToTransform,
+                                         coordinates, firstCoordToTransform,
+                                         numPointsToTransform);
+                    }
+                    wraparound(coordinates, firstCoordToTransform, 
numPointsToTransform);
+                    firstCoordToTransform += numPointsToTransform * dimension;
+                    numPointsToTransform = 0;
+                    toGrid = inputToGrid;
+                    srcDim = toGrid.getSourceDimensions();
+                }
+                int offset = inputCoordinateOffset;
+                if ((inputCoordinateOffset += srcDim) > coordinates.length) {
+                    int n = firstCoordToTransform / dimension;      // Number 
of points already transformed.
+                    n = points.size() - n + numPointsToTransform;   // Number 
of points left to transform.
+                    coordinates = new double[Math.multiplyExact(n, 
Math.max(srcDim, dimension))];
+                }
+                for (int i=0; i<srcDim; i++) {
+                    coordinates[offset++] = point.getCoordinate(i);
+                }
+                numPointsToTransform++;
+            }
+            /*
+             * Transforms the remaining sequence of points.
+             * Actually, in most cases, all points are transformed here.
+             */
+            if (numPointsToTransform > 0) {     // Because `toGrid` may be 
null.
+                assert toGrid.getTargetDimensions() == dimension;
+                toGrid.transform(coordinates, firstCoordToTransform,
+                                 coordinates, firstCoordToTransform,
+                                 numPointsToTransform);
+            }
+            wraparound(coordinates, firstCoordToTransform, 
numPointsToTransform);
+            final int numPoints = firstCoordToTransform / dimension + 
numPointsToTransform;
+            /*
+             * Create the iterator. The `ValuesAtPointIterator.create(…)` 
method will identify the slices in
+             * n-dimensional coverage, get the rendered images for the regions 
of interest and get the tiles.
+             */
+            final IntFunction<String> ifOutside;
+            if (nullIfOutside) {
+                ifOutside = null;
+            } else {
+                final var listOfPoints = (points instanceof List<?>) ? (List<? 
extends DirectPosition>) points : null;
+                ifOutside = (index) -> {
+                    if (listOfPoints != null) try {
+                        DirectPosition point = listOfPoints.get(index);
+                        if (point != null) {
+                            return pointOutsideCoverage(point);
+                        }
+                    } catch (IndexOutOfBoundsException e) {
+                        recoverableException("pointOutsideCoverage", e);
+                    }
+                    return 
Resources.format(Resources.Keys.PointOutsideCoverageDomain_1, "#" + index);
+                };
+            }
+            return StreamSupport.stream(ValuesAtPointIterator.create(coverage, 
coordinates, numPoints, ifOutside), parallel);
+        } catch (CannotEvaluateException ex) {
+            throw ex;
+        } catch (RuntimeException | FactoryException | TransformException ex) {
+            throw new CannotEvaluateException(ex.getMessage(), ex);
+        }
     }
 
     /**
@@ -421,119 +465,122 @@ class DefaultEvaluator implements 
GridCoverage.Evaluator {
     @Override
     public FractionalGridCoordinates toGridCoordinates(final DirectPosition 
point) throws TransformException {
         try {
-            return new 
FractionalGridCoordinates(toGridPosition(Objects.requireNonNull(point)));
+            final double[] gridCoords = toGridPosition(point);
+            final int dimension = inputToGrid.getTargetDimensions();
+            return new FractionalGridCoordinates(ArraysExt.resize(gridCoords, 
dimension));
         } catch (FactoryException e) {
             throw new TransformException(e.getMessage(), e);
         }
     }
 
     /**
-     * Updates the grid {@linkplain #position} with the given geospatial 
position.
-     * This is the implementation of {@link 
#toGridCoordinates(DirectPosition)} except
-     * that it avoid creating a new {@link FractionalGridCoordinates} on each 
method call.
+     * Returns the grid coordinates of the given "real world" coordinates.
+     * This method may return an array longer than the grid dimension.
+     * Extra dimensions should be ignored.
      *
      * @param  point  the geospatial position.
      * @return the given position converted to grid coordinates (possibly out 
of grid bounds).
      * @throws FactoryException if no operation is found form given point CRS 
to coverage CRS.
      * @throws TransformException if the given position cannot be converted.
      */
-    final FractionalGridCoordinates.Position toGridPosition(final 
DirectPosition point)
-            throws FactoryException, TransformException
-    {
+    final double[] toGridPosition(final DirectPosition point) throws 
FactoryException, TransformException {
         /*
          * If the `inputToGrid` transform has not yet been computed or is 
outdated, compute now.
          * The result will be cached and reused as long as the `inputCRS` is 
the same.
          */
-        final CoordinateReferenceSystem crs = 
point.getCoordinateReferenceSystem();
-        if (crs != inputCRS || inputToGrid == null) {
-            setInputCRS(crs);
-        }
-        /*
-         * Transform geospatial coordinates to grid coordinates. Result is 
unconditionally stored
-         * in the `position` object, which will be copied by the caller if 
needed for public API.
-         */
-        final DirectPosition result = inputToGrid.transform(point, position);
-        if (result != position) {
-            // Should not happen, but be paranoiac.
-            final double[] coordinates = position.coordinates;
-            System.arraycopy(result.getCoordinates(), 0, coordinates, 0, 
coordinates.length);
+        setInputCRS(point.getCoordinateReferenceSystem());
+        gridCoordinates = inputToGrid.transform(point, gridCoordinates);
+        final int dimension = inputToGrid.getTargetDimensions();
+        final double[] coordinates = point.getCoordinates();
+        final double[] gridCoords = (dimension <= coordinates.length) ? 
coordinates : new double[dimension];
+        inputToGrid.transform(coordinates, 0, gridCoords, 0, 1);
+        wraparound(gridCoords, 0, 1);
+        return gridCoords;
+    }
+
+    /**
+     * If a coordinate is outside the coverage extent, check if a wraparound 
on some axes
+     * would bring the coordinates inside the extent. Coordinates are adjusted 
in-place.
+     *
+     * @param  gridCoords  the grid coordinates.
+     * @param  offset      index of the first grid coordinate value.
+     * @param  numPoints   number of points in the array.
+     */
+    private void wraparound(final double[] gridCoords, int offset, int 
numPoints) throws TransformException {
+        if (wraparoundAxes == 0) {
+            return;
         }
-        /*
-         * In most cases, the work of this method ends here. The remaining 
code in this method
-         * is for handling wraparound axes. If a coordinate is outside the 
coverage extent,
-         * check if a wraparound on some axes would bring the coordinates 
inside the extent.
-         */
-        long axes = wraparoundAxes;
-        if (axes != 0) {
-            double[] coordinates = position.coordinates;
+        double[]  twoPoints = null;   // Created only if needed.
+        final int dimension = inputToGrid.getTargetDimensions();
+        final int dimOfWrap = gridToWraparound.getTargetDimensions();
+next:   while (--numPoints >= 0) {
+            long axesToCheck = wraparoundAxes;
             long outsideAxes = 0;
-            int j = 0;
+            int  limitIndex  = 0;     // Even indices are lower values, odd 
indices are upper values.
             do {
-                final int i = Long.numberOfTrailingZeros(axes);
-                final double c = coordinates[i];
-                double border;
-                if (c < (border = wraparoundExtent[j++]) || c > (border = 
wraparoundExtent[j])) {
+                final int  axis = Long.numberOfTrailingZeros(axesToCheck);
+                final long mask = 1L << axis;
+                final double c  = gridCoords[offset + axis];
+                double limit;
+                if (c < (limit = wraparoundExtent[limitIndex]) || c > (limit = 
wraparoundExtent[limitIndex + 1])) {
                     /*
                      * Detected that the point is outside the grid extent 
along an axis where wraparound is possible.
-                     * The first time that we find such axis, expand the 
coordinates array for storing two points.
-                     * The two points will have the same coordinates, except 
on all axes where the point is outside.
-                     * On those axes, the coordinate of the first point is set 
to the closest border of the grid.
+                     * We will compute 2 points with the same coordinates 
except on axes where the point is outside.
+                     * On those axes, the coordinate of the copied point is 
set to the closest limit of the grid.
                      */
                     if (outsideAxes == 0) {
-                        final int n = coordinates.length;
-                        coordinates = Arrays.copyOf(coordinates, 2*Math.max(n, 
gridToWraparound.getTargetDimensions()));
-                        System.arraycopy(coordinates, 0, coordinates, n, n);
+                        if (twoPoints == null) {
+                            twoPoints = new double[Math.max(dimension, 
dimOfWrap * 2)];
+                        }
+                        System.arraycopy(gridCoords, offset, twoPoints, 0, 
dimension);
                     }
-                    coordinates[i] = border;
-                    outsideAxes |= (1L << i);
+                    twoPoints[axis] = limit;
+                    outsideAxes |= mask;
                 }
-                j++;    // Outside above `if (…)` statement because increment 
needs to be unconditional.
-                axes &= ~(1L << i);
-            } while (axes != 0);
-            assert wraparoundExtent.length == j : j;
+                axesToCheck &= ~mask;
+                limitIndex  += 2;
+            } while (axesToCheck != 0);
+            assert wraparoundExtent.length == limitIndex : limitIndex;
             /*
              * If a coordinate was found outside the grid, transform to a CRS 
where we can apply shift.
-             * It may be the same CRS as the coverage CRS or the source CRS, 
but not necessarily.
+             * It may be the same CRS as the coverage CRS or as the source 
CRS, but not necessarily.
              * For example if the CRS is projected, then we need to use a 
geographic intermediate CRS.
-             * In the common case where the source CRS is already geographic, 
the second point in the
-             * `coordinates` array after `transform(…)` will contain the same 
coordinates as `point`,
-             * but potentially with more dimensions.
              */
             if (outsideAxes != 0) {
-                gridToWraparound.transform(coordinates, 0, coordinates, 0, 2);
-                final int s = gridToWraparound.getTargetDimensions();
-                for (int i = periods.length; --i >= 0;) {
-                    final double period = periods[i];
+                gridToWraparound.transform(twoPoints, 0, twoPoints, dimOfWrap, 
1);  // Clipped coordinates.
+                gridToWraparound.transform(gridCoords, offset, twoPoints, 0, 
1);    // Original coordinates.
+                for (int axis = periods.length; --axis >= 0;) {
+                    final double period = periods[axis];
                     if (period > 0) {
                         /*
                          * Compute the shift that was necessary for moving the 
point inside the grid,
                          * then round that shift to an integer number of 
periods. Modify the original
                          * coordinate by applying that modified translation.
                          */
-                        final int oi = i + s;                       // Index 
of original coordinates.
-                        double shift = coordinates[i] - coordinates[oi];
+                        double shift = twoPoints[axis + dimOfWrap] - 
twoPoints[axis];
                         shift = Math.copySign(Math.ceil(Math.abs(shift) / 
period), shift) * period;
-                        coordinates[oi] += shift;
+                        twoPoints[axis] += shift;
                     }
                 }
                 /*
                  * Convert back the shifted point to grid coordinates, then 
check again if the new point
-                 * is inside the grid extent. If this is not the case, we will 
return the old position
+                 * is inside the grid extent. If this is not the case, we will 
keep the old position
                  * on the assumption that it will be less confusing to the 
user.
                  */
-                gridToWraparound.inverse().transform(coordinates, s, 
coordinates, 0, 1);
-                j = 0;
+                gridToWraparound.inverse().transform(twoPoints, 0, twoPoints, 
0, 1);
+                limitIndex = 0;
                 do {
-                    final int i = Long.numberOfTrailingZeros(outsideAxes);
-                    final double c = coordinates[i];
-                    if (c < wraparoundExtent[j++] || c > 
wraparoundExtent[j++]) {
-                        return position;
+                    final int axis = Long.numberOfTrailingZeros(outsideAxes);
+                    final double c = twoPoints[axis];
+                    if (c < wraparoundExtent[limitIndex++] || c > 
wraparoundExtent[limitIndex++]) {
+                        offset += dimension;
+                        continue next;
                     }
-                    outsideAxes &= ~(1L << i);
+                    outsideAxes &= ~(1L << axis);
                 } while (outsideAxes != 0);
                 /*
-                 * Copy shifted coordinate values to the final 
`FractionalGridCoordinates`, except the NaN values.
-                 * NaN values may exist if the given `point` has less 
dimensions than the grid geometry, in which
+                 * Copy shifted coordinate values, except the NaN values.
+                 * NaN values may exist if the given points have less 
dimensions than the grid geometry, in which
                  * case missing values have been replaced by `slice` values in 
the `target` array but not in the
                  * `coordinates` array. We want to keep the `slice` values in 
the `target` array.
                  *
@@ -541,27 +588,32 @@ class DefaultEvaluator implements GridCoverage.Evaluator {
                  * could happen that a transform resulted in NaN values in 
other dimensions. But that check would
                  * be costly, so we avoid it for now.
                  */
-                final double[] target = position.coordinates;
-                for (int i = target.length; --i >= 0;) {
-                    final double value = coordinates[i];
+                for (int i=0; i<dimension; i++) {
+                    final double value = twoPoints[i];
                     if (!Double.isNaN(value)) {
-                        target[i] = value;
+                        gridCoords[offset] = value;
                     }
+                    offset++;
                 }
             }
         }
-        return position;
     }
 
     /**
-     * Recomputes the {@link #inputToGrid} field. This method should be 
invoked when the transform
-     * has not yet been computed or became outdated because {@link #inputCRS} 
needs to be changed.
+     * Recomputes the {@link #inputToGrid} field if the <abbr>CRS</abbr> 
changed.
+     * This method should be invoked when the transform has not yet been 
computed
+     * or may became outdated because {@link #inputCRS} needs to be changed.
      *
-     * @param  crs  the new value to assign to {@link #inputCRS}.
+     * @param  crs  the new value to assign to {@link #inputCRS}. Can be 
{@code null}.
+     * @return whether the given <abbr>CRS</abbr> is different than the 
previous one.
      */
-    private void setInputCRS(final CoordinateReferenceSystem crs)
+    private boolean setInputCRS(final CoordinateReferenceSystem crs)
             throws FactoryException, NoninvertibleTransformException
     {
+        if (crs == inputCRS && inputToGrid != null) {
+            return false;
+        }
+        final GridCoverage coverage = getCoverage();
         final GridGeometry gridGeometry = coverage.getGridGeometry();
         MathTransform gridToCRS = 
gridGeometry.getGridToCRS(PixelInCell.CELL_CENTER);
         MathTransform crsToGrid = gridToCRS.inverse();
@@ -580,7 +632,7 @@ class DefaultEvaluator implements GridCoverage.Evaluator {
                  * dimensions. We try to fill missing dimensions with the help 
of the `slice` map.
                  */
                 @SuppressWarnings("LocalVariableHidesMemberVariable")
-                final Map<Integer,Long> slice = getDefaultSlice();
+                final Map<Integer, Long> slice = getDefaultSlice();
                 try {
                     CoordinateOperation op = CRS.findOperation(stepCRS, crs, 
areaOfInterest);
                     gridToCRS = MathTransforms.concatenate(gridToCRS, 
op.getMathTransform());
@@ -626,9 +678,71 @@ class DefaultEvaluator implements GridCoverage.Evaluator {
             }
         }
         // Modify fields only after everything else succeeded.
-        position    = new 
FractionalGridCoordinates.Position(crsToGrid.getTargetDimensions());
         inputCRS    = crs;
         inputToGrid = crsToGrid;
+        return true;
+    }
+
+    /**
+     * Creates an error message for a grid coordinates out of bounds.
+     * This method tries to detect the dimension of the out-of-bounds
+     * coordinate by searching for the dimension with largest error.
+     *
+     * <h4>Thread safety</h4>
+     * This method may be invoked during parallel execution of the return 
value of {@link #stream(Collection, boolean)}.
+     * Therefore, it needs to be thread-safe even if {@link 
GridCoverage.Evaluator} is documented as not thread safe.
+     *
+     * @param  point  the point which is outside the grid.
+     * @return message to provide to {@link PointOutsideCoverageException}.
+     */
+    final synchronized String pointOutsideCoverage(final DirectPosition point) 
{
+        String details = null;
+        final var buffer = new StringBuilder();
+        final GridCoverage coverage = getCoverage();
+        final GridExtent extent = coverage.gridGeometry.extent;
+        final MathTransform gridToCRS = coverage.gridGeometry.gridToCRS;
+        if (extent != null && gridToCRS != null) try {
+            int    axis     = 0;
+            long   validMin = 0;
+            long   validMax = 0;
+            double distance = 0;
+            gridCoordinates = gridToCRS.inverse().transform(point, 
gridCoordinates);
+            final int dimension = Math.min(gridCoordinates.getDimension(), 
extent.getDimension());
+            for (int i=0; i<dimension; i++) {
+                final long low  = extent.getLow(i);
+                final long high = extent.getHigh(i);
+                final double c  = gridCoordinates.getCoordinate(i);
+                double d;   // Reminder: value may be NaN.
+                if ((d = low - c) > distance || (d = c - high) > distance) {
+                    axis     = i;
+                    validMin = low;
+                    validMax = high;
+                    distance = d;
+                }
+            }
+            for (int i=0; i<dimension; i++) {
+                if (i != 0) buffer.append(' ');
+                
StringBuilders.trimFractionalPart(buffer.append(gridCoordinates.getCoordinate(i)));
+            }
+            details = 
Resources.format(Resources.Keys.GridCoordinateOutsideCoverage_4,
+                    extent.getAxisIdentification(axis, axis), validMin, 
validMax, buffer);
+        } catch (MismatchedDimensionException | TransformException e) {
+            recoverableException("pointOutsideCoverage", e);
+        }
+        /*
+         * If non-null, `message` provides details about the problem using 
grid coordinates.
+         * Also format a simpler message using the "real world" coordinates.
+         */
+        buffer.setLength(0);
+        if (coordinateFormat == null) {
+            coordinateFormat = coverage.createCoordinateFormat();
+        }
+        coordinateFormat.format(point, buffer);
+        String message = 
Resources.format(Resources.Keys.PointOutsideCoverageDomain_1, buffer);
+        if (details != null) {
+            message = message + System.lineSeparator() + details;
+        }
+        return message;
     }
 
     /**
@@ -638,7 +752,7 @@ class DefaultEvaluator implements GridCoverage.Evaluator {
      * @param  caller     the method where exception occurred.
      * @param  exception  the exception that occurred.
      */
-    private static void recoverableException(final String caller, final 
TransformException exception) {
+    private static void recoverableException(final String caller, final 
Exception exception) {
         Logging.recoverableException(GridExtent.LOGGER, 
DefaultEvaluator.class, caller, exception);
     }
 }
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/EvaluatorWrapper.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/EvaluatorWrapper.java
index 12e7e658b5..6f2e5db8df 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/EvaluatorWrapper.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/EvaluatorWrapper.java
@@ -17,6 +17,8 @@
 package org.apache.sis.coverage.grid;
 
 import java.util.Map;
+import java.util.Collection;
+import java.util.stream.Stream;
 import org.opengis.geometry.DirectPosition;
 import org.opengis.referencing.operation.TransformException;
 
@@ -84,7 +86,7 @@ abstract class EvaluatorWrapper implements 
GridCoverage.Evaluator {
      * with a different grid geometry than the coverage of the wrapped 
evaluator.
      */
     @Override
-    public Map<Integer,Long> getDefaultSlice() {
+    public Map<Integer, Long> getDefaultSlice() {
         return source.getDefaultSlice();
     }
 
@@ -94,7 +96,7 @@ abstract class EvaluatorWrapper implements 
GridCoverage.Evaluator {
      * with a different grid geometry than the coverage of the wrapped 
evaluator.
      */
     @Override
-    public void setDefaultSlice(Map<Integer,Long> slice) {
+    public void setDefaultSlice(Map<Integer, Long> slice) {
         source.setDefaultSlice(slice);
     }
 
@@ -120,4 +122,17 @@ abstract class EvaluatorWrapper implements 
GridCoverage.Evaluator {
     public double[] apply(final DirectPosition point) throws 
CannotEvaluateException {
         return source.apply(point);
     }
+
+    /**
+     * Returns a stream of double values for given points in the coverage.
+     * This method should be overridden if this evaluator is for a coverage
+     * doing some on-the-fly conversion of sample values.
+     *
+     * @param  points   positions where to evaluate the sample values.
+     * @param  parallel {@code true} for a parallel stream, or {@code false} 
for a sequential stream.
+     */
+    @Override
+    public Stream<double[]> stream(Collection<? extends DirectPosition> 
points, boolean parallel) {
+        return source.stream(points, parallel);
+    }
 }
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/FractionalGridCoordinates.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/FractionalGridCoordinates.java
index d8d23d9c0e..5eded56547 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/FractionalGridCoordinates.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/FractionalGridCoordinates.java
@@ -21,6 +21,7 @@ import java.io.Serializable;
 import org.opengis.geometry.DirectPosition;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.geometry.GeneralDirectPosition;
 import org.apache.sis.feature.internal.Resources;
 import org.apache.sis.util.StringBuilders;
 import org.apache.sis.util.internal.shared.Strings;
@@ -48,7 +49,7 @@ import org.opengis.coverage.grid.GridCoordinates;
  * {@link ArithmeticException} is thrown.</p>
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.5
+ * @version 1.6
  *
  * @see GridCoverage.Evaluator#toGridCoordinates(DirectPosition)
  *
@@ -63,7 +64,7 @@ public class FractionalGridCoordinates implements 
GridCoordinates, Serializable
     /**
      * The grid coordinates as floating-point numbers.
      */
-    final double[] coordinates;
+    private final double[] coordinates;
 
     /**
      * Creates a new grid coordinates with the given number of dimensions.
@@ -79,6 +80,16 @@ public class FractionalGridCoordinates implements 
GridCoordinates, Serializable
         coordinates = new double[dimension];
     }
 
+    /**
+     * Creates a new grid coordinates with the given coordinates.
+     * The array length is the number of dimensions.
+     *
+     * @param  coordinates  the grid coordinates.
+     */
+    FractionalGridCoordinates(final double[] coordinates) {
+        this.coordinates = coordinates;
+    }
+
     /**
      * Creates a new grid coordinates initialized to a copy of the given 
coordinates.
      *
@@ -134,13 +145,7 @@ public class FractionalGridCoordinates implements 
GridCoordinates, Serializable
     @Override
     public long getCoordinateValue(final int dimension) {
         final double value = coordinates[dimension];
-        /*
-         * 2048 is the smallest value that can be added or removed to 
Long.MIN/MAX_VALUE,
-         * as given by Math.ulp(Long.MIN_VALUE). We add this tolerance since 
the contract
-         * is to return the `long` value closest to the `double` value and we 
consider a
-         * 1 ULP error as close enough.
-         */
-        if (value >= (Long.MIN_VALUE - 2048d) && value <= (Long.MAX_VALUE + 
2048d)) {
+        if (value >= ValuesAtPointIterator.DOMAIN_MINIMUM && value <= 
ValuesAtPointIterator.DOMAIN_MAXIMUM) {
             return Math.round(value);
         }
         throw new 
ArithmeticException(Resources.format(Resources.Keys.UnconvertibleGridCoordinate_2,
 "long", value));
@@ -179,7 +184,7 @@ public class FractionalGridCoordinates implements 
GridCoordinates, Serializable
     /**
      * Creates a new grid extent around this grid coordinates. The returned 
extent will have the same number
      * of dimensions than this grid coordinates. For each dimension 
<var>i</var> the following relationships
-     * will hold:
+     * will hold (where {@code extent} is the return value):
      *
      * <ol>
      *   <li>If <code>extent.{@linkplain GridExtent#getSize(int) 
getSize}(i)</code> ≥ 2 and no shift (see below) then:<ul>
@@ -228,21 +233,11 @@ public class FractionalGridCoordinates implements 
GridCoordinates, Serializable
      * @throws PointOutsideCoverageException if the grid coordinates (rounded 
to nearest integers) are outside the
      *         given bounds.
      * @return a grid extent of the given size (if possible) containing those 
grid coordinates.
-     */
-    public GridExtent toExtent(final GridExtent bounds, final long... size) {
-        return toExtent(bounds, size, false);
-    }
-
-    /**
-     * Implementation of {@link #toExtent(GridExtent, long...)} with the 
option to replace
-     * {@link PointOutsideCoverageException} by null return value.
      *
-     * @param  bounds         if the coordinates shall be contained inside a 
grid, that grid extent. Otherwise {@code null}.
-     * @param  size           the desired extent sizes as strictly positive 
numbers, or 0 for automatic sizes (1 or 2).
-     * @param  nullIfOutside  whether to return {@code null} instead of 
throwing an exception if given point is outside coverage bounds.
-     * @return a grid extent containing grid coordinates, or {@code null} if 
outside and {@code nullIfOutside} is {@code true}.
+     * @deprecated Not used anymore because this method leads to a 
multiplication of very small read operations.
      */
-    final GridExtent toExtent(final GridExtent bounds, final long[] size, 
final boolean nullIfOutside) {
+    @Deprecated(since = "1.6", forRemoval = true)
+    public GridExtent toExtent(final GridExtent bounds, final long... size) {
         final int dimension = coordinates.length;
         if (bounds != null) {
             final int bd = bounds.getDimension();
@@ -303,10 +298,7 @@ public class FractionalGridCoordinates implements 
GridCoordinates, Serializable
                 final long validMin = bounds.getLow(i);
                 final long validMax = bounds.getHigh(i);
                 if (nearest > validMax || nearest < validMin) {
-                    if (nullIfOutside) {
-                        return null;
-                    }
-                    final StringBuilder b = new StringBuilder();
+                    final var b = new StringBuilder();
                     writeCoordinates(b);
                     throw new PointOutsideCoverageException(
                             
Resources.format(Resources.Keys.GridCoordinateOutsideCoverage_4,
@@ -340,6 +332,7 @@ public class FractionalGridCoordinates implements 
GridCoordinates, Serializable
 
     /**
      * Returns the grid coordinates converted to a geospatial position using 
the given transform.
+     * This is the reverse of {@link 
GridCoverage.Evaluator#toGridCoordinates(DirectPosition)}.
      * The {@code gridToCRS} argument is typically {@link 
GridGeometry#getGridToCRS(PixelInCell)}
      * with {@link PixelInCell#CELL_CENTER}.
      *
@@ -350,115 +343,19 @@ public class FractionalGridCoordinates implements 
GridCoordinates, Serializable
      * @see GridCoverage.Evaluator#toGridCoordinates(DirectPosition)
      */
     public DirectPosition toPosition(final MathTransform gridToCRS) throws 
TransformException {
-        return gridToCRS.transform(new Position(this), null);
-    }
-
-    /**
-     * A grid coordinates viewed as a {@link DirectPosition}. This class is 
used only for coordinate transformation.
-     * We do not want to make this class public in order to avoid the abuse of 
{@link DirectPosition} as a storage
-     * of grid coordinates.
-     *
-     * <p>Note this this class does not comply with the contract documented in 
{@link DirectPosition#equals(Object)}
-     * and {@link DirectPosition#hashCode()} javadoc. This is another reason 
for not making this class public.</p>
-     */
-    static final class Position extends FractionalGridCoordinates implements 
DirectPosition {
-        /**
-         * For cross-version compatibility.
-         */
-        private static final long serialVersionUID = -7804151694395153401L;
-
-        /**
-         * Creates a new position of the given number of dimensions.
-         */
-        Position(final int dimension) {
-            super(dimension);
-        }
-
-        /**
-         * Creates a new position initialized to a copy of the given 
coordinates.
-         */
-        Position(final FractionalGridCoordinates other) {
-            super(other);
-        }
-
-        /**
-         * Returns all coordinate values.
-         */
-        @Override
-        public double[] getCoordinates() {
-            return coordinates.clone();
-        }
-
-        /**
-         * Returns the coordinate value at the given dimension.
-         */
-        @Override
-        public double getCoordinate(int dimension) {
-            return coordinates[dimension];
-        }
-
-        /**
-         * Sets the coordinate value at the given dimension.
-         */
-        @Override
-        public void setCoordinate(final int dimension, final double value) {
-            coordinates[dimension] = value;
-        }
-
-        /**
-         * Returns the grid coordinates converted to a geospatial position 
using the given transform.
-         */
-        @Override
-        public DirectPosition toPosition(final MathTransform gridToCRS) throws 
TransformException {
-            return gridToCRS.transform(this, null);
-        }
+        final var position = new 
GeneralDirectPosition(gridToCRS.getTargetDimensions());
+        gridToCRS.transform(coordinates, 0, position.coordinates, 0, 1);
+        return position;
     }
 
     /**
-     * Creates an error message for a grid coordinates out of bounds.
-     * This method tries to detect the dimension of the out-of-bounds
-     * coordinate by searching for the dimension with largest error.
+     * Returns a string representation of this grid coordinates for debugging 
purposes.
      *
-     * @param  bounds  the expected bounds, or {@code null} if unknown.
-     * @return message to provide to {@link PointOutsideCoverageException},
-     *         or {@code null} if the given bounds were null.
-     */
-    final String pointOutsideCoverage(final GridExtent bounds) {
-        if (bounds == null) {
-            return null;
-        }
-        int    axis     = 0;
-        long   validMin = 0;
-        long   validMax = 0;
-        double distance = 0;
-        for (int i = bounds.getDimension(); --i >= 0;) {
-            final long low  = bounds.getLow(i);
-            final long high = bounds.getHigh(i);
-            final double c  = coordinates[i];
-            double d = low - c;
-            if (!(d > distance)) {              // Use '!' for entering in 
this block if `d` is NaN.
-                d = c - high;
-                if (!(d > distance)) {          // Use '!' for skipping this 
coordinate if `d` is NaN.
-                    continue;
-                }
-            }
-            axis     = i;
-            validMin = low;
-            validMax = high;
-            distance = d;
-        }
-        final StringBuilder b = new StringBuilder();
-        writeCoordinates(b);
-        return Resources.format(Resources.Keys.GridCoordinateOutsideCoverage_4,
-                bounds.getAxisIdentification(axis, axis), validMin, validMax, 
b.toString());
-    }
-
-    /**
-     * Returns a string representation of this grid coordinates for debugging 
purpose.
+     * @return a string representation for debugging purposes.
      */
     @Override
     public String toString() {
-        final StringBuilder buffer = new StringBuilder("GridCoordinates[");
+        final var buffer = new StringBuilder("GridCoordinates[");
         writeCoordinates(buffer);
         return buffer.append(']').toString();
     }
@@ -475,6 +372,8 @@ public class FractionalGridCoordinates implements 
GridCoordinates, Serializable
 
     /**
      * Returns a hash code value for this grid coordinates.
+     *
+     * @return a hash code value.
      */
     @Override
     public int hashCode() {
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverage.java
index d58dd22075..e28a389118 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverage.java
@@ -36,9 +36,10 @@ import org.apache.sis.measure.NumberRange;
 import org.apache.sis.coverage.BandedCoverage;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.coverage.SubspaceNotSpecifiedException;
+import org.apache.sis.coverage.internal.shared.SampleDimensions;
 import org.apache.sis.image.DataType;
 import org.apache.sis.image.ImageProcessor;
-import org.apache.sis.coverage.internal.shared.SampleDimensions;
+import org.apache.sis.geometry.CoordinateFormat;
 import org.apache.sis.util.collection.DefaultTreeTable;
 import org.apache.sis.util.collection.TableColumn;
 import org.apache.sis.util.collection.TreeTable;
@@ -57,7 +58,7 @@ import org.opengis.coverage.CannotEvaluateException;
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
  * @author  Johann Sorel (Geomatys)
- * @version 1.4
+ * @version 1.6
  * @since   1.0
  */
 public abstract class GridCoverage extends BandedCoverage {
@@ -343,7 +344,11 @@ public abstract class GridCoverage extends BandedCoverage {
      */
     @Override
     public Evaluator evaluator() {
-        return new DefaultEvaluator(this);
+        return new DefaultEvaluator() {
+            @Override public GridCoverage getCoverage() {
+                return GridCoverage.this;
+            }
+        };
     }
 
     /**
@@ -358,7 +363,7 @@ public abstract class GridCoverage extends BandedCoverage {
      *
      * @author  Johann Sorel (Geomatys)
      * @author  Martin Desruisseaux (Geomatys)
-     * @version 1.3
+     * @version 1.6
      *
      * @see GridCoverage#evaluator()
      *
@@ -394,7 +399,7 @@ public abstract class GridCoverage extends BandedCoverage {
          *
          * @return the default slice where to perform evaluation, or an empty 
map if unspecified.
          */
-        Map<Integer,Long> getDefaultSlice();
+        Map<Integer, Long> getDefaultSlice();
 
         /**
          * Sets the default slice where to perform evaluation when the points 
do not have enough dimensions.
@@ -406,7 +411,7 @@ public abstract class GridCoverage extends BandedCoverage {
          *
          * @see GridExtent#getSliceCoordinates()
          */
-        void setDefaultSlice(Map<Integer,Long> slice);
+        void setDefaultSlice(Map<Integer, Long> slice);
 
         /**
          * Converts the specified geospatial position to grid coordinates. If 
the given position is associated to
@@ -511,6 +516,27 @@ public abstract class GridCoverage extends BandedCoverage {
      */
     public abstract RenderedImage render(GridExtent sliceExtent) throws 
CannotEvaluateException;
 
+    /**
+     * Creates a new object for formatting spatial-temporal coordinates in the 
domain of this grid coverage.
+     * The {@linkplain CoordinateFormat#setDefaultCRS default 
<abbr>CRS</abbr>} of the format is set to the
+     * <abbr>CRS</abbr> of this coverage, and the {@linkplain 
CoordinateFormat#setPrecisions precision} of
+     * each coordinate is set to the {@linkplain 
GridGeometry#getResolution(boolean) grid resolution}.
+     *
+     * @return a coordinate format configured for the domain of this grid 
coverage.
+     *
+     * @since 1.6
+     */
+    public CoordinateFormat createCoordinateFormat() {
+        final var cf = new CoordinateFormat();
+        if (gridGeometry.isDefined(GridGeometry.CRS)) {
+            cf.setDefaultCRS(gridGeometry.getCoordinateReferenceSystem());
+        }
+        if (gridGeometry.isDefined(GridGeometry.RESOLUTION)) {
+            cf.setPrecisions(gridGeometry.getResolution(true));
+        }
+        return cf;
+    }
+
     /**
      * Returns a string representation of this grid coverage for debugging 
purpose.
      * The returned string is implementation dependent and may change in any 
future version.
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverage2D.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverage2D.java
index 5e4188a2f1..f746d99629 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverage2D.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverage2D.java
@@ -33,6 +33,7 @@ import static java.lang.Math.min;
 import static java.lang.Math.addExact;
 import static java.lang.Math.subtractExact;
 import static java.lang.Math.toIntExact;
+import static java.lang.Math.round;
 import org.opengis.metadata.spatial.DimensionNameType;
 import org.opengis.util.NameFactory;
 import org.opengis.util.InternationalString;
@@ -91,7 +92,7 @@ import org.opengis.coverage.PointOutsideCoverageException;
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Johann Sorel (Geomatys)
  * @author  Alexis Manin (Geomatys)
- * @version 1.5
+ * @version 1.6
  * @since   1.1
  */
 public class GridCoverage2D extends GridCoverage {
@@ -503,7 +504,14 @@ public class GridCoverage2D extends GridCoverage {
          * Creates a new evaluator for the enclosing coverage.
          */
         PixelAccessor() {
-            super(GridCoverage2D.this);
+        }
+
+        /**
+         * Returns the coverage from which this evaluator is fetching sample 
values.
+         */
+        @Override
+        public final GridCoverage getCoverage() {
+            return GridCoverage2D.this;
         }
 
         /**
@@ -513,25 +521,33 @@ public class GridCoverage2D extends GridCoverage {
          */
         @Override
         public double[] apply(final DirectPosition point) throws 
CannotEvaluateException {
+            RuntimeException cause = null;
             try {
-                final FractionalGridCoordinates gc = toGridPosition(point);
-                try {
-                    final int x = 
toIntExact(addExact(gc.getCoordinateValue(xDimension), gridToImageX));
-                    final int y = 
toIntExact(addExact(gc.getCoordinateValue(yDimension), gridToImageY));
-                    return evaluate(data, x, y);
-                } catch (ArithmeticException | IndexOutOfBoundsException | 
DisjointExtentException ex) {
-                    if (isNullIfOutside()) {
-                        return null;
+                final double[] gridCoords = toGridPosition(point);
+                final double cx = gridCoords[xDimension];
+                final double cy = gridCoords[yDimension];
+                if (cx >= ValuesAtPointIterator.DOMAIN_MINIMUM && cx <= 
ValuesAtPointIterator.DOMAIN_MAXIMUM &&
+                    cy >= ValuesAtPointIterator.DOMAIN_MINIMUM && cy <= 
ValuesAtPointIterator.DOMAIN_MAXIMUM)
+                {
+                    try {
+                        final int  x = toIntExact(addExact(round(cx), 
gridToImageX));
+                        final int  y = toIntExact(addExact(round(cy), 
gridToImageY));
+                        final int tx = ImageUtilities.pixelToTileX(data, x);
+                        final int ty = ImageUtilities.pixelToTileY(data, y);
+                        return values = data.getTile(tx, ty).getPixel(x, y, 
values);
+                    } catch (ArithmeticException | IndexOutOfBoundsException 
e) {
+                        cause = e;
                     }
-                    throw (PointOutsideCoverageException) new 
PointOutsideCoverageException(
-                            gc.pointOutsideCoverage(gridGeometry.extent), 
point).initCause(ex);
                 }
-            } catch (PointOutsideCoverageException ex) {
-                ex.setOffendingLocation(point);
-                throw ex;
             } catch (RuntimeException | FactoryException | TransformException 
ex) {
                 throw new CannotEvaluateException(ex.getMessage(), ex);
             }
+            if (isNullIfOutside()) {
+                return null;
+            }
+            var ex = new 
PointOutsideCoverageException(pointOutsideCoverage(point), point);
+            ex.initCause(cause);
+            throw ex;
         }
     }
 
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/ValuesAtPointIterator.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/ValuesAtPointIterator.java
new file mode 100644
index 0000000000..44f31eb5b5
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/ValuesAtPointIterator.java
@@ -0,0 +1,809 @@
+/*
+ * 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.coverage.grid;
+
+import java.util.Arrays;
+import java.util.BitSet;
+import java.util.Spliterator;
+import java.util.function.Consumer;
+import java.util.function.IntFunction;
+import java.awt.image.Raster;
+import java.awt.image.RenderedImage;
+import org.apache.sis.util.internal.shared.Strings;
+import static org.apache.sis.coverage.grid.GridCoverage.BIDIMENSIONAL;
+
+// Specific to the geoapi-3.1 and geoapi-4.0 branches:
+import org.opengis.coverage.PointOutsideCoverageException;
+
+
+/**
+ * An iterator over the sample values evaluated at given coordinates.
+ * This iterator has many layers. The first layer iterates over slices.
+ * Then the second layer iterates over images, and a third layer over tiles.
+ * A last layer returns null values for points that are outside the coverage.
+ *
+ * <h2>Limitations</h2>
+ * Current implementation performs nearest-neighbor sampling only.
+ * A future version may provide interpolations.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ *
+ * @see <a href="https://issues.apache.org/jira/browse/SIS-576";>SIS-576</a>
+ */
+abstract class ValuesAtPointIterator implements Spliterator<double[]> {
+    /**
+     * The dimensions of grid dimensions taken as <var>x</var> and 
<var>y</var> image axes.
+     * Static constants for now, may become configurable fields in the future.
+     */
+    private static final int X_DIMENSION = 0, Y_DIMENSION = 1;
+
+    /**
+     * A mask for deciding when a slice width or height become too large for a 
single read operation.
+     * This is both for avoiding 32-bits integer overflow (keeping in mind 
that the maximal image size
+     * may be the square of this size) and for reducing the risk of reading 
unnecessary tiles between
+     * points separated by potentially large empty spaces.
+     */
+    private static final long MAXIMUM_SLICE_SIZE_MASK = ~0x7FFF;
+
+    /**
+     * The minimum and maximum value that we accept for a {@code double} value 
before rounding to {@code long}.
+     * A margin of 2048 is added, which is the smallest change that can be 
applied on {@code Long.MIN/MAX_VALUE}
+     * as given by {@code Math.ulp(Long.MIN_VALUE)}. We add this tolerance 
because this class aim to return the
+     * {@code long} grid coordinate value closest to the {@code double} value 
and we consider an 1 <abbr>ULP</abbr>
+     * error as close enough.
+     */
+    static final double DOMAIN_MINIMUM = Long.MIN_VALUE - 2048d,
+                        DOMAIN_MAXIMUM = Long.MAX_VALUE + 2048d;
+
+    /**
+     * Grid coordinates of points to evaluate, rounded to nearest integers.
+     * This array may be shared by many iterators and should not be modified.
+     * This array is null in the particular case of the {@link Null} subclass.
+     */
+    protected final long[] nearestXY;
+
+    /**
+     * Index of the first grid coordinate of the next point to evaluate.
+     * This is incremented by {@value GridCoverage2D#BIDIMENSIONAL} after each 
evaluation.
+     */
+    protected int indexOfXY;
+
+    /**
+     * Index after the last coordinate of the last point to evaluate.
+     */
+    protected final int limitOfXY;
+
+    /**
+     * Supplier of the message of the exception to throw if a point is outside 
the coverage bounds.
+     * The function argument is the index of the coordinate tuple which is 
outside the grid coverage.
+     * If this supplier is {@code null}, then null arrays will be returned 
instead of throwing an exception.
+     */
+    protected final IntFunction<String> ifOutside;
+
+    /**
+     * Creates a new iterator which will traverses a subset of the given grid 
coordinates.
+     * Subclasses should initialize {@link #indexOfXY} to the index of the 
first valid coordinate.
+     *
+     * @param nearestXY  grid coordinates of points to evaluate, or {@code 
null}.
+     * @param limitOfXY  index after the last coordinate of the last point to 
evaluate.
+     * @param ifOutside  supplier of exception message for points outside the 
coverage bounds, or {@code null}.
+     */
+    protected ValuesAtPointIterator(final long[] nearestXY, final int 
limitOfXY, final IntFunction<String> ifOutside) {
+        this.nearestXY = nearestXY;
+        this.limitOfXY = limitOfXY;
+        this.ifOutside = ifOutside;
+    }
+
+    /**
+     * Creates a new iterator with the given grid coordinates. The {@code 
gridCoords} array is the result
+     * of applying the inverse of the "grid to CRS" transform on user-supplied 
"real world" coordinates,
+     * then resolving wraparounds. This constructor rounds these grid 
coordinates to nearest integers.
+     *
+     * @param  coverage    the coverage which will be evaluated.
+     * @param  gridCoords  the grid coordinates as floating-point numbers.
+     * @param  numPoints   number of points in the array.
+     * @param  ifOutside   supplier of exception message for points outside 
the coverage bounds, or {@code null}.
+     * @return the iterator.
+     */
+    static ValuesAtPointIterator create(final GridCoverage coverage, final 
double[] gridCoords, int numPoints,
+                                        final IntFunction<String> ifOutside)
+    {
+        return Slices.create(coverage, gridCoords, 0, numPoints, 
ifOutside).shortcut();
+    }
+
+    /**
+     * Returns the number of remaining points to evaluate.
+     */
+    @Override
+    public final long estimateSize() {
+        return (limitOfXY - indexOfXY) / BIDIMENSIONAL;
+    }
+
+    /**
+     * Returns the number of remaining points to evaluate.
+     */
+    @Override
+    public final long getExactSizeIfKnown() {
+        return estimateSize();
+    }
+
+    /**
+     * Returns the characteristics of this iterator.
+     * The iterator is {@link #SIZED} and {@link #ORDERED}.
+     * Whether it is {@link #NONNULL} depends on whether there is an {@link 
#ifOutside} supplier.
+     */
+    @Override
+    public final int characteristics() {
+        return (ifOutside == null) ? (SIZED | SUBSIZED | ORDERED) : (SIZED | 
SUBSIZED | ORDERED | NONNULL);
+    }
+
+    /**
+     * Returns a string representation of this iterator for debugging purposes.
+     */
+    @Override
+    public String toString() {
+        return Strings.toString(getClass(), "estimateSize", estimateSize(), 
"nullIfOutside", ifOutside == null);
+    }
+
+
+
+
+    /**
+     * Base class of iterators that contains other iterators.
+     * A {@link Slices} is a group of two-dimensional images.
+     * An {@link Image} is a group of tiles.
+     */
+    private static abstract class Group extends ValuesAtPointIterator {
+        /**
+         * Index of the first grid coordinate of the first point which is 
inside each child.
+         * Each value in this array is the index of the first valid element of 
{@link #nearestXY}.
+         * Values in this array shall be in strictly increasing order.
+         */
+        private final int[] firstGridCoordOfChildren;
+
+        /**
+         * Index of the next child in which to evaluate a sequence of points.
+         * This is an index in the {@link #firstGridCoordOfChildren} array.
+         */
+        private int nextChildIndex;
+
+        /**
+         * Maximal value (exclusive) of {@link #nextChildIndex}.
+         */
+        private final int upperChildIndex;
+
+        /**
+         * Iterator over pixel values of the current child.
+         * This is an instance of {@link Image}, {@link Tile} or {@link Null}.
+         * This field become {@code null} when the iteration is finished.
+         */
+        protected ValuesAtPointIterator current;
+
+        /**
+         * Creates a new iterator over a subset of the given iterator.
+         * This iterator will evaluate a prefix of the sequence of points of 
the given iterator.
+         * Caller should change the parent iterator to a suffix after this 
constructor returned.
+         * This constructor is for {@link #trySplit()} implementation only.
+         *
+         * @param  parent           the iterator from which to create a prefix.
+         * @param  upperChildIndex  index after the last child that this 
iterator can use.
+         */
+        protected Group(final Group parent, final int upperChildIndex) {
+            super(parent.nearestXY, 
parent.firstGridCoordOfChildren[upperChildIndex], parent.ifOutside);
+            this.indexOfXY                = parent.indexOfXY;
+            this.firstGridCoordOfChildren = parent.firstGridCoordOfChildren;
+            this.current                  = parent.current;
+            this.nextChildIndex           = parent.nextChildIndex;
+            this.upperChildIndex          = upperChildIndex;
+        }
+
+        /**
+         * Creates a new iterator which will traverse a subset of the given 
grid coordinates.
+         *
+         * @param nearestXY  grid coordinates of points to evaluate, or {@code 
null}.
+         * @param limitOfXY  index after the last coordinate of the last point 
to evaluate.
+         * @param ifOutside  supplier of exception message for points outside 
the coverage bounds, or {@code null}.
+         */
+        protected Group(final long[] nearestXY,
+                        final int    limitOfXY,
+                        final int[]  firstGridCoordOfChildren,
+                        final int    upperChildIndex,
+                        final IntFunction<String> ifOutside)
+        {
+            super(nearestXY, limitOfXY, ifOutside);
+            this.firstGridCoordOfChildren = firstGridCoordOfChildren;
+            this.upperChildIndex = upperChildIndex;
+        }
+
+        /**
+         * If this iterator can be replaced by a more direct one, returns the 
more direct iterator.
+         * Otherwise returns {@code this} if there are more points to traverse.
+         * May return {@code null} if the iteration is finished.
+         */
+        protected final ValuesAtPointIterator shortcut() {
+            return (nextChildIndex >= upperChildIndex) ? current : this;
+        }
+
+        /**
+         * Returns an iterator over the points to evaluate in the next child, 
or {@code null} if none.
+         * Invoking this method may cause the loading or the computation of a 
tile.
+         *
+         * @throws PointOutsideCoverageException if a point is outside the 
grid coverage
+         *         and the iterator is not configured for returning {@code 
null} in such case.
+         */
+        protected final ValuesAtPointIterator nextChild() {
+            int childIndex = nextChildIndex;
+            if (childIndex < upperChildIndex) {
+                indexOfXY = firstGridCoordOfChildren[childIndex];
+                int stopAtXY = (++nextChildIndex < upperChildIndex) ? 
firstGridCoordOfChildren[nextChildIndex] : limitOfXY;
+                return createChild(childIndex, stopAtXY);
+            }
+            return null;
+        }
+
+        /**
+         * Creates a child which will iterate over the {@link #nearestXY} 
coordinates
+         * from {@link #indexOfXY} inclusive to {@code stopAtXY} exclusive.
+         * Invoking this method may cause the loading or the computation of a 
tile.
+         *
+         * @param  childIndex  index of the child to create.
+         * @param  stopAtXY    index after the last {@link #nearestXY} value 
to use.
+         * @throws PointOutsideCoverageException if a point is outside the 
grid coverage
+         *         and the iterator is not configured for returning {@code 
null} in such case.
+         */
+        abstract ValuesAtPointIterator createChild(int childIndex, int 
stopAtXY);
+
+        /**
+         * Creates a new iterator covering a prefix of the points.
+         *
+         * @param  stopAtXY  index after the last grid coordinates of the last 
point covered by the prefix
+         * @return a new iterator covering the specified prefix of the points.
+         */
+        abstract Group createPrefix(int stopAtXY);
+
+        /**
+         * Tries to split this iterator. If successful, the returned iterator 
is a prefix
+         * of the sequence of points to evaluate, and this iterator become the 
remaining.
+         *
+         * @return an iterator covering a prefix of the points, or {@code 
null} if this iterator cannot be split.
+         */
+        @Override
+        public final Spliterator<double[]> trySplit() {
+            if (current == null) {
+                return null;
+            }
+            // Find the middle of the remaining number of points to evaluate.
+            int i = nextChildIndex + (upperChildIndex - nextChildIndex) / 2;
+            i = Arrays.binarySearch(firstGridCoordOfChildren, nextChildIndex, 
upperChildIndex, i);
+            if (i < 0) i = ~i;   // Tild operator, not minus. It gives the 
insertion point.
+            if (i > nextChildIndex && i < upperChildIndex) {
+                final var prefix = createPrefix(i);
+                nextChildIndex = prefix.upperChildIndex;
+                indexOfXY = prefix.limitOfXY;
+                current = nextChild();
+                return prefix;
+            } else {
+                return current.trySplit();    // After this call, `current` 
become a suffix.
+            }
+        }
+
+        /**
+         * Sends the sample values to the caller if the iteration is not 
finished.
+         */
+        @Override
+        public final boolean tryAdvance(final Consumer<? super double[]> 
action) {
+            while (current != null) {
+                if (current.tryAdvance(action)) {
+                    return true;
+                }
+                current = nextChild();
+            }
+            return false;
+        }
+
+        /**
+         * Sends remaining sample values to the caller until the end of the 
iteration.
+         */
+        @Override
+        public final void forEachRemaining(final Consumer<? super double[]> 
action) {
+            while (current != null) {
+                current.forEachRemaining(action);
+                current = nextChild();
+            }
+        }
+    }
+
+
+
+
+    /**
+     * An iterator over the sample values of points contained in different 
slices of a <var>n</var>-dimensional cube.
+     * This class searches for sequences of consecutive points that are 
located on the same two-dimensional slice,
+     * then creates an {@link Image} iterator for each slice.
+     */
+    private static final class Slices extends Group {
+        /**
+         * The coverage from which to evaluate the pixel values.
+         */
+        private final GridCoverage coverage;
+
+        /**
+         * Indices of images in which sequences of points will be evaluated.
+         * Some array elements may be {@code null} if a sequence of points is
+         * outside the grid coverage or has NaN coordinate values.
+         */
+        private final GridExtent[] imageExtents;
+
+        /**
+         * Creates a new iterator over a subset of the given iterator.
+         * This iterator will evaluate a prefix of the sequence of points of 
the given iterator.
+         * Caller should change the parent iterator to a suffix after this 
constructor returned.
+         * This constructor is for {@link #trySplit()} implementation only.
+         *
+         * @param  parent  the iterator from which to create a prefix.
+         * @param  stopAt  index after the last image that this iterator can 
use.
+         */
+        private Slices(final Slices parent, final int stopAt) {
+            super(parent, stopAt);
+            this.coverage     = parent.coverage;
+            this.imageExtents = parent.imageExtents;
+        }
+
+        /**
+         * Workaround for RFE #4093999 ("Relax constraint on placement of 
this()/super() call in constructors").
+         */
+        private Slices(final GridCoverage coverage,
+                       final long[]       nearestXY,
+                       final int          limitOfXY,
+                       final int[]        firstGridCoordOfChildren,
+                       final int          upperChildIndex,
+                       final GridExtent[] imageExtents,
+                       final IntFunction<String> ifOutside)
+        {
+            super(nearestXY, limitOfXY, firstGridCoordOfChildren, 
upperChildIndex, ifOutside);
+            this.coverage = coverage;
+            this.imageExtents = imageExtents;
+            current = nextChild();
+        }
+
+        /**
+         * Creates a new iterator with the given grid coordinates. The {@code 
gridCoords} array is the result
+         * of applying the inverse of the "grid to CRS" transform on 
user-supplied "real world" coordinates,
+         * then resolving wraparounds. This constructor rounds these grid 
coordinates to nearest integers.
+         *
+         * @todo Retrofit in above constructor after RFE #4093999.
+         *
+         * @param coverage          the coverage which will be evaluated.
+         * @param gridCoords        the grid coordinates as floating-point 
numbers.
+         * @param gridCoordsOffset  index of the first grid coordinate value.
+         * @param numPoints         number of points in the array.
+         * @param ifOutside         supplier of exception message for points 
outside the coverage bounds, or {@code null}.
+         */
+        static Slices create(final GridCoverage coverage, final double[] 
gridCoords, int gridCoordsOffset, int numPoints,
+                             final IntFunction<String> ifOutside)
+        {
+            final int dimension  = coverage.gridGeometry.getDimension();
+            final var extentLow  = new long[dimension];
+            final var extentHigh = new long[dimension];
+            final var nearestXY  = new long[numPoints * BIDIMENSIONAL];
+            var imageExtents     = new GridExtent[1];   // Length is 1 in the 
common case of two-dimensional data.
+            var imageFirstCoords = new int[1];
+            int imageCount       = 0;
+            int indexOfXY        = 0;
+            int limitOfXY        = 0;
+            while (--numPoints >= 0) {
+                boolean wasOutside = false;
+                for (int i=0; i<dimension; i++) {
+                    double c = gridCoords[gridCoordsOffset + i];
+                    wasOutside |= !(c >= DOMAIN_MINIMUM && c <= 
DOMAIN_MAXIMUM);    // Use `!` for catching NaN.
+                    extentLow[i] = Math.round(c);
+                }
+                if (wasOutside && ifOutside != null) {
+                    throw new 
PointOutsideCoverageException(ifOutside.apply(indexOfXY / BIDIMENSIONAL));
+                }
+                long lowerX, upperX, lowerY, upperY;
+                nearestXY[limitOfXY++] = lowerX = upperX = 
extentLow[X_DIMENSION];
+                nearestXY[limitOfXY++] = lowerY = upperY = 
extentLow[Y_DIMENSION];
+                gridCoordsOffset += dimension;
+changeOfSlice:  while (numPoints != 0) {
+                    boolean isValid = true;
+                    // Note: following code assumes that X_DIMENSION and 
Y_DIMENSION are the two first dimensions.
+                    for (int i = BIDIMENSIONAL; i < dimension; i++) {
+                        double c = gridCoords[gridCoordsOffset + i];
+                        isValid &= (c >= DOMAIN_MINIMUM && c <= 
DOMAIN_MAXIMUM);
+                        if (Math.round(c) != extentLow[i]) {
+                            break changeOfSlice;
+                        }
+                    }
+                    final double cx = gridCoords[gridCoordsOffset + 
X_DIMENSION];
+                    final double cy = gridCoords[gridCoordsOffset + 
Y_DIMENSION];
+                    isValid &= (cx >= DOMAIN_MINIMUM && cx <= DOMAIN_MAXIMUM) 
&&
+                               (cy >= DOMAIN_MINIMUM && cy <= DOMAIN_MAXIMUM);
+                    if (isValid == wasOutside) {
+                        break;
+                    }
+                    final long x = Math.round(cx);
+                    final long y = Math.round(cy);
+                    final long xmin = Math.min(x, lowerX);
+                    final long ymin = Math.min(y, lowerY);
+                    final long xmax = Math.max(x, upperX);
+                    final long ymax = Math.max(y, upperY);
+                    if ((((xmax - xmin) | (ymax - ymin)) & 
MAXIMUM_SLICE_SIZE_MASK) != 0) {
+                        break;
+                    }
+                    lowerX = xmin;
+                    lowerY = ymin;
+                    upperX = xmax;
+                    upperY = ymax;
+                    nearestXY[limitOfXY++] = x;
+                    nearestXY[limitOfXY++] = y;
+                    gridCoordsOffset += dimension;
+                    numPoints--;
+                }
+                /*
+                 * Reached the end of a sequence of points in the same image. 
We will read later a rectangular
+                 * region containing all these points. We do not try to merge 
with an hypothetic reuse of this
+                 * image later in the iteration, because it would increase the 
risk that we load too much data
+                 * if the points are spread in distant regions. Instead, we 
rely on `ComputedImage` cache.
+                 */
+                if (imageCount >= imageExtents.length) {
+                    imageExtents = Arrays.copyOf(imageExtents, 
imageExtents.length * 2);
+                    imageFirstCoords = Arrays.copyOf(imageFirstCoords, 
imageFirstCoords.length * 2);
+                }
+                if (wasOutside) {
+                    // Leave `imageExtents[imageCount]` to null.
+                    imageFirstCoords[imageCount++] = indexOfXY;
+                    indexOfXY = limitOfXY;
+                    continue;
+                }
+                System.arraycopy(extentLow, 0, extentHigh, 0, 
extentHigh.length);
+                extentLow [X_DIMENSION] = lowerX;
+                extentLow [Y_DIMENSION] = lowerY;
+                extentHigh[X_DIMENSION] = upperX;
+                extentHigh[Y_DIMENSION] = upperY;
+                imageExtents[imageCount] = new GridExtent(null, extentLow, 
extentHigh, true);
+                imageFirstCoords[imageCount++] = indexOfXY;
+
+                // Make grid coordinates relative to the region that we 
requested.
+                while (indexOfXY < limitOfXY) {
+                    nearestXY[indexOfXY++] -= lowerX;
+                    nearestXY[indexOfXY++] -= lowerY;
+                }
+            }
+            return new Slices(coverage, nearestXY, limitOfXY, 
imageFirstCoords, imageCount, imageExtents, ifOutside);
+        }
+
+        /**
+         * Creates a child which will iterate over the {@link #nearestXY} 
coordinates
+         * from {@link #indexOfXY} inclusive to {@code stopAtXY} exclusive.
+         */
+        @Override
+        final ValuesAtPointIterator createChild(final int childIndex, final 
int stopAtXY) {
+            final GridExtent extent = imageExtents[childIndex];
+            if (extent != null) try {
+                return Image.create(this, stopAtXY, 
coverage.render(extent)).shortcut();
+            } catch (DisjointExtentException cause) {
+                if (ifOutside != null) {
+                    var e = new 
PointOutsideCoverageException(ifOutside.apply(indexOfXY / BIDIMENSIONAL));
+                    e.initCause(cause);
+                    throw e;
+                }
+            }
+            return new Null(indexOfXY, stopAtXY);
+        }
+
+        /**
+         * Creates a new iterator covering a prefix of the points.
+         * This is invoked by {@link #trySplit()} implementation.
+         */
+        @Override
+        final Group createPrefix(int stopAtXY) {
+            return new Slices(this, stopAtXY);
+        }
+    }
+
+
+
+
+    /**
+     * An iterator over the sample values of point contained in the same image.
+     * For performance reasons, this method recycles the same array in calls to
+     * {@link Consumer#accept(Object)}.
+     */
+    private static final class Image extends Group {
+        /**
+         * The image from which to evaluate the pixel values.
+         */
+        private final RenderedImage image;
+
+        /**
+         * Indices of tiles in which sequences of points will be evaluated.
+         * The values in this array are (<var>x</var>, <var>y</var>) pairs.
+         * Therefore, the length of this array is at least twice the number
+         * of tiles where to get values.
+         */
+        private final int[] tileIndices;
+
+        /**
+         * Whether a tile at a given index does not exist. It happens when a 
sequence of points
+         * is outside the image and the iterator is allowed to return {@code 
null} in such cases.
+         * The corresponding values of {@link #tileIndices} are meaningless 
and should be ignored.
+         */
+        private final BitSet tileIsAbsent;
+
+        /**
+         * Creates a new iterator over a subset of the given iterator.
+         * This iterator will evaluate a prefix of the sequence of points of 
the given iterator.
+         * Caller should change the parent iterator to a suffix after this 
constructor returned.
+         * This constructor is for {@link #trySplit()} implementation only.
+         *
+         * @param  parent  the iterator from which to create a prefix.
+         * @param  stopAt  index after the last tile that this iterator can 
use.
+         */
+        private Image(final Image parent, final int stopAt) {
+            super(parent, stopAt);
+            this.image        = parent.image;
+            this.tileIndices  = parent.tileIndices;
+            this.tileIsAbsent = parent.tileIsAbsent;
+        }
+
+        /**
+         * Workaround for RFE #4093999 ("Relax constraint on placement of 
this()/super() call in constructors").
+         */
+        private Image(final RenderedImage image,
+                      final long[] nearestXY,
+                      final int    limitOfXY,
+                      final int[]  firstGridCoordOfChildren,
+                      final int    upperChildIndex,
+                      final int[]  tileIndices,
+                      final BitSet tileIsAbsent,
+                      final IntFunction<String> ifOutside)
+        {
+            super(nearestXY, limitOfXY, firstGridCoordOfChildren, 
upperChildIndex, ifOutside);
+            this.image        = image;
+            this.tileIndices  = tileIndices;
+            this.tileIsAbsent = tileIsAbsent;
+            current = nextChild();
+        }
+
+        /**
+         * Creates a new iterator for the specified image.
+         *
+         * @todo Retrofit in above constructor after RFE #4093999.
+         *
+         * @param parent     the parent iterator from which to inherit the 
grid coordinates.
+         * @param limitOfXY  index after the last coordinate of the last point 
to evaluate.
+         * @param image      the image from which to get the sample values.
+         */
+        static Image create(final ValuesAtPointIterator parent, final int 
limitOfXY, final RenderedImage image) {
+            final long xmin            = image.getMinX();
+            final long ymin            = image.getMinY();
+            final long xmax            = image.getWidth()  + xmin;
+            final long ymax            = image.getHeight() + ymin;
+            final long tileWidth       = image.getTileWidth();
+            final long tileHeight      = image.getTileHeight();
+            final long tileGridXOffset = image.getTileGridXOffset();
+            final long tileGridYOffset = image.getTileGridYOffset();
+            int[] tileIndices = new int[BIDIMENSIONAL];
+            int[] tileFirstCoords = new int[1];
+            final var tileIsAbsent = new BitSet();
+            final long[] nearestXY = parent.nearestXY;
+            int tileCount, indexOfXY = parent.indexOfXY;
+nextTile:   for (tileCount = 0; indexOfXY < limitOfXY; tileCount++) {
+                if (tileCount >= tileFirstCoords.length) {
+                    tileFirstCoords = Arrays.copyOf(tileFirstCoords, 
tileFirstCoords.length * 2);
+                    tileIndices = Arrays.copyOf(tileIndices, 
tileIndices.length * 2);
+                }
+                tileFirstCoords[tileCount] = indexOfXY;
+                boolean wasOutside = false;
+                do {
+                    long x = nearestXY[indexOfXY++];
+                    long y = nearestXY[indexOfXY++];
+                    if (x >= xmin && x < xmax && y >= ymin && y < ymax) {
+                        if (wasOutside) {
+                            indexOfXY -= BIDIMENSIONAL;  // Push back those 
valid coordinates.
+                            break;
+                        }
+                        final long tileX    = Math.floorDiv(x - 
tileGridXOffset, tileWidth);
+                        final long tileY    = Math.floorDiv(y - 
tileGridYOffset, tileHeight);
+                        final long tileXMin = Math.max(xmin,  tileX    * 
tileWidth  + tileGridXOffset);
+                        final long tileYMin = Math.max(ymin,  tileY    * 
tileHeight + tileGridYOffset);
+                        final long tileXMax = Math.min(xmax, (tileX+1) * 
tileWidth  + tileGridXOffset);
+                        final long tileYMax = Math.min(ymax, (tileY+1) * 
tileHeight + tileGridYOffset);
+                        while (indexOfXY < limitOfXY) {
+                            x = nearestXY[indexOfXY++];
+                            y = nearestXY[indexOfXY++];
+                            if (x < tileXMin || x >= tileXMax || y < tileYMin 
|| y >= tileYMax) {
+                                indexOfXY -= BIDIMENSIONAL;  // Push back 
those invalid coordinates.
+                                break;
+                            }
+                        }
+                        final int i = tileCount * BIDIMENSIONAL;
+                        tileIndices[i  ] = Math.toIntExact(tileX);
+                        tileIndices[i+1] = Math.toIntExact(tileY);
+                        continue nextTile;
+                    }
+                    /*
+                     * Found a point outside the image. By setting 
`wasOutside`, we change the behavior
+                     * of this loop for searching the end of the sequence of 
points that are outside
+                     * (instead of the end of the sequence of points that are 
on the same tile).
+                     */
+                    if (parent.ifOutside != null) {
+                        throw new 
PointOutsideCoverageException(parent.ifOutside.apply(indexOfXY / 
BIDIMENSIONAL));
+                    }
+                    wasOutside = true;
+                } while (indexOfXY < limitOfXY);
+                tileIsAbsent.set(tileCount);
+            }
+            return new Image(image, nearestXY, limitOfXY, tileFirstCoords, 
tileCount, tileIndices, tileIsAbsent, parent.ifOutside);
+        }
+
+        /**
+         * Creates a child which will iterate over the {@link #nearestXY} 
coordinates
+         * from {@link #indexOfXY} inclusive to {@code stopAtXY} exclusive.
+         */
+        @Override
+        final ValuesAtPointIterator createChild(int i, final int stopAtXY) {
+            if (tileIsAbsent.get(i)) {
+                return new Null(indexOfXY, stopAtXY);
+            }
+            i *= BIDIMENSIONAL;
+            final int tileX = tileIndices[i];
+            final int tileY = tileIndices[i+1];
+            return new Tile(this, indexOfXY, stopAtXY, image.getTile(tileX, 
tileY));
+        }
+
+        /**
+         * Creates a new iterator covering a prefix of the points.
+         * This is invoked by {@link #trySplit()} implementation.
+         */
+        @Override
+        final Group createPrefix(int stopAtXY) {
+            return new Image(this, stopAtXY);
+        }
+    }
+
+
+
+
+    /**
+     * An iterator over the sample values of point contained in the same tile.
+     * For performance reasons, this method recycles the same array in calls to
+     * {@link Consumer#accept(Object)}.
+     */
+    private static final class Tile extends ValuesAtPointIterator {
+        /**
+         * The tile from which to get the sample values.
+         */
+        private final Raster tile;
+
+        /**
+         * The sample values.
+         */
+        private double[] samples;
+
+        /**
+         * Creates a new iterator for the specified tile.
+         *
+         * @param parent     the parent iterator from which to inherit the 
grid coordinates.
+         * @param indexOfXY  index of the first coordinate of the next point 
to evaluate.
+         * @param limitOfXY  index after the last coordinate of the last point 
to evaluate.
+         * @param tile       the tile from which to get the sample values.
+         */
+        Tile(final ValuesAtPointIterator parent, final int indexOfXY, final 
int limitOfXY, final Raster tile) {
+            super(parent.nearestXY, limitOfXY, null);
+            this.indexOfXY = indexOfXY;
+            this.tile = tile;
+        }
+
+        /**
+         * Splits this iterator if there is enough points (an arbitrary 
threshold) to make it worth.
+         * If successful, the returned iterator is the prefix of the sequence 
of points to evaluate,
+         * and this iterator become the suffix.
+         */
+        @Override
+        public Spliterator<double[]> trySplit() {
+            final int start = indexOfXY;
+            final int half = ((limitOfXY - start) / 2) & ~1;        // Must be 
even.
+            if (half >= 10) {    // Arbitrary threshold.
+                return new Tile(this, start, indexOfXY += half, tile);  // 
Prefix.
+            }
+            return null;
+        }
+
+        /**
+         * Sends the sample values to the caller if the iteration is not 
finished.
+         * For performance reasons, this method recycles the same array in 
calls to {@code accept}.
+         */
+        @Override
+        public boolean tryAdvance(final Consumer<? super double[]> action) {
+            if (indexOfXY < limitOfXY) {
+                final int x = Math.toIntExact(nearestXY[indexOfXY++]);
+                final int y = Math.toIntExact(nearestXY[indexOfXY++]);
+                action.accept(samples = tile.getPixel(x, y, samples));
+                return true;
+            }
+            return false;
+        }
+
+        /**
+         * Sends remaining sample values to the caller until the end of the 
iteration.
+         * For performance reasons, this method recycles the same array in 
calls to {@code accept}.
+         */
+        @Override
+        public void forEachRemaining(final Consumer<? super double[]> action) {
+            while (indexOfXY < limitOfXY) {
+                final int x = Math.toIntExact(nearestXY[indexOfXY++]);
+                final int y = Math.toIntExact(nearestXY[indexOfXY++]);
+                action.accept(samples = tile.getPixel(x, y, samples));
+            }
+        }
+    }
+
+
+
+
+    /**
+     * An iterator which returns only null values.
+     * This is used for a sequence of points outside the image.
+     */
+    private static final class Null extends ValuesAtPointIterator {
+        /**
+         * Creates a new iterator of null elements.
+         *
+         * @param indexOfXY  index of the first coordinate of the next point 
to evaluate.
+         * @param limitOfXY  index after the last coordinate of the last point 
to evaluate.
+         */
+        Null(final int indexOfXY, final int limitOfXY) {
+            super(null, limitOfXY, null);
+            this.indexOfXY = indexOfXY;
+        }
+
+        /**
+         * Do not split this iterator as the caller would usually do nothing 
anyway.
+         * It does not seem worth to let this class be parallelized.
+         */
+        @Override
+        public Spliterator<double[]> trySplit() {
+            return null;
+        }
+
+        /**
+         * Sends a {@code null} array to the caller if the iteration is not 
finished.
+         */
+        @Override
+        public boolean tryAdvance(final Consumer<? super double[]> action) {
+            if (indexOfXY < limitOfXY) {
+                indexOfXY += BIDIMENSIONAL;
+                action.accept(null);
+                return true;
+            }
+            return false;
+        }
+
+        /**
+         * Sends {@code null} arrays to the caller until the end of the 
iteration.
+         */
+        @Override
+        public void forEachRemaining(final Consumer<? super double[]> action) {
+            while (indexOfXY < limitOfXY) {
+                indexOfXY += BIDIMENSIONAL;
+                action.accept(null);
+            }
+        }
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java
index 2b38525bf8..145b08cb89 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.java
@@ -180,8 +180,8 @@ public class Resources extends IndexedResourceBundle {
         public static final short EmptyTileOrImageRegion = 20;
 
         /**
-         * Indices ({3}) are outside grid coverage. The value in dimension {0} 
shall be between
-         * {1,number} and {2,number} inclusive.
+         * Grid coordinates ({3}) are outside the coverage domain. The 
coordinate in the {0} dimension
+         * shall be between {1,number} and {2,number} inclusive.
          */
         public static final short GridCoordinateOutsideCoverage_4 = 21;
 
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties
index 3fc96f2d07..1dda1a0cd7 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources.properties
@@ -44,7 +44,7 @@ DependencyNotFound_3              = Operation \u201c{0}\u201d 
requires a \u201c{
 DuplicatedSampleDimensionIndex_1  = Sample dimension index {0} is duplicated.
 EmptyImage                        = Image has zero pixel.
 EmptyTileOrImageRegion            = Empty tile or image region.
-GridCoordinateOutsideCoverage_4   = Indices ({3}) are outside grid coverage. 
The value in dimension {0} shall be between {1,number} and {2,number} inclusive.
+GridCoordinateOutsideCoverage_4   = Grid coordinates ({3}) are outside the 
coverage domain. The coordinate in the {0} dimension shall be between 
{1,number} and {2,number} inclusive.
 GridEnvelopeMustBeNDimensional_1  = The grid envelope must have at least {0} 
dimensions.
 GridExtentsAreDisjoint_5          = The specified grid extent is outside the 
domain. Indices [{3,number} \u2026 {4,number}] specified in dimension {0} do 
not intersect the [{1,number} \u2026 {2,number}] grid extent.
 IllegalCategoryRange_2            = Sample value range {1} for \u201c{0}\u201d 
category is illegal.
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties
index 5610f695c7..356619b3a6 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/feature/internal/Resources_fr.properties
@@ -49,7 +49,7 @@ DependencyNotFound_3              = L\u2019op\u00e9ration 
\u00ab\u202f{0}\u202f\
 DuplicatedSampleDimensionIndex_1  = L\u2019index de dimension 
d\u2019\u00e9chantillonnage {0} est r\u00e9p\u00e9t\u00e9.
 EmptyImage                        = L\u2019image a z\u00e9ro pixel.
 EmptyTileOrImageRegion            = La tuile ou la r\u00e9gion de l\u2019image 
est vide.
-GridCoordinateOutsideCoverage_4   = Les indices ({3}) sont en dehors du 
domaine de la grille. La valeur dans la dimension {0} doit \u00eatre entre 
{1,number} et {2,number} inclusivement.
+GridCoordinateOutsideCoverage_4   = Les coordonn\u00e9es de grille ({3}) sont 
en dehors du domaine de la grille. La coordonn\u00e9e de la dimension {0} doit 
\u00eatre entre {1,number} et {2,number} inclusivement.
 GridEnvelopeMustBeNDimensional_1  = L\u2019enveloppe de la grille doit avoir 
au moins {0} dimensions.
 GridExtentsAreDisjoint_5          = L\u2019\u00e9tendue de grille 
sp\u00e9cifi\u00e9e est en dehors du domaine. Les indices [{3,number} \u2026 
{4,number}] qui ont \u00e9t\u00e9 sp\u00e9cifi\u00e9es dans la dimension {0} 
n\u2019interceptent pas l\u2019\u00e9tendue [{1,number} \u2026 {2,number}] de 
la grille.
 IllegalCategoryRange_2            = La plage de valeurs {1} pour la 
cat\u00e9gorie \u00ab\u202f{0}\u202f\u00bb est ill\u00e9gale.
diff --git 
a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/DefaultEvaluatorTest.java
 
b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/DefaultEvaluatorTest.java
new file mode 100644
index 0000000000..4d54bb30b6
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/DefaultEvaluatorTest.java
@@ -0,0 +1,294 @@
+/*
+ * 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.coverage.grid;
+
+import java.util.Random;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Stream;
+import java.awt.image.DataBuffer;
+import javax.measure.IncommensurableException;
+import org.opengis.geometry.DirectPosition;
+import org.opengis.referencing.cs.CoordinateSystem;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.TransformException;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.geometry.Envelope2D;
+import org.apache.sis.geometry.GeneralDirectPosition;
+import org.apache.sis.referencing.cs.CoordinateSystems;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+
+// Test dependencies
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+import org.apache.sis.test.TestCase;
+import org.apache.sis.test.TestUtilities;
+import org.apache.sis.image.TiledImageMock;
+import org.apache.sis.referencing.crs.HardCodedCRS;
+import org.junit.jupiter.api.TestInstance;
+
+
+/**
+ * Tests {@link DefaultEvaluator}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+@SuppressWarnings("exports")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public final class DefaultEvaluatorTest extends TestCase {
+    /**
+     * The random number generator.
+     */
+    private final Random random;
+
+    /**
+     * Size of the image to test, in pixels.
+     * Used for computing expected sample values.
+     */
+    private final int width, height;
+
+    /**
+     * Size of tiles, in pixels.
+     * Used for computing expected sample values.
+     */
+    private final int tileWidth, tileHeight;
+
+    /**
+     * Number of tiles on the <var>x</var> axis.
+     */
+    private final int numXTiles;
+
+    /**
+     * The grid geometry of the coverage used as source data.
+     */
+    private final GridGeometry gridGeometry;
+
+    /**
+     * Number of bands.
+     */
+    private final int numBands;
+
+    /**
+     * The evaluator to test.
+     */
+    private final GridCoverage.Evaluator evaluator;
+
+    /**
+     * The expected values in the first band of each coordinate.
+     * The length of this array is the number of test points.
+     */
+    private float[] expectedValues;
+
+    /**
+     * Creates a new test case with a small grid coverage.
+     * The sample values will be 3 digits numbers of the form "BTYX" where:
+     * <ul>
+     *   <li><var>B</var> is the band index starting with 1 for the first 
band.</li>
+     *   <li><var>T</var> is the tile index starting with 1 for the first tile 
and increasing in a row-major fashion.</li>
+     *   <li><var>Y</var> is the <var>y</var> coordinate (row 0-based index) 
of the sample value relative to current tile.</li>
+     *   <li><var>X</var> is the <var>x</var> coordinate (column 0-based 
index) of the sample value relative to current tile.</li>
+     * </ul>
+     *
+     * Image size and tile size are computed in a way that keep each 
above-cited numbers smaller than 10.
+     */
+    public DefaultEvaluatorTest() {
+        random     = TestUtilities.createRandomNumberGenerator();
+        numBands   = random.nextInt(3) + 1;
+        tileWidth  = random.nextInt(4) + 6;   // Keep lower than 10.
+        tileHeight = random.nextInt(4) + 6;
+        width      = tileWidth  * (random.nextInt(3) + 1) - random.nextInt(4);
+        height     = tileHeight * (random.nextInt(3) + 1) - random.nextInt(4);
+        final var image  = new TiledImageMock(
+                DataBuffer.TYPE_USHORT,         // data type
+                numBands,                       // number of values per pixel
+                random.nextInt(20) - 10,        // minX
+                random.nextInt(20) - 10,        // minY
+                width,     height,              // image size
+                tileWidth, tileHeight,          // tile size
+                random.nextInt(20) - 10,        // minTileX
+                random.nextInt(20) - 10,        // minTileY
+                random.nextBoolean());          // banded or interleaved 
sample model
+
+        image.initializeAllTiles();
+        final int dx = random.nextInt(200) - 100;
+        final int dy = random.nextInt(200) - 100;
+        gridGeometry = new GridGeometry(
+                new GridExtent(null, new long[] {dx, dy}, new long[] {dx + 
width, dy + height}, false),
+                new Envelope2D(HardCodedCRS.WGS84, 20, -10, 40, 60), 
GridOrientation.HOMOTHETY);
+        evaluator = new GridCoverage2D(gridGeometry, null, image).evaluator();
+        numXTiles = image.getNumXTiles();
+    }
+
+    /**
+     * Creates test points, together with the expected values.
+     * The expected values are set in the {@link #expectedValues} field.
+     *
+     * @param  allowVariations  whether to allow points outside the coverage 
domain and change of <abbr>CRS</abbr>.
+     * @return the test points.
+     * @throws TransformException if a test point cannot be computed.
+     */
+    private List<DirectPosition> createTestPoints(final boolean 
allowVariations) throws TransformException {
+        final int     numPoints  = random.nextInt(30) + 20;
+        final var     points     = new GeneralDirectPosition[numPoints];
+        final float   lowerX     = gridGeometry.extent.getLow(0);
+        final float   lowerY     = gridGeometry.extent.getLow(1);
+        final float[] gridCoords = new float[2];
+        MathTransform gridToCRS  = gridGeometry.gridToCRS;
+        CoordinateReferenceSystem crs = HardCodedCRS.WGS84;
+        final CoordinateSystem coverageCS = crs.getCoordinateSystem();
+        expectedValues = new float[numPoints];
+        for (int i=0; i<numPoints; i++) {
+            /*
+             * Randomly change the CRS if this change is allowed.
+             */
+            if (allowVariations && random.nextInt(5) == 0) try {
+                if (random.nextBoolean()) {
+                    crs = HardCodedCRS.WGS84_LATITUDE_FIRST;
+                    var swap = CoordinateSystems.swapAndScaleAxes(coverageCS, 
crs.getCoordinateSystem());
+                    gridToCRS = 
MathTransforms.concatenate(gridGeometry.gridToCRS, MathTransforms.linear(swap));
+                } else {
+                    crs = HardCodedCRS.WGS84;
+                    gridToCRS  = gridGeometry.gridToCRS;
+                }
+            } catch (IncommensurableException e) {
+                throw new AssertionError(e);
+            }
+            final float expected;
+            if (allowVariations && random.nextInt(5) == 0) {
+                gridCoords[0] = lowerX + (random.nextBoolean() ? -10 : 10 + 
width);
+                gridCoords[1] = lowerY + (random.nextBoolean() ? -10 : 10 + 
height);
+                expected = Float.NaN;
+            } else {
+                final int x = random.nextInt(width);
+                final int y = random.nextInt(height);
+                final int tx = x / tileWidth;
+                final int ty = y / tileHeight;
+                gridCoords[0] = lowerX + x + 0.875f * (random.nextFloat() - 
0.5f);
+                gridCoords[1] = lowerY + y + 0.875f * (random.nextFloat() - 
0.5f);
+                expected = (x - tx * tileWidth)
+                         + (y - ty * tileHeight) * 10
+                         + 100*(numXTiles*ty + tx + 1);
+            }
+            expectedValues[i] = expected;
+            final var point = new GeneralDirectPosition(crs);
+            gridToCRS.transform(gridCoords, 0, point.coordinates, 0, 1);
+            points[i] = point;
+        }
+        return Arrays.asList(points);
+    }
+
+    /**
+     * Compares the actual sample values against the expected values.
+     * The given stream should compute sample values for the points
+     * returned by {@link #createTestPoints()}.
+     *
+     * @param  stream  the computed values.
+     */
+    private void runAndCompare(final Stream<double[]> stream) {
+        final double[][] actual = stream.map((samples) -> (samples != null) ? 
samples.clone() : null).toArray(double[][]::new);
+        assertEquals(expectedValues.length, actual.length);
+        for (int i=0; i<actual.length; i++) {
+            double expected = expectedValues[i];
+            final double[] samples = actual[i];
+            if (samples != null) {
+                assertEquals(numBands, samples.length);
+            }
+            for (int band = 0; band < numBands; band++) {
+                assertEquals(expected += 1000, (samples != null) ? 
samples[band] : Double.NaN);
+            }
+        }
+    }
+
+    /**
+     * Tests the {@code apply(DirectPosition)} method.
+     * Assuming that the {@link GridCoverage2DTest#testEvaluator()} test 
passes,
+     * this test verifies that the above test infrastructure works.
+     *
+     * @see GridCoverage2DTest#testEvaluator()
+     * @see GridCoverage2DTest#testEvaluatorWithWraparound()
+     * @throws TransformException if a test point cannot be computed.
+     */
+    @Test
+    public void testApply() throws TransformException {
+        evaluator.setNullIfOutside(false);
+        assertFalse(evaluator.isNullIfOutside());
+        runAndCompare(createTestPoints(false).stream().map(evaluator::apply));
+    }
+
+    /**
+     * Same as {@link #testApply()} but with points outside the grid coverage 
domain.
+     * Also tests input points with different <abbr>CRS</abbr>.
+     *
+     * @throws TransformException if a test point cannot be computed.
+     */
+    @Test
+    public void testApplyWithPointOutside() throws TransformException {
+        evaluator.setNullIfOutside(true);
+        assertTrue(evaluator.isNullIfOutside());
+        runAndCompare(createTestPoints(true).stream().map(evaluator::apply));
+    }
+
+    /**
+     * Tests with random points inside the coverage computed sequentially.
+     *
+     * @throws TransformException if a test point cannot be computed.
+     */
+    @Test
+    public void testSequential() throws TransformException {
+        evaluator.setNullIfOutside(false);
+        assertFalse(evaluator.isNullIfOutside());
+        runAndCompare(evaluator.stream(createTestPoints(false), false));
+    }
+
+    /**
+     * Same as {@link #testSequential()} but with points outside the grid 
coverage domain.
+     * Also tests input points with different <abbr>CRS</abbr>.
+     *
+     * @throws TransformException if a test point cannot be computed.
+     */
+    @Test
+    public void testSequentialWithPointOutside() throws TransformException {
+        evaluator.setNullIfOutside(true);
+        assertTrue(evaluator.isNullIfOutside());
+        runAndCompare(evaluator.stream(createTestPoints(true), false));
+    }
+
+    /**
+     * Tests with random points inside the coverage computed sequentially.
+     *
+     * @throws TransformException if a test point cannot be computed.
+     */
+    @Test
+    public void testParallel() throws TransformException {
+        evaluator.setNullIfOutside(false);
+        assertFalse(evaluator.isNullIfOutside());
+        runAndCompare(evaluator.stream(createTestPoints(false), true));
+    }
+
+    /**
+     * Same as {@link #testParallel()} but with points outside the grid 
coverage domain.
+     * Also tests input points with different <abbr>CRS</abbr>.
+     *
+     * @throws TransformException if a test point cannot be computed.
+     */
+    @Test
+    public void testParallelWithPointOutside() throws TransformException {
+        evaluator.setNullIfOutside(true);
+        assertTrue(evaluator.isNullIfOutside());
+        runAndCompare(evaluator.stream(createTestPoints(true), true));
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/FractionalGridCoordinatesTest.java
 
b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/FractionalGridCoordinatesTest.java
index 33b49c9e69..36e9b0dc5a 100644
--- 
a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/FractionalGridCoordinatesTest.java
+++ 
b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/FractionalGridCoordinatesTest.java
@@ -27,6 +27,7 @@ import org.apache.sis.test.TestCase;
  *
  * @author  Martin Desruisseaux (Geomatys)
  */
+@SuppressWarnings("exports")
 public final class FractionalGridCoordinatesTest extends TestCase {
     /**
      * Creates a new test case.
@@ -38,11 +39,7 @@ public final class FractionalGridCoordinatesTest extends 
TestCase {
      * Creates a test instance with (4 -1.1 7.6) coordinate values.
      */
     private static FractionalGridCoordinates instance() {
-        final FractionalGridCoordinates gc = new FractionalGridCoordinates(3);
-        gc.coordinates[0] =  4;
-        gc.coordinates[1] = -1.1;
-        gc.coordinates[2] =  7.6;
-        return gc;
+        return new FractionalGridCoordinates(new double[] {4, -1.1, 7.6});
     }
 
     /**
@@ -61,6 +58,7 @@ public final class FractionalGridCoordinatesTest extends 
TestCase {
      * with default parameter values.
      */
     @Test
+    @SuppressWarnings("removal")   // TODO: make 
GridExtentTest.assertExtentEquals private.
     public void testToExtent() {
         final GridExtent extent = instance().toExtent(null);
         GridExtentTest.assertExtentEquals(extent, 0,  4,  4);
@@ -72,6 +70,7 @@ public final class FractionalGridCoordinatesTest extends 
TestCase {
      * Tests {@link FractionalGridCoordinates#toExtent(GridExtent, long...)} 
with a size of 1.
      */
     @Test
+    @SuppressWarnings("removal")
     public void testToExtentSize1() {
         final GridExtent extent = instance().toExtent(null, 1, 1, 1);
         GridExtentTest.assertExtentEquals(extent, 0,  4,  4);
@@ -83,6 +82,7 @@ public final class FractionalGridCoordinatesTest extends 
TestCase {
      * Tests {@link FractionalGridCoordinates#toExtent(GridExtent, long...)} 
with a size greater than 2.
      */
     @Test
+    @SuppressWarnings("removal")
     public void testToExtentSizeN() {
         final GridExtent extent = instance().toExtent(null, 3, 5, 4);
         GridExtentTest.assertExtentEquals(extent, 0,  3,  5);
@@ -94,6 +94,7 @@ public final class FractionalGridCoordinatesTest extends 
TestCase {
      * Tests {@link FractionalGridCoordinates#toExtent(GridExtent, long...)} 
with a bounds constraint.
      */
     @Test
+    @SuppressWarnings("removal")
     public void testToExtentBounded() {
         final GridExtent bounds = new GridExtent(null, new long[] {0, -1, 0}, 
new long[] {4, 2, 8}, true);
         final GridExtent extent = instance().toExtent(bounds, 3, 5, 4);
diff --git 
a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/GridCoverage2DTest.java
 
b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/GridCoverage2DTest.java
index d023ff510a..81b6477f2f 100644
--- 
a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/GridCoverage2DTest.java
+++ 
b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/coverage/grid/GridCoverage2DTest.java
@@ -91,7 +91,7 @@ public class GridCoverage2DTest extends TestCase {
         final var grid = new GridGeometry(new GridExtent(GRID_SIZE, GRID_SIZE),
                             PixelInCell.CELL_CENTER, gridToCRS, 
HardCodedCRS.WGS84);
 
-        final MathTransform1D toUnits = (MathTransform1D) 
MathTransforms.linear(0.5, 100);
+        final var toUnits = (MathTransform1D) MathTransforms.linear(0.5, 100);
         final SampleDimension sd = new SampleDimension.Builder().setName("Some 
kind of height")
                 .addQuantitative("data", NumberRange.create(-10, true, 10, 
true), toUnits, Units.METRE)
                 .build();
@@ -182,7 +182,7 @@ public class GridCoverage2DTest extends TestCase {
          *
          *   70 = p * 0.5 + 100   →   (70-100)/0.5 = p   →   p = -60
          */
-        final WritableRenderedImage image = (WritableRenderedImage) 
coverage.render(null);
+        final var image = (WritableRenderedImage) coverage.render(null);
         final WritableRaster raster = image.getWritableTile(0, 0);
         raster.setSample(0, 0, 0,  70);
         raster.setSample(1, 0, 0,   2.5);
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/CompoundFormat.java 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/CompoundFormat.java
index b40b9791a5..1e2b084de3 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/CompoundFormat.java
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/CompoundFormat.java
@@ -105,7 +105,7 @@ import static 
org.apache.sis.util.internal.shared.Constants.UTC;
  * in case of error.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.5
+ * @version 1.6
  *
  * @param <T>  the base type of objects parsed and formatted by this class.
  *
@@ -349,15 +349,31 @@ public abstract class CompoundFormat<T> extends Format 
implements Localized {
     public abstract void format(T object, Appendable toAppendTo) throws 
IOException;
 
     /**
-     * Writes a textual representation of the specified object in the given 
buffer.
-     * This method delegates its work to {@link #format(Object, Appendable)}, 
but
-     * without propagating {@link IOException}. The I/O exception should never
-     * occur since we are writing in a {@link StringBuffer}.
+     * Writes a textual representation of the given object in the given buffer.
+     * This method delegates the work to {@link #format(Object, Appendable)}.
+     * If an {@link IOException} occurs (for example, because {@code 
format(…)} performs
+     * some I/O operations on other objects than the given {@link 
StringBuilder}),
+     * the exception is wrapped in {@link UncheckedIOException}.
      *
-     * <div class="note"><b>Note:</b>
-     * Strictly speaking, an {@link IOException} could still occur if a 
subclass overrides the above {@code format}
-     * method and performs some I/O operation outside the given {@link 
StringBuffer}. However, this is not the intended
-     * usage of this class and implementers should avoid such unexpected I/O 
operation.</div>
+     * @param  object      the object to format.
+     * @param  toAppendTo  where to format the object.
+     *
+     * @since 1.6
+     */
+    public void format(T object, StringBuilder toAppendTo) {
+        try {
+            format(object, (Appendable) toAppendTo);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    /**
+     * Writes a textual representation of the specified object in the given 
buffer.
+     * This method delegates the work to {@link #format(Object, Appendable)}.
+     * If an {@link IOException} occurs (for example, because {@code 
format(…)} performs
+     * some I/O operations on other objects than the given {@link 
StringBuffer}),
+     * the exception is wrapped in {@link UncheckedIOException}.
      *
      * @param  object      the object to format.
      * @param  toAppendTo  where to format the object.
@@ -373,9 +389,8 @@ public abstract class CompoundFormat<T> extends Format 
implements Localized {
             throw new 
IllegalArgumentException(Errors.format(Errors.Keys.IllegalClass_2, valueType, 
Classes.getClass(object)), e);
         } catch (IOException e) {
             /*
-             * Should never happen when writing into a StringBuffer, unless 
the user
-             * override the format(Object, Appendable) method.  We do not 
rethrow an
-             * AssertionError because of this possibility.
+             * Should never happen when writing into a StringBuffer, unless 
the error
+             * results from another operation than writting in the given 
buffer.
              */
             throw new UncheckedIOException(e);
         }
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/package-info.java 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/package-info.java
index 54ade7a38d..571953421a 100644
--- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/package-info.java
+++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/package-info.java
@@ -41,7 +41,7 @@
  * Unicode supplementary characters}.
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
- * @version 1.5
+ * @version 1.6
  * @since   0.3
  */
 package org.apache.sis.io;


Reply via email to