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

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

commit 0061e5aa331a22fe13dc3bde2e7cd076a8ce7f93
Merge: 0a2fde0652 1d55e786a1
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Tue Jun 23 15:49:12 2026 +0200

    Merge branch 'fix/image-tile-matrix-get-tile' into geoapi-4.0.
    https://github.com/apache/sis/pull/43
    
    Summary of changes applied during the merge:
    * `imageTilingExtent` modified for avoiding exclusion of tiles outside 
previous requests but nevertheless loaded.
    * Iteration over slices moved after the computation of the intersection 
between requested bounds and available bounds.
    * Bug fix in calculation of `imageToTileX` and `imageToTileX` (was a bug in 
previous code, not in the pull request).
    * Renamed some variables for avoiding above confusion and for consistency 
with the names used in other classes.
    * Documentation, in particular about assumption that each slice has a "tile 
size" of 1.
    * In `ImageTileMatrixTest`, run the tests with sequential iterations before 
to test parallel iterations.

 .../main/org/apache/sis/image/ComputedImage.java   |  15 -
 .../main/org/apache/sis/image/PlanarImage.java     |  21 ++
 .../sis/image/internal/shared/ReshapedImage.java   |  14 +-
 .../sis/image/internal/shared/TiledImage.java      |  23 +-
 .../image/internal/shared/ReshapedImageTest.java   |  25 +-
 .../apache/sis/storage/tiling/ImageTileMatrix.java | 346 ++++++++++++++-----
 .../apache/sis/storage/tiling/IterationDomain.java |  38 +--
 .../org/apache/sis/storage/tiling/TileMatrix.java  |   2 +
 .../sis/storage/tiling/ImageTileMatrixTest.java    | 371 +++++++++++++++++++++
 9 files changed, 719 insertions(+), 136 deletions(-)

diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java
index 7a342f5c28,83d65a92a5..934b8d0c32
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/ComputedImage.java
@@@ -442,21 -442,21 +442,6 @@@ public abstract class ComputedImage ext
          return sampleModel.getHeight();
      }
  
--    /**
--     * Verifies that an index is inside the expected range of tile indices.
--     * If the index is out of bounds, then this method throws an {@code 
IndexOutOfBoundsException}
--     * for consistency with {@link java.awt.image.BufferedImage#getTile(int, 
int)} public contract.
--     *
--     * @throws IndexOutOfBoundsException if the given tile index is out of 
bounds.
--     */
--    private static void checkTileIndex(final String name, final int min, 
final int count, final int value) {
--        final int max = min + count;
--        if (value < min || value >= max) {
--            throw new IndexOutOfBoundsException(Errors.format(
--                    Errors.Keys.ValueOutOfRange_4, name, min, max - 1, 
value));
--        }
--    }
--
      /**
       * Returns a tile of this image, computing it when needed.
       * This method performs the first of the following actions that apply:
diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java
index 0e17b00ab4,a84309840b..38545fbc49
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/PlanarImage.java
@@@ -485,6 -470,6 +485,27 @@@ public abstract class PlanarImage imple
          return Math.toIntExact(getMinY() - multiplyFull(getMinTileY(), 
getTileHeight()));
      }
  
++    /**
++     * Verifies that an index is inside the expected range of tile indices.
++     * If the index is out of bounds, then this method throws an {@code 
IndexOutOfBoundsException}
++     * for consistency with {@link java.awt.image.BufferedImage#getTile(int, 
int)} public contract.
++     *
++     * @param  name   name of the argument to validate (usually {@code 
"tileX"} or {@code "tileY"}).
++     * @param  min    the minimum valid tile index (usually {@link 
#getMinTileX()} or {@link #getMinTileY()}).
++     * @param  count  the number of tiles (usually {@link #getNumXTiles()} or 
{@link #getNumYTiles()}).
++     * @param  index  the tile index to validate.
++     * @throws IndexOutOfBoundsException if the given tile index is out of 
bounds.
++     *
++     * @since 1.7
++     */
++    protected static void checkTileIndex(final String name, final int min, 
final int count, final int index) {
++        final int max = min + (count - 1);
++        if (index < min || index > max) {
++            throw new IndexOutOfBoundsException(Errors.format(
++                    Errors.Keys.ValueOutOfRange_4, name, min, max, index));
++        }
++    }
++
      /**
       * Creates a raster with the same sample model as this image and with the 
given size and location.
       * This method does not verify argument validity.
diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/ReshapedImage.java
index 7db4d35660,17bebfaab5..6c1469f3f0
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/ReshapedImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/ReshapedImage.java
@@@ -42,6 -42,6 +42,7 @@@ import org.apache.sis.image.PlanarImage
   *
   * @author  Johann Sorel (Geomatys)
   * @author  Martin Desruisseaux (Geomatys)
++ * @author  Alexis Manin (Geomatys)
   */
  public final class ReshapedImage extends PlanarImage {
      /**
@@@ -128,7 -128,7 +129,8 @@@
                  0, 0,
                  tileWidth,
                  tileHeight,
-                 0, 0);
 -                tileX, tileY);
++                tileX,
++                tileY);
          return image.isIdentity() ? image.source : image;
      }
  
@@@ -295,9 -295,17 +297,19 @@@
       * @param  tileX  the <var>x</var> index of the requested tile in the 
tile array.
       * @param  tileY  the <var>y</var> index of the requested tile in the 
tile array.
       * @return the tile specified by the specified indices.
++     * @throws IndexOutOfBoundsException if a given tile index is out of 
bounds.
       */
      @Override
      public Raster getTile(final int tileX, final int tileY) {
 -        // Ensure reshaped image strictly respect its boundaries and does not 
access source tiles outside its domain.
 -        if (!(
 -                getMinTileX() <= tileX && tileX < getMinTileX() + 
getNumXTiles()
 -                &&
 -                getMinTileY() <= tileY && tileY < getMinTileY() + 
getNumYTiles()
 -        )) {
 -            throw new IllegalArgumentException("Requested tile is outside 
Image domain");
 -        }
++        /*
++         * Checking the tile index is not strictly necessary because 
`RenderedImage` does not specify
++         * any behavior for invalid tile index. In particular, the exception 
to throw is unspecified.
++         * In this method, skipping the check would not be a violation of the 
method contract: we may
++         * return more tiles that what `ReshapedImage` declares, but those 
tiles are not wrong.
++         * However, checking the image boundaries may help to catch user's 
mistake.
++         */
++        checkTileIndex("tileX", minTileX, getNumXTiles(), tileX);
++        checkTileIndex("tileY", minTileY, getNumYTiles(), tileY);
          return offset(source.getTile(tileX, tileY));
      }
  
diff --cc 
endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/TiledImage.java
index 80fbe92537,80fbe92537..f0c3869f3d
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/TiledImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/TiledImage.java
@@@ -23,7 -23,7 +23,6 @@@ import java.awt.image.ColorModel
  import java.awt.image.SampleModel;
  import org.apache.sis.image.PlanarImage;
  import org.apache.sis.util.ArgumentChecks;
--import org.apache.sis.util.resources.Errors;
  
  
  /**
@@@ -236,7 -236,7 +235,7 @@@ public class TiledImage extends PlanarI
       * Returns the minimum tile index in the X direction.
       */
      @Override
--    public int getMinTileX() {
++    public final int getMinTileX() {
          return minTileX;
      }
  
@@@ -244,7 -244,7 +243,7 @@@
       * Returns the minimum tile index in the Y direction.
       */
      @Override
--    public int getMinTileY() {
++    public final int getMinTileY() {
          return minTileY;
      }
  
@@@ -255,20 -255,20 +254,8 @@@
      public Raster getTile(int tileX, int tileY) {
          final int numXTiles = getNumXTiles();
          final int numYTiles = getNumYTiles();
--        tileX = verifyTileIndex("tileX", tileX, minTileX, numXTiles);
--        tileY = verifyTileIndex("tileY", tileY, minTileY, numYTiles);
--        return tiles[tileX + tileY * numXTiles];
--    }
--
--    /**
--     * Verifies that the given tile index is inside expected bounds, then 
returns is zero-based value.
--     */
--    private static int verifyTileIndex(final String name, final int value, 
final int min, final int count) {
--        final int r = value - min;
--        if (r >= 0 && r < count) {
--            return r;
--        }
--        throw new IndexOutOfBoundsException(Errors.format(
--                Errors.Keys.ValueOutOfRange_4, name, min, min + count - 1, 
value));
++        checkTileIndex("tileX", minTileX, numXTiles, tileX);
++        checkTileIndex("tileY", minTileY, numYTiles, tileY);
++        return tiles[(tileX - minTileX) + (tileY - minTileY) * numXTiles];
      }
  }
diff --cc 
endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/internal/shared/ReshapedImageTest.java
index 35973b6908,10884f8d3b..7230007200
--- 
a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/internal/shared/ReshapedImageTest.java
+++ 
b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/image/internal/shared/ReshapedImageTest.java
@@@ -20,6 -20,6 +20,7 @@@ import java.util.Random
  import java.awt.image.DataBuffer;
  import java.awt.image.BufferedImage;
  import java.awt.image.RenderedImage;
++import java.awt.image.Raster;
  
  // Test dependencies
  import org.junit.jupiter.api.Test;
@@@ -27,6 -27,6 +28,7 @@@ import static org.junit.jupiter.api.Ass
  import org.apache.sis.test.TestCase;
  import org.apache.sis.test.TestUtilities;
  import org.apache.sis.image.TiledImageMock;
++import static org.apache.sis.test.Assertions.assertMessageContains;
  import static org.apache.sis.feature.Assertions.assertValuesEqual;
  
  
@@@ -35,6 -35,6 +37,7 @@@
   *
   * @author  Johann Sorel (Geomatys)
   * @author  Martin Desruisseaux (Geomatys)
++ * @author  Alexis Manin (Geomatys)
   */
  public final class ReshapedImageTest extends TestCase {
      /**
@@@ -86,7 -86,7 +89,7 @@@
          numYTiles = 1;
          width     = TILE_WIDTH;
          height    = TILE_HEIGHT;
--        final BufferedImage data = new BufferedImage(TILE_WIDTH, TILE_HEIGHT, 
BufferedImage.TYPE_BYTE_GRAY);
++        final var data = new BufferedImage(TILE_WIDTH, TILE_HEIGHT, 
BufferedImage.TYPE_BYTE_GRAY);
          data.getRaster().setSamples(0, 0, TILE_WIDTH, TILE_HEIGHT, 0, new 
int[] {
              1, 2, 3,
              4, 7, 6
@@@ -198,4 -198,28 +201,24 @@@
              {1210, 1211, 1212  ,  1310, 1311, 1312  ,  1410, 1411, 1412}
          });
      }
+ 
+     /**
+      * Verify a reshaped image created to expose a single tile from a source 
tiled image only serves the requested tile.
+      */
+     @Test
+     public void testExposeSingleTileFromTiledImage() {
+         var source  = new TiledImageMock(DataBuffer.TYPE_USHORT, 1, 0, 0, 4, 
4, 2, 2, 0, 0, false);
+         source.validate();
+         source.initializeAllTiles(0);
++        RenderedImage lastTile = ReshapedImage.singleTile(source, 1, 1);
++        assertMessageContains(assertThrows(IndexOutOfBoundsException.class,
++                () -> lastTile.getTile(0, 0),
++                "Tile (0, 0) should not be available"), "tileX");
+ 
 -        var lastTile = ReshapedImage.singleTile(source, 1, 1);
 -        try {
 -            lastTile.getTile(0, 0);
 -            fail("Tile (0, 0) should not be available");
 -        } catch (IllegalArgumentException e) {
 -            // Expected
 -        }
 -
 -        final var exposedTile = lastTile.getTile(1, 1);
++        Raster exposedTile = lastTile.getTile(1, 1);
+         assertValuesEqual(exposedTile, 0, new int[][] {
+                 { 400, 401 },
+                 { 410, 411 }
+         });
+     }
  }
diff --cc 
endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/ImageTileMatrix.java
index a50ed64ab1,c0bf1791e2..3587faa0a7
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/ImageTileMatrix.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/ImageTileMatrix.java
@@@ -24,6 -24,6 +24,15 @@@ import java.awt.Rectangle
  import java.awt.image.RenderedImage;
  import java.nio.file.Path;
  import java.util.logging.Logger;
++import static java.lang.Math.addExact;
++import static java.lang.Math.subtractExact;
++import static java.lang.Math.multiplyExact;
++import static java.lang.Math.incrementExact;
++import static java.lang.Math.decrementExact;
++import static java.lang.Math.floorDiv;
++import static java.lang.Math.toIntExact;
++import static java.lang.Math.min;
++import static java.lang.Math.max;
  import org.opengis.util.GenericName;
  import org.opengis.referencing.operation.TransformException;
  import org.apache.sis.referencing.operation.matrix.Matrices;
@@@ -33,8 -33,7 +42,8 @@@ import org.apache.sis.storage.MemoryGri
  import org.apache.sis.storage.DataStoreException;
  import org.apache.sis.storage.NoSuchDataException;
  import org.apache.sis.storage.UnsupportedQueryException;
- import org.apache.sis.storage.InternalDataStoreException;
  import org.apache.sis.storage.Resource;
++import org.apache.sis.coverage.SubspaceNotSpecifiedException;
  import org.apache.sis.coverage.grid.GridExtent;
  import org.apache.sis.coverage.grid.GridGeometry;
  import org.apache.sis.coverage.grid.GridCoverage2D;
@@@ -57,10 -56,10 +66,14 @@@ import org.apache.sis.storage.internal.
   * The tile size must be specified at construction time and must be equal to 
the size of
   * the tiles of the rendered image.
   *
++ * If the coverage has more than two dimensions, the current implementation 
requires
++ * a tile size of 1 in all dimensions other than <var>X</var> and 
<var>Y</var>.
++ *
   * <p>This class is needed only when the application needs details about the 
tiling scheme,
   * for example in order to implement a Web Map Tile Service 
(<abbr>WMTS</abbr>).</p>
   *
   * @author  Martin Desruisseaux (Geomatys)
++ * @author  Alexis Manin (Geomatys)
   */
  final class ImageTileMatrix implements TileMatrix {
      /**
@@@ -109,6 -108,6 +122,8 @@@
       * because some {@link TiledGridCoverage} implementations may use some 
metadata (typically the
       * image date) as a third grid dimension, but still manage all tiles as 
two-dimensional.
       * In such case, all extra dimensions are assumed to have a size of 1.
++     *
++     * @see TiledGridCoverageResource#getTileSize()
       */
      private final int[] tileSize;
  
@@@ -129,9 -128,15 +144,25 @@@
       * The image containing the tiles of the tile matrix. Computed when first 
needed, and may be
       * recomputed multiple times with different offsets if the tile indices 
are larger than the
       * capacity of 32-bits integers.
++     *
++     * <p>If the coverage has more than two dimensions, this image contains 
the tiles of only one slice.
++     * The current implementation does not cache more than one 
two-dimensional slice at a time, in order
++     * to avoid too large memory consumption.</p>
       */
      private RenderedImage image;
  
+     /**
 -     * Extent of {@link #image} in the {@link #coverage} {@link 
TileMatrix#getTilingScheme() tiling scheme}.
 -     * Id {@link #image} is null, this should also be null. If image is not 
null, this must not be null.
++     * Indices in extra-dimensions (above two) of the tiles cached by {@link 
#image}.
++     * The length of this array shall be the number of dimensions of the 
{@linkplain #coverage}.
++     * The values at the <var>X</var> and <var>Y</var> dimensions will be 
ignored and <em>shall</em> be zero.
++     * The values in all other dimensions shall be the index of the slice 
rendered by {@linkplain #image}.
++     * The zeros in <var>X</var> and <var>Y</var> dimensions are important 
for making array comparisons simpler.
++     *
++     * <p>The content of this array shall not be modified.
++     * If new indices are needed, a new array shall be created.</p>
+      */
 -    private GridExtent imageTilingExtent;
++    private long[] imageSliceIndices;
+ 
      /**
       * The grid coverage processor to use when tiles use a subset of the 
bands.
       *
@@@ -180,7 -185,7 +211,7 @@@
               * using the minimum number needed for the largest index.
               */
              if (i != 0) pattern.append(',');
--            
pattern.append("%0").append(DecimalFunctions.floorLog10(Math.max(tileCount[i] - 
1, 1)) + 1).append('d');
++            
pattern.append("%0").append(DecimalFunctions.floorLog10(max(tileCount[i] - 1, 
1)) + 1).append('d');
          }
          tilingScheme = new GridGeometry(cellGrid, extent.reshape(null, 
tileCount, false), MathTransforms.linear(toCells));
          tileIndicesPattern = pattern.toString();
@@@ -278,13 -273,13 +309,13 @@@
                  imageToTileY = this.imageToTileY;
              }
              if (image != null) {
--                final long tileX = 
Math.subtractExact(indices[coverage.xDimension], imageToTileX);
++                final long tileX = 
subtractExact(indices[coverage.xDimension], imageToTileX);
                  final long x0 = image.getMinTileX();
                  if (tileX >= x0 && tileX < x0 + image.getNumXTiles()) {
--                    final long tileY = 
Math.subtractExact(indices[coverage.yDimension], imageToTileY);
++                    final long tileY = 
subtractExact(indices[coverage.yDimension], imageToTileY);
                      final long y0 = image.getMinTileY();
                      if (tileY >= y0 && tileY < y0 + image.getNumYTiles()) {
--                        return getTileStatus(image, Math.toIntExact(tileX), 
Math.toIntExact(tileY));
++                        return getTileStatus(image, toIntExact(tileX), 
toIntExact(tileY));
                      }
                  }
              }
@@@ -331,7 -326,7 +362,14 @@@
      public Optional<Tile> getTile(final long... indices) throws 
DataStoreException {
          final GridExtent extent = tilingScheme.getExtent();
          if (extent.contains(indices)) try {
--            final Tile tile = iterator(extent.reshape(indices, indices, 
true)).createFirstTile();
++            final int dimension = indices.length;
++            final long[] cellLow  = new long[dimension];
++            final long[] cellHigh = new long[dimension];
++            for (int i=0; i < dimension; i++) {
++                cellLow [i] = tileToCell(indices[i], i);
++                cellHigh[i] = addExact(cellLow[i], tileSize[i] - 1);    // 
Inclusive.
++            }
++            final Tile tile = builder(indices, indices, cellLow, 
cellHigh).createFirstTile();
              return (tile.getStatus() == TileStatus.MISSING) ? 
Optional.empty() : Optional.of(tile);
          } catch (ArithmeticException e) {
              throw new UnsupportedQueryException(e);
@@@ -348,106 -343,155 +386,224 @@@
       * <h4>Limitations</h4>
       * The current implementation limits the size of the given extent to 
{@link Integer#MAX_VALUE}
       * in each dimension. Note that this is a maximum size in tile indices, 
not in pixel coordinates.
++     * If the requested tile ranges exceed the capacity of the integer 
numbers used by this implementation,
++     * an {@link ArithmeticException} will be thrown either at the execution 
of this method,
++     * or during the execution of the returned stream.
       *
--     * @param  indiceRanges  ranges of tile indices in all dimensions, or 
{@code null} for all tiles.
++     * <p>Another limitation is that the tile size in all dimensions other 
than <var>X</var> and <var>Y</var>
++     * must be one, otherwise an {@link SubspaceNotSpecifiedException} will 
be thrown.</p>
++     *
++     * @param  tileRanges  ranges of tile indices in all dimensions, or 
{@code null} for all tiles.
       * @param  parallel  {@code true} for a parallel stream (if supported), 
or {@code false} for a sequential stream.
       * @return stream of tiles, excluding missing tiles.
       * @throws DataStoreException if the tiles cannot be fetched in the given 
ranges of tile indexes.
++     * @throws SubspaceNotSpecifiedException if tile size in dimensions other 
than X and Y is greater than 1.
++     * @throws ArithmeticException if some coordinates are too large.
++     *         This exception may also happen latter, during the execution of 
the returned stream.
       */
      @Override
--    public Stream<Tile> getTiles(GridExtent indiceRanges, final boolean 
parallel) throws DataStoreException {
--        ArgumentChecks.ensureDimensionMatches("indiceRanges", 
tilingScheme.getDimension(), indiceRanges);
--        if (indiceRanges == null) {
--            indiceRanges = tilingScheme.getExtent();
++    public Stream<Tile> getTiles(GridExtent tileRanges, final boolean 
parallel) throws DataStoreException {
++        /*
++         * Argument check: "indiceRanges" is the name used in public API, but 
we use `tileRanges` in this
++         * implementation for avoiding confusion with `cellRanges`. No need 
to expose this change to user.
++         */
++        ArgumentChecks.ensureDimensionMatches("indiceRanges", 
tilingScheme.getDimension(), tileRanges);
++        if (tileRanges == null) {
++            tileRanges = tilingScheme.getExtent();
          }
-         try {
-             return StreamSupport.stream(iterator(indiceRanges).iterator(), 
parallel);
-         } catch (ArithmeticException e) {
-             throw new UnsupportedQueryException(e);
 -
++        /*
++         * Gets the bounds of the images to read. If deferred reading is 
supported,
++         * we can expand to the bounds of the whole coverage in order to 
perform a
++         * read operation (deferred) only once. The bounds are intersected 
with the
++         * coverage extent.
++         */
++        @SuppressWarnings("LocalVariableHidesMemberVariable")
+         final var coverage = coverage();
 -        for (int dim = 0 ; dim < indiceRanges.getDimension(); dim++) {
 -            if (dim != coverage.xDimension && dim != coverage.yDimension && 
dim > 1) {
 -                return slice(indiceRanges, coverage.xDimension, 
coverage.yDimension, parallel);
++        final int xDimension = coverage.xDimension;
++        final int yDimension = coverage.yDimension;
++        final GridExtent cellRanges = coverage.getGridGeometry().getExtent();
++        final int dimension = cellRanges.getDimension();
++        final var cellLow  = new long[dimension];
++        final var tileLow  = new long[dimension];
++        final var cellHigh = new long[dimension];
++        final var tileHigh = new long[dimension];
++        boolean is2D = true;
++        for (int i=0; i<dimension; i++) {
++            // Intersection between request and available data, in cell 
coordinates.
++            cellLow [i] = max(cellRanges.getLow (i), 
tileToCell(tileRanges.getLow(i), i));
++            cellHigh[i] = min(cellRanges.getHigh(i), 
decrementExact(tileToCell(incrementExact(tileRanges.getHigh(i)), i)));
++            final long span = cellHigh[i] - cellLow[i] + 1;
++            if (span < 0 || span > Integer.MAX_VALUE) {
++                throw new 
ArithmeticException(resource.errors().getString(Errors.Keys.IntegerOverflow_1, 
Integer.SIZE));
++            }
++            // Convert back to tile indices after intersection.
++            tileLow [i] = cellToTile(cellLow [i], i);
++            tileHigh[i] = cellToTile(cellHigh[i], i);
++            /*
++             * Special cases for (X,Y) dimensions: if deferred loading is 
enabled, expand the request
++             * to the maximum size that we can handle with 32-bits integers. 
This is okay because the
++             * actual loading will not happen at `read(…)` invocation time. 
Note that we do not expand
++             * `tileLow` and `tileHigh` because they should stay bounded to 
user's request.
++             */
++            if (i != xDimension && i != yDimension) {
++                is2D &= (cellLow[i] == cellHigh[i]);
++            } else if (coverage.deferredTileReading) {
++                final long remain = min(cellRanges.getSize(i), 
Integer.MAX_VALUE) - span;
++                final long after  = min(remain / 2, cellRanges.getHigh(i) - 
cellHigh[i]);
++                final long before = min(remain - after, cellLow[i] - 
cellRanges.getLow(i));
++                cellLow [i] -= before;
++                cellHigh[i] += after;
+             }
          }
 -
 -        assert indiceRanges.getDegreesOfFreedom() <= 2 : "This code should 
only be reached if requested extent is 2D";
 -        try {
 -            return StreamSupport.stream(iterator(indiceRanges).iterator(), 
parallel);
 -        } catch (ArithmeticException e) {
 -            throw new UnsupportedQueryException(e);
++        if (is2D) {
++            // This code should only be reached if the requested extent is 2D.
++            assert tileRanges.getDegreesOfFreedom() <= 
TiledGridCoverageResource.BIDIMENSIONAL;
++            return StreamSupport.stream(builder(tileLow, tileHigh, cellLow, 
cellHigh).iterator(), parallel);
+         }
 -    }
 -
 -    /**
 -     * Split given extent in a lazy sequence of 2D extents. For each 2D slice,
 -     * we query all tiles contained in user requested area.
 -     *
 -     * @param indiceRanges N-D tile extent to slice and to load tiles for.
 -     * @param xDim X dimension of the coverage, first axis of the 2D part to 
preserve in slices.
 -     * @param yDim Y dimension of the coverage, second axis of the 2D part to 
preserve in slices.
 -     * @param parallel True if we want a parallel stream returned, false 
otherwise.
 -     * @return A lazy sequence of all tiles contained in the given tile range.
 -     */
 -    private Stream<Tile> slice(GridExtent indiceRanges, int xDim, int yDim, 
boolean parallel) {
 -        final var slicingStart = indiceRanges.getLow().getCoordinateValues();
 -        final var slicingEnd   = indiceRanges.getHigh().getCoordinateValues();
 -        final long xMax = slicingEnd[xDim];
 -        final long yMax = slicingEnd[yDim];
 -        slicingEnd[xDim] = slicingStart[xDim];
 -        slicingEnd[yDim] = slicingStart[yDim];
 -
 -        final var slicingExtent = new GridExtent(null, slicingStart, 
slicingEnd, true);
 -        // NOTE: not sure here, but depending on stream fork policy,
 -        // allowing parallel extents ould hurt performance,
 -        // because it potentially allows to load tiles from different slices 
in parallel.
 -        // As this class image caching strategy is based on 2D extents,
 -        // we instead push parallelism down on tile loading level directly 
(see flatMap block).
 -        return slicingExtent.latticePointStream(false)
 -                .map(slicePoint -> {
 -                    final var sliceHigh = Arrays.copyOf(slicePoint, 
slicePoint.length);
 -                    sliceHigh[xDim] = xMax;
 -                    sliceHigh[yDim] = yMax;
 -                    return new GridExtent(null, slicePoint, sliceHigh, true);
 -                })
 -                .flatMap(slice -> {
++        /*
++         * Splits the given extent in a lazy sequence of 2D extents.
++         * For each 2D slice, we query all tiles contained in the user 
requested tile ranges.
++         *
++         * Note: the iteration over slices is executed in sequential order 
even if `parallel` is true.
++         * Allowing parallel iteration over slices could hurt performance, 
because it would cause the
++         * loading of rendered images from different slices in parallel. As 
this class `image` caching
++         * strategy is based on 2D extents, we instead push parallelism down 
on tile loading level directly
++         * (see `flatMap` block).
++         */
++        final long[] tileHighInExtraDimensions = tileHigh.clone();
++        tileHighInExtraDimensions[xDimension] = tileLow[xDimension];
++        tileHighInExtraDimensions[yDimension] = tileLow[yDimension];
++        return tileRanges.reshape(tileLow, tileHighInExtraDimensions, 
true).latticePointStream(false)
++                .flatMap((final long[] sliceLow) -> {
++                    final long[] sliceHigh = sliceLow.clone();
++                    sliceHigh[xDimension] = tileHigh[xDimension];
++                    sliceHigh[yDimension] = tileHigh[yDimension];
++                    for (int i=0; i<sliceLow.length; i++) {
++                        if (i != xDimension && i != yDimension) {
++                            cellLow [i] = tileToCell(sliceLow[i], i);
++                            cellHigh[i] = addExact(cellLow[i], tileSize[i] - 
1);    // Inclusive.
++                        }
++                    }
++                    final IteratorBuilder builder;
+                     try {
 -                        return 
StreamSupport.stream(iterator(slice).iterator(), parallel);
++                        builder = builder(sliceLow, sliceHigh, cellLow, 
cellHigh);
+                     } catch (DataStoreException e) {
+                         throw new BackingStoreException("Cannot load tiles 
for 2D extent", e);
+                     }
++                    return StreamSupport.stream(builder.iterator(), parallel);
+                 });
      }
  
      /**
--     * Creates an object which can be used for retrieving a single tile or a 
stream tiles.
++     * Creates an object which can be used for retrieving a single tile or a 
stream of tiles.
++     * All arguments in this method are in the coordinate system of the 
{@linkplain #coverage}.
++     * Note that a translation may exist between the coverage and the 
{@linkplain #image}.
++     *
++     * <p>The cell indices may describe a region larger to the tile indices 
if deferred loading
++     * of tiles is enabled. In such case, we will prepare more tiles than 
what user requested,
++     * but will iterate over only the requested tiles.</p>
       *
--     * @param  indiceRanges  ranges of tile indices in all dimensions, or 
{@code null} for all tiles.
++     * @param  tileLow   tile index (inclusive) of the first valid tile 
requested by the user.
++     * @param  tileHigh  tile index (inclusive) of the last valid tile 
requested by the user.
++     * @param  cellLow   cell index (inclusive) of the first cell to load if 
necessary.
++     * @param  cellHigh  cell index (inclusive) of the last cell to load if 
necessary.
       * @return a request which can be used for getting a tile or a stream of 
tiles in the given region.
       * @throws DataStoreException if the tiles cannot be fetched in the given 
ranges of tile indexes.
       * @throws ArithmeticException if coordinate computation exceeds the 
capacity of 64-bits integers.
++     * @throws SubspaceNotSpecifiedException if tile size in dimensions other 
than X and Y is greater than 1.
       */
--    private synchronized IterationDomain<Tile> iterator(final GridExtent 
indiceRanges) throws DataStoreException {
++    private synchronized IteratorBuilder builder(final long[] tileLow, final 
long[] tileHigh,
++                                                 final long[] cellLow, final 
long[] cellHigh)
++            throws DataStoreException
++    {
          @SuppressWarnings("LocalVariableHidesMemberVariable")
          final TiledGridCoverage coverage = coverage();
-         boolean retry = false;
-         do {    // This loop will be executed only 1 or 2 times.
-             if (image != null) {
-                 final long xmin, ymin, xmax, ymax;
-                 xmin = Math.subtractExact(indiceRanges.getLow 
(coverage.xDimension), imageToTileX);
-                 xmax = 
Math.subtractExact(indiceRanges.getHigh(coverage.xDimension), imageToTileX);
-                 final long x0 = image.getMinTileX();
-                 if (xmin >= x0 && xmax < x0 + image.getNumXTiles()) {
-                     ymin = Math.subtractExact(indiceRanges.getLow 
(coverage.yDimension), imageToTileY);
-                     ymax = 
Math.subtractExact(indiceRanges.getHigh(coverage.yDimension), imageToTileY);
-                     final long y0 = image.getMinTileY();
-                     if (ymin >= y0 && ymax < y0 + image.getNumYTiles()) {
-                         return new Iterator(Math.toIntExact(xmin),
-                                             Math.toIntExact(ymin),
-                                             Math.toIntExact(xmax),
-                                             Math.toIntExact(ymax));
-                     }
-                 }
-             }
 -        assert Arrays.equals(indiceRanges.getSubspaceDimensions(2), new int[] 
{coverage.xDimension, coverage.yDimension})
 -                : "Iterator can only return tiles for a 2D slice over 
coverage XY dimensions.";
 -
 -        // Returns currently cached image if it
 -        final var indiceRangesLow = 
indiceRanges.getLow().getCoordinateValues();
 -        if (image == null || !imageTilingExtent.contains(indiceRanges)) {
--            /*
--             * Gets the bounds of the image to read. If deferred reading is 
supported,
--             * we can expand to the bounds of the whole coverage in order to 
perform a
--             * read operation (deferred) only once.
--             */
--            final GridExtent extent = coverage.getGridGeometry().getExtent();
--            final int dimension = extent.getDimension();
--            final var low  = new long[dimension];
--            final var high = new long[dimension];
--            for (int i=0; i<dimension; i++) {
--                final long limit = Math.incrementExact(extent.getHigh(i));
--                high[i] = Math.min(limit, 
tileToCell(Math.incrementExact(indiceRanges.getHigh(i)), i));
-                 low [i] = Math.max(extent.getLow(i), 
tileToCell(indiceRanges.getLow(i), i));
 -                low [i] = Math.max(extent.getLow(i), 
tileToCell(indiceRangesLow[i], i));
--                final long span = high[i] - low[i];
--                if (span < 0 || span > Integer.MAX_VALUE) {
--                    throw new 
ArithmeticException(resource.errors().getString(Errors.Keys.IntegerOverflow_1, 
Integer.SIZE));
--                }
--                if (coverage.deferredTileReading) {
--                    final long remain = Math.min(extent.getSize(i), 
Integer.MAX_VALUE) - span;
--                    final long after  = Math.min(remain >> 1, limit - 
high[i]);
--                    final long before = Math.min(remain - after, low[i] - 
extent.getLow(i));
--                    low [i] -= before;
--                    high[i] += after;
--                }
++        final int xDimension = coverage.xDimension;
++        final int yDimension = coverage.yDimension;
++        final long[] slice = cellLow.clone();
++        slice[xDimension] = 0;
++        slice[yDimension] = 0;
++        if (image != null) {
++            final long tx0, ty0;
++            if (!Arrays.equals(imageSliceIndices, slice)
++                    || tileLow [xDimension] < (tx0 = image.getMinTileX() + 
imageToTileX) || tileHigh[xDimension] >= tx0 + image.getNumXTiles()
++                    || tileLow [yDimension] < (ty0 = image.getMinTileY() + 
imageToTileY) || tileHigh[yDimension] >= ty0 + image.getNumYTiles())
++            {
++                image = null;
              }
-             image = coverage.render(extent.reshape(low, high, false));
 -
 -
 -            final var imagePixelExtent = extent.reshape(low, high, false);
 -            imageTilingExtent = imagePixelExtent
 -                    .translate(Arrays.stream(tileToCell).map(v -> 
-v).toArray())
 -                    .subsample(Arrays.stream(tileSize).mapToLong(v -> 
v).toArray());
 -            image = coverage.render(imagePixelExtent);
--            imageToTileX = low[coverage.xDimension];
--            imageToTileY = low[coverage.yDimension];
-         } while ((retry = !retry) == true);
-         throw new InternalDataStoreException();     // Should never happen.
+         }
 -
 -        return new Iterator(
 -                
Math.toIntExact(Math.subtractExact(indiceRangesLow[coverage.xDimension], 
imageToTileX)),
 -                
Math.toIntExact(Math.subtractExact(indiceRangesLow[coverage.yDimension], 
imageToTileY)),
 -                
Math.toIntExact(Math.subtractExact(indiceRanges.getHigh(coverage.xDimension), 
imageToTileX)),
 -                
Math.toIntExact(Math.subtractExact(indiceRanges.getHigh(coverage.yDimension), 
imageToTileY)),
 -                indiceRangesLow
 -        );
++        if (image == null) {
++            image = 
coverage.render(coverage.getGridGeometry().getExtent().reshape(cellLow, 
cellHigh, true));
++            imageToTileX = cellToImageTile(xDimension, cellLow, 
image.getTileGridXOffset(), image.getTileWidth());
++            imageToTileY = cellToImageTile(yDimension, cellLow, 
image.getTileGridYOffset(), image.getTileHeight());
++            imageSliceIndices = slice;
++        }
++        return new IteratorBuilder(toIntExact(subtractExact(tileLow 
[xDimension], imageToTileX)),
++                                   toIntExact(subtractExact(tileLow 
[yDimension], imageToTileY)),
++                                   
toIntExact(subtractExact(tileHigh[xDimension], imageToTileX)),
++                                   
toIntExact(subtractExact(tileHigh[yDimension], imageToTileY)));
      }
  
      /**
       * Converts the give tile coordinate in the given dimension to cell 
coordinates.
++     * The cell and tile coordinates are in the {@linkplain #coverage} space, 
which
++     * may be translated compared to the coordinates of the rendered image.
       *
       * @param  coordinate  the tile coordinate to convert.
       * @param  dimension   the dimension of the coordinate to convert.
--     * @return the cell coordinate.
++     * @return the cell coordinate in the grid coverage.
       * @throws ArithmeticException if the result overflows the capacity of 
64-bits integers.
       */
      private long tileToCell(long coordinate, final int dimension) {
          if (dimension < tileSize.length) {
--            coordinate = Math.multiplyExact(coordinate, tileSize[dimension]);
++            coordinate = multiplyExact(coordinate, tileSize[dimension]);
          }
--        return Math.addExact(tileToCell[dimension], coordinate);
++        return addExact(tileToCell[dimension], coordinate);
++    }
++
++    /**
++     * Converts the give cell coordinate in the given dimension to tile 
coordinates.
++     * The cell and tile coordinates are in the {@linkplain #coverage} space, 
which
++     * may be translated compared to the coordinates of the rendered image.
++     *
++     * @param  coordinate  the cell coordinate to convert.
++     * @param  dimension   the dimension of the coordinate to convert.
++     * @return the tile coordinate in the grid coverage.
++     * @throws ArithmeticException if the result overflows the capacity of 
64-bits integers.
++     */
++    private long cellToTile(long coordinate, final int dimension) {
++        coordinate = subtractExact(coordinate, tileToCell[dimension]);
++        if (dimension < tileSize.length) {
++            coordinate = floorDiv(coordinate, tileSize[dimension]);
++        }
++        return coordinate;
++    }
++
++    /**
++     * Converts a coverage cell coordinates to the index of a tile in the 
rendered image.
++     * This is used for computing {@link #imageToTileX} and {@link 
#imageToTileY}.
++     *
++     * @param  dimension  the dimension of the coordinate to convert.
++     * @param  origin     lower values of the grid coordinates of the extent 
which has been requested for rendering.
++     * @param  offset     {@link RenderedImage#getTileGridXOffset()} or 
{@link RenderedImage#getTileGridYOffset()}.
++     * @param  size       {@link RenderedImage#getTileWidth()} or {@link 
RenderedImage#getTileHeight()}.
++     * @return difference between index of the tile in the coverage and index 
of the tile in the rendered image.
++     * @throws ArithmeticException if the result overflows the capacity of 
64-bits integers.
++     */
++    private long cellToImageTile(final int dimension, final long[] origin, 
final int offset, final int size) {
++        /*
++         * `cellToTile` is the tile coordinate in the grid coverage, assuming 
that tile indices start at (0,0).
++         * The corresponding tile coordinate in the image can be anything. 
Since `origin` gave the coordinates
++         * of pixel (0,0) in the rendered image, `cellToTile` corresponds to 
the rendered image tile which
++         * contains pixel (0,0). This is the tile at index `-tileGridoffset / 
tileSize` (note the minus sign).
++         */
++        return addExact(cellToTile(origin[dimension], dimension), 
floorDiv(offset, size));
      }
  
      /**
       * Factory for an iterator over tiles in ranges of user-specified tile 
indices.
       */
--    private final class Iterator extends IterationDomain<Tile> {
++    private final class IteratorBuilder extends IterationDomain<Tile> {
          /**
           * Snapshot of {@link ImageTileMatrix#image}.
           */
@@@ -458,19 -502,42 +614,42 @@@
           */
          private final long offsetX, offsetY;
  
+         /**
 -         * Dimension indices for the X and Y axes in the tile matrix 
coordinate system.
++         * Dimension indices for the <var>X</var> and <var>Y</var> axes in 
the tile matrix coordinate system.
+          */
 -        private final int xDim, yDim;
++        private final int xDimension, yDimension;
+ 
+         /**
 -         * Base template of tile indices.
 -         * Used when returning {@link Tile#getIndices() tile indices}.
 -         * Tiles will use these coordinates, replacing {link #xDim X} and 
{link #yDim Y} dimensions.
++         * Tile coordinates of the slice on which to iterate, except for 
values in X and Y dimensions.
++         * Used when returning {@linkplain Tile#getIndices() tile indices}.
++         * Tiles will use these coordinates, replacing {@link #xDimension X} 
and {@linkplain #yDimension Y} dimensions.
+          */
 -        private final long[] baseIndices;
++        private final long[] sliceIndices;
+ 
          /**
           * Creates a new request for tile iterators.
           *
-          * @param xmin  first column index of tiles, inclusive.
-          * @param xmin  first row index of tiles, inclusive.
-          * @param xmax  last column index of tiles, inclusive.
-          * @param ymax  last row index of tiles, inclusive.
 -         * @param xmin        first column index of tiles, inclusive.
 -         * @param ymin        first row index of tiles, inclusive.
 -         * @param xmax        last column index of tiles, inclusive.
 -         * @param ymax        last row index of tiles, inclusive.
 -         * @param baseIndices tile coordinate template.
 -         *                    It can be any valid coordinate of the tiles 
managed by this iterator.
 -         *                    It serves as base to build correct tile 
coordinate for each returned tile.
 -         *                    Each tile will clone this array and replace its 
X and Y indices with its own.
 -         *                    Therefore, it is important that it represent 
properly the "slice" of extra-dimensions
 -         *                    this iterator operates on.
++         * <h4>Iteration in a slice of a <var>n</var>-dimensional cube.</h4>
++         * The {@link #imageSliceIndices} field gives the tile coordinates of 
the slice on which to iterate.
++         * It can be any valid coordinates of the tiles managed by this 
iterator.
++         * It serves as base to build correct tile coordinates for each 
returned tile.
++         * Each tile will clone this array and replace its X and Y indices by 
its own.
++         * Therefore, it is important that it represents properly the 
coordinates of extra-dimensions
++         * this iterator operates on.
++         *
++         * @param minTileX   first column index of tiles, inclusive.
++         * @param minTileY   first row index of tiles, inclusive.
++         * @param maxTileX   last column index of tiles, inclusive.
++         * @param maxTileY   last row index of tiles, inclusive.
           */
-         Iterator(final int xmin, final int ymin, final int xmax, final int 
ymax) {
 -        Iterator(final int xmin, final int ymin, final int xmax, final int 
ymax,
 -                 final long[] baseIndices)
 -        {
--            super(xmin, ymin, xmax, ymax);
--            tiles   = image;
--            offsetX = imageToTileX;
--            offsetY = imageToTileY;
 -            xDim    = coverage.xDimension;
 -            yDim    = coverage.yDimension;
 -            this.baseIndices = baseIndices;
++        IteratorBuilder(final int minTileX, final int minTileY, final int 
maxTileX, final int maxTileY) {
++            super(minTileX, minTileY, maxTileX, maxTileY);
++            tiles        = image;
++            offsetX      = imageToTileX;
++            offsetY      = imageToTileY;
++            xDimension   = coverage.xDimension;
++            yDimension   = coverage.yDimension;
++            sliceIndices = imageSliceIndices;
          }
  
          /**
@@@ -491,7 -558,10 +670,10 @@@
  
                  /** Returns the indices of this tile in the {@code 
TileMatrix}. */
                  @Override public long[] getIndices() {
-                     return new long[] {offsetX + tileX, offsetY + tileY};
 -                    final long[] indices = baseIndices.clone();
 -                    indices[xDim] = offsetX + tileX;
 -                    indices[yDim] = offsetY + tileY;
++                    final long[] indices = sliceIndices.clone();
++                    indices[xDimension] = offsetX + tileX;
++                    indices[yDimension] = offsetY + tileY;
+                     return indices;
                  }
  
                  /** Returns information about whether the tile failed to 
load. */
@@@ -528,8 -598,8 +710,8 @@@
          final long[] high = new long[indices.length];
          for (int i=0; i<indices.length; i++) {
              final long size = (i < tileSize.length) ? tileSize[i] : 1;
--            low [i] = Math.addExact(tileToCell[i], 
Math.multiplyExact(indices[i], size));
--            high[i] = Math.addExact(low[i], size - 1);
++            low [i] = addExact(tileToCell[i], multiplyExact(indices[i], 
size));
++            high[i] = addExact(low[i], size - 1);
          }
          @SuppressWarnings("LocalVariableHidesMemberVariable")
          final TiledGridCoverage coverage = coverage();
diff --cc 
endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/IterationDomain.java
index 0f26f25fd7,0f26f25fd7..81556220a9
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/IterationDomain.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/IterationDomain.java
@@@ -42,18 -42,18 +42,18 @@@ abstract class IterationDomain<T> 
      /**
       * Minimal column index of the region on which to iterate.
       */
--    private final int xmin;
++    private final int minTileX;
  
      /**
       * Minimal row index of the region on which to iterate.
       */
--    private final int ymin;
++    private final int minTileY;
  
      /**
       * Number of columns on which to iterate.
--     * This is {@code (xmax + 1) - xmin} where {@code xmax} is inclusive.
++     * This is {@code (maxTileX + 1) - minTileX} where {@code maxTileX} is 
inclusive.
       */
--    private final long width;
++    private final long numXTiles;
  
      /**
       * Maximum value (exclusive) of the indexes of the tiles on which to 
iterate.
@@@ -64,16 -64,16 +64,16 @@@
      /**
       * Creates a new request for tile iterators.
       *
--     * @param xmin  first column index of tiles, inclusive.
--     * @param xmin  first row index of tiles, inclusive.
--     * @param xmax  last column index of tiles, inclusive.
--     * @param ymax  last row index of tiles, inclusive.
++     * @param minTileX  first column index of tiles, inclusive.
++     * @param minTileY  first row index of tiles, inclusive.
++     * @param maxTileX  last column index of tiles, inclusive.
++     * @param maxTileY  last row index of tiles, inclusive.
       */
--    protected IterationDomain(final int xmin, final int ymin, final int xmax, 
final int ymax) {
--        this.xmin  = xmin;
--        this.ymin  = ymin;
--        this.width = (xmax + 1L) - xmin;
--        this.limit = Math.multiplyExact(width, (ymax + 1L) - ymin);
++    protected IterationDomain(final int minTileX, final int minTileY, final 
int maxTileX, final int maxTileY) {
++        this.minTileX  = minTileX;
++        this.minTileY  = minTileY;
++        this.numXTiles = (maxTileX + 1L) - minTileX;
++        this.limit     = Math.multiplyExact(numXTiles, (maxTileY + 1L) - 
minTileY);
      }
  
      /**
@@@ -91,7 -91,7 +91,7 @@@
       * Creates the first tile, or returns {@code null} if the tile is missing.
       */
      final T createFirstTile() {
--        return createTile(xmin, ymin);
++        return createTile(minTileX, minTileY);
      }
  
      /**
@@@ -121,7 -121,7 +121,7 @@@
           * Creates a new iterator which will initially traverse all tiles.
           */
          Iterator() {
--            index = Math.multiplyExact(width, ymin);
++            index = Math.multiplyExact(numXTiles, minTileY);
              increment = 1;
          }
  
@@@ -188,8 -188,8 +188,8 @@@
          @Override
          public boolean tryAdvance(final Consumer<? super T> action) {
              while (index < limit) {
--                final int tileY = Math.toIntExact(index / width);
--                final int tileX = Math.toIntExact(index % width + xmin);
++                final int tileY = Math.toIntExact(index / numXTiles);
++                final int tileX = Math.toIntExact(index % numXTiles + 
minTileX);
                  final T   tile  = createTile(tileX, tileY);
                  index += increment;
                  if (tile != null) {
@@@ -208,8 -208,8 +208,8 @@@
          @Override
          public void forEachRemaining(final Consumer<? super T> action) {
              while (index < limit) {
--                final int tileY = Math.toIntExact(index / width);
--                final int tileX = Math.toIntExact(index % width + xmin);
++                final int tileY = Math.toIntExact(index / numXTiles);
++                final int tileX = Math.toIntExact(index % numXTiles + 
minTileX);
                  final T   tile  = createTile(tileX, tileY);
                  index += increment;
                  if (tile != null) {
diff --cc 
endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TileMatrix.java
index 257ef1afb2,257ef1afb2..e3e8abcc67
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TileMatrix.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TileMatrix.java
@@@ -228,6 -228,6 +228,8 @@@ public interface TileMatrix 
       * @return stream of tiles, excluding {@linkplain TileStatus#MISSING 
missing} tiles.
       *         Iteration order of the stream may vary from one implementation 
to another and from one call to another.
       * @throws DataStoreException if the tiles cannot be fetched in the given 
ranges of tile indexes.
++     * @throws ArithmeticException if some coordinates are too large.
++     *         This exception may also happen latter, during the execution of 
the returned stream.
       */
      Stream<Tile> getTiles(GridExtent indiceRanges, boolean parallel) throws 
DataStoreException;
  }
diff --cc 
endorsed/src/org.apache.sis.storage/test/org/apache/sis/storage/tiling/ImageTileMatrixTest.java
index 0000000000,899f80e4b7..6862a52b74
mode 000000,100644..100644
--- 
a/endorsed/src/org.apache.sis.storage/test/org/apache/sis/storage/tiling/ImageTileMatrixTest.java
+++ 
b/endorsed/src/org.apache.sis.storage/test/org/apache/sis/storage/tiling/ImageTileMatrixTest.java
@@@ -1,0 -1,288 +1,371 @@@
+ /*
+  * 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.storage.tiling;
+ 
 -import java.awt.image.BufferedImage;
 -import java.awt.image.ColorModel;
++import java.util.List;
++import java.util.Arrays;
+ import java.awt.image.Raster;
 -import java.awt.image.SampleModel;
++import java.awt.image.RenderedImage;
+ import java.awt.image.WritableRaster;
 -import java.util.Arrays;
 -import java.util.List;
 -import org.apache.sis.storage.GridCoverageResource;
+ import org.opengis.metadata.spatial.DimensionNameType;
+ import org.opengis.util.GenericName;
+ import org.apache.sis.coverage.SampleDimension;
+ import org.apache.sis.coverage.grid.GridExtent;
+ import org.apache.sis.coverage.grid.GridGeometry;
+ import org.apache.sis.coverage.grid.PixelInCell;
 -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.storage.DataStoreException;
++import org.apache.sis.storage.GridCoverageResource;
+ import org.apache.sis.util.iso.Names;
+ 
+ // Test dependencies
+ import org.junit.jupiter.api.Test;
++import org.junit.jupiter.api.TestInstance;
+ import static org.junit.jupiter.api.Assertions.*;
+ import org.apache.sis.test.TestCase;
+ import org.apache.sis.referencing.crs.HardCodedCRS;
++import static org.apache.sis.test.Assertions.assertSingleton;
+ 
+ 
+ /**
+  * Verify behavior of {@link ImageTileMatrix} in a 3D space.
 - * </br>
+  * This test creates a {@link MockTiledResource coverage mockup} that 
contains two 2D slices.
+  * Each slice contains {@link #NUM_COLS} tile columns and {@link #NUM_ROWS} 
tile rows.
+  * Each tile is a single band image whose diagonal is filled with its tile 
indices.
+  *
+  * @author  Alexis Manin (Geomatys)
++ * @author  Martin Desruisseaux (Geomatys)
+  */
+ @SuppressWarnings("exports")
++@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+ public final class ImageTileMatrixTest extends TestCase {
++    /**
++     * Number of dimensions of the coverage in this test. The dimension of X 
and Y are 0 and 1 respectively.
++     * These dimensions should use different values for increasing the 
chances to detect wrong dimension index.
++     */
++    private static final int DIMENSION = 3;
+ 
 -    private static final BufferedImage MODEL = new BufferedImage(4, 4, 
BufferedImage.TYPE_BYTE_GRAY);
 -    private static final int TILE_WIDTH  = 4;
 -    private static final int TILE_HEIGHT = 4;
 -    private static final int NUM_COLS    = 3;
 -    private static final int NUM_ROWS    = 3;
 -    private static final int NUM_SLICES  = 2;
++    /**
++     * Size of all tiles in number of pixels.
++     * Image should be non-square for increasing the chances to detect bug in 
use of width and height.
++     */
++    private static final int TILE_WIDTH  = 4,
++                             TILE_HEIGHT = 3;
++
++    /**
++     * Number of tiles in each dimension to be tested.
++     */
++    private static final int NUM_COLS    = 3,
++                             NUM_ROWS    = 5,
++                             NUM_SLICES  = 2;
++
++    /**
++     * Value returned by {@link MockTiledResource#getGridGeometry()}.
++     * Shared by each test method since this value is unmodifiable.
++     */
++    private final GridGeometry gridGeometry;
+ 
+     /**
 -     * Test that a tile from a 3D dataset can return its associated resource.
 -     * This is an anti-regression test because of a bug encountered while 
fetching tile resource.
++     * Value returned by {@link MockTiledResource#getSampleDimensions()}.
++     * Shared by each test method since this value is unmodifiable.
++     */
++    private final List<SampleDimension> sampleDimensions;
++
++    /**
++     * Creates a new text case.
++     */
++    public ImageTileMatrixTest() {
++        final var extent = new GridExtent(
++                new DimensionNameType[] {
++                    DimensionNameType.COLUMN,
++                    DimensionNameType.ROW,
++                    DimensionNameType.VERTICAL
++                },
++                null,   // Means all zeros.
++                new long[] {
++                    NUM_COLS * TILE_WIDTH,
++                    NUM_ROWS * TILE_HEIGHT,
++                    NUM_SLICES
++                },
++                false);
++
++        gridGeometry = new GridGeometry(extent, PixelInCell.CELL_CORNER,
++                MathTransforms.scale(0.5, 0.5, 100), HardCodedCRS.WGS84_3D);
++
++        sampleDimensions = List.of(new 
SampleDimension.Builder().setName("data").build());
++    }
++
++    /**
++     * Tests that a tile from a 3D dataset can return its associated resource.
++     *
++     * @throws DataStoreException if an error occurred while querying the 
tiles.
+      */
+     @Test
+     public void testGetIndividualTiles3D() throws DataStoreException {
 -        final var matrix = get3DMockupTileMatrix();
++        testGetIndividualTiles3D(get3DMockupTileMatrix(), false);
++    }
++
++    /**
++     * Implementation of {@link #testGetIndividualTiles3D()} and {@link 
#testParallelism()}.
++     */
++    private void testGetIndividualTiles3D(final TileMatrix matrix, final 
boolean parallel) throws DataStoreException {
+         final var tilingExtent = matrix.getTilingScheme().getExtent();
 -        assertEquals(3, tilingExtent.getDimension());
 -        tilingExtent.latticePointStream(true)
++        assertEquals(DIMENSION, tilingExtent.getDimension());
++        tilingExtent.latticePointStream(parallel)
+                 .map(tileCoord -> loadTile(tileCoord, matrix))
+                 .forEach(ImageTileMatrixTest::checkTileContent);
+     }
+ 
+     /**
 -     * Check another path to obtain and validate tiles: get a batch of (all 
in fact) tiles using {@link TileMatrix#getTiles(GridExtent, boolean)}
++     * Checks another path to obtain and validate tiles.
++     * This path gets a batch of (all in fact) tiles using {@link 
TileMatrix#getTiles(GridExtent, boolean)}.
++     *
++     * @throws DataStoreException if an error occurred while querying the 
tiles.
+      */
+     @Test
+     public void testGetTileBatch3D() throws DataStoreException {
 -        final var matrix = get3DMockupTileMatrix();
 -        matrix.getTiles(null, true)
 -              .forEach(ImageTileMatrixTest::checkTileContent);
++        testGetTileBatch3D(get3DMockupTileMatrix(), false);
+     }
+ 
+     /**
 -     * Check tile content by querying a full rendering on each slice in the 
3D coverage mockup.
++     * Implementation of {@link #testGetTileBatch3D()} and {@link 
#testParallelism()}.
++     */
++    private void testGetTileBatch3D(final TileMatrix matrix, final boolean 
parallel) throws DataStoreException {
++        matrix.getTiles(null, 
parallel).forEach(ImageTileMatrixTest::checkTileContent);
++    }
++
++    /**
++     * Checks tile content by querying a full rendering on each slice in the 
3D coverage mockup.
+      * The aim is to ensure that coverage rendering properly returns a tiled 
image,
+      * and that each tile in the rendered image gives back its associated 
Tile content.
+      * Said otherwise, it checks that there's no tile mismatch or mixup when 
using ImageIO accessor.
++     *
++     * @throws DataStoreException if an error occurred while querying the 
tiles.
+      */
+     @Test
+     public void testGetTilesViaRender() throws DataStoreException {
++        testGetTilesViaRender(false);
++    }
++
++    /**
++     * Implementation of {@link #testGetTilesViaRender()} and {@link 
#testParallelism()}.
++     */
++    private void testGetTilesViaRender(final boolean parallel) throws 
DataStoreException {
+         final var resource = new MockTiledResource();
 -        final var coverage = resource.read((GridGeometry) null);
++        final var coverage = resource.read(null, null);
+         final var overallExtent = coverage.getGridGeometry().getExtent();
 -        overallExtent.resize(1, 1).latticePointStream(true)
++        overallExtent.resize(1, 1).latticePointStream(parallel)
+                 .forEach(temporalSlice -> {
 -                    var sliceHigh = temporalSlice.clone();
++                    final long[] sliceHigh = temporalSlice.clone();
+                     sliceHigh[0] = overallExtent.getHigh(0);
+                     sliceHigh[1] = overallExtent.getHigh(1);
+                     final var renderExtent = new GridExtent(null, 
temporalSlice, sliceHigh, true);
 -
 -                    final var image = coverage.render(renderExtent);
++                    final RenderedImage image = coverage.render(renderExtent);
+                     for (int col = 0; col < NUM_COLS; col++) {
+                         for (int row = 0; row < NUM_ROWS; row++) {
 -                            final var tileIndices = temporalSlice.clone();
 -                            tileIndices[0] = col;
 -                            tileIndices[1] = row;
 -                            final var tileRaster = image.getTile(col, row);
 -                            checkTileOrigin(tileRaster, tileIndices);
 -                            checkTileRaster(tileRaster, tileIndices);
++                            final Raster tileRaster = image.getTile(col, row);
++                            assertEquals(col * TILE_WIDTH,  
tileRaster.getMinX());
++                            assertEquals(row * TILE_HEIGHT, 
tileRaster.getMinY());
++                            checkTileRaster(tileRaster, new long[] {col, row, 
temporalSlice[2]});
+                         }
+                     }
+                 });
+     }
+ 
 -    private void checkTileOrigin(Raster tileRaster, long[] tileIndices) {
 -        final var expectedTileOrigin = new int[] {
 -                Math.toIntExact(Math.multiplyExact(tileIndices[0], 
TILE_WIDTH)),
 -                Math.toIntExact(Math.multiplyExact(tileIndices[1], 
TILE_HEIGHT))
 -        };
 -        final var actualTileOrigin = new int[] { tileRaster.getMinX(), 
tileRaster.getMinY() };
 -        assertArrayEquals(expectedTileOrigin, actualTileOrigin,
 -                () -> String.format(
 -                        "Tile (%s): raster origin does not match rendering 
location. Expected: (%s) but was (%s)",
 -                        Arrays.toString(tileIndices), 
Arrays.toString(expectedTileOrigin), Arrays.toString(actualTileOrigin)
 -                )
 -        );
++    /**
++     * Same tests as {@link #testGetIndividualTiles3D()}, {@link 
#testGetTileBatch3D()}
++     * and {@link #testGetTilesViaRender()} but executed in parallel.
++     *
++     * @throws DataStoreException if an error occurred while querying the 
tiles.
++     */
++    @Test
++    public void testParallelism() throws DataStoreException {
++        final var matrix = get3DMockupTileMatrix();
++        testGetIndividualTiles3D(matrix, true);
++        testGetTileBatch3D(matrix, true);
++        testGetTilesViaRender(true);
+     }
+ 
++    /**
++     * Returns the tile matrix from the three-dimensional resource mock.
++     *
++     * @return tile matrix from the three-dimensional resource mock.
++     * @throws DataStoreException should never be thrown with the mock.
++     */
+     private TileMatrix get3DMockupTileMatrix() throws DataStoreException {
+         final var resource = new MockTiledResource();
 -        final var tileMatrixSets = resource.getTileMatrixSets();
 -        assertFalse(tileMatrixSets.isEmpty());
 -        final var tms = tileMatrixSets.iterator().next();
 -        final var tileMatrixIterator = 
tms.getTileMatrices().values().iterator();
 -        assertTrue(tileMatrixIterator.hasNext());
 -        final var matrix = tileMatrixIterator.next();
 -        assertFalse(tileMatrixIterator.hasNext());
 -        return matrix;
++        final var tms = assertSingleton(resource.getTileMatrixSets());
++        return assertSingleton(tms.getTileMatrices().values());
+     }
+ 
+     /**
 -     * Load a specific tile from the given tile matrix.
 -     * This method expects that tile content will respect this test
++     * Loads the tile at the specified tile indices. This method does not 
perform any verification.
++     * Caller will typically invoke {@link #checkTileContent(Tile)} after 
this method.
++     *
++     * @param  tileIndices  indices of the tile to load.
++     * @param  matrix       tile matrix from which to load the time.
+      */
 -    private Tile loadTile(long[] requestedTileIndices, TileMatrix matrix) {
++    private static Tile loadTile(final long[] tileIndices, final TileMatrix 
matrix) {
+         try {
 -            final var optTile = matrix.getTile(requestedTileIndices);
 -            assertTrue(optTile.isPresent());
 -            final var tile = optTile.get();
 -            final var tIndices = tile.getIndices();
 -            assertArrayEquals(requestedTileIndices, tIndices, "Tile indices 
differ from request");
++            final Tile tile = 
matrix.getTile(tileIndices).orElseThrow(AssertionError::new);
++            assertArrayEquals(tileIndices, tile.getIndices(), "Tile indices 
differ from request.");
+             return tile;
+         } catch (DataStoreException e) {
 -            throw new AssertionError("Extraction of tile ("+ 
Arrays.toString(requestedTileIndices)+") failed", e);
++            // Because this method is invoked in lambda function, avoid 
checked exceptions.
++            throw new AssertionError("Cannot read tile (" + 
Arrays.toString(tileIndices) + ").", e);
+         }
+     }
+ 
 -    private static void checkTileContent(Tile tile) {
++    /**
++     * Verifies the properties and the sample values stored in the given tile.
++     * These sample values should contain the tile coordinates on a diagonal 
starting from the corner.
++     *
++     * @param  tile  the tile to verify.
++     */
++    private static void checkTileContent(final Tile tile) {
+         final var tileIndices = tile.getIndices();
++        assertEquals(DIMENSION, tileIndices.length);
++        assertEquals(TileStatus.EXISTS, tile.getStatus());
++        final RenderedImage tileImage;
+         try {
 -            assertEquals(TileStatus.EXISTS, tile.getStatus());
 -            final var tResource = tile.getResource();
 -            assertNotNull(tResource);
 -            if (!(tResource instanceof GridCoverageResource 
tileGridResource)) {
 -                throw new AssertionError("Tile resource is not a grid 
resource");
 -            }
 -            final var tileImage = tileGridResource.read(null).render(null);
 -            assertEquals(TILE_WIDTH, tileImage.getWidth());
 -            assertEquals(TILE_HEIGHT, tileImage.getHeight());
 -
 -            assertEquals(1, tileImage.getNumXTiles() * 
tileImage.getNumYTiles(),
 -                    "Tile image should contain only a single raster tile.");
 -            final var tileRaster = tileImage.getTile(tileImage.getMinTileX(), 
tileImage.getMinTileY());
 -            checkTileRaster(tileRaster, tileIndices);
++            var resource = assertInstanceOf(GridCoverageResource.class, 
tile.getResource());
++            tileImage = resource.read(null, null).render(null);
+         } catch (DataStoreException e) {
 -            fail("Validation of tile ("+ Arrays.toString(tileIndices)+") 
failed", e);
++            // Because this method is invoked in lambda function, avoid 
checked exceptions.
++            throw new AssertionError("Cannot read tile (" + 
Arrays.toString(tileIndices) + ").", e);
+         }
++        assertEquals(TILE_WIDTH,  tileImage.getWidth());
++        assertEquals(TILE_HEIGHT, tileImage.getHeight());
++        assertEquals(1, tileImage.getNumXTiles());
++        assertEquals(1, tileImage.getNumYTiles());
++        checkTileRaster(tileImage.getTile(tileImage.getMinTileX(), 
tileImage.getMinTileY()), tileIndices);
+     }
+ 
 -    private static void checkTileRaster(Raster tileRaster, long[] 
tileIndices) {
 -        for (int i=0 ; i < tileIndices.length ; i++) {
++    /**
++     * Verifies the sample values stored on the diagonal starting in the tile 
upper-left corner.
++     * These sample values should contain the tile coordinates on a diagonal 
starting from the corner.
++     *
++     * @param  tile         the tile to verify.
++     * @param  tileIndices  expected tile coordinates.
++     */
++    private static void checkTileRaster(final Raster tile, final long[] 
tileIndices) {
++        assertEquals(DIMENSION, tileIndices.length);
++        for (int i=0 ; i<DIMENSION; i++) {
+             final var index = i;
 -            assertEquals(tileIndices[i], 
tileRaster.getSample(tileRaster.getMinX() + i, tileRaster.getMinY() + i, 0),
 -                    () -> String.format("Tile sample at coordinate (%1$d, 
%1$d) should be the tile coordinate at dimension %1$d", index));
++            final int x = tile.getMinX() + i;
++            final int y = tile.getMinY() + i;
++            assertEquals(tileIndices[i], tile.getSample(x, y, 0),
++                    () -> String.format("Tile sample at coordinate (%d, %d) 
should be the tile coordinate at dimension %d.", x, y, index));
+         }
+     }
+ 
+     /**
+      * A minimal {@link TiledGridCoverageResource} for testing.
++     * The size of all rasters is {@value #TILE_WIDTH} × {@value 
#TILE_HEIGHT} pixels.
++     * The tile coordinates are stored as sample values on the diagonal 
starting from
++     * the upper-left corner.
+      */
 -    private static final class MockTiledResource extends 
TiledGridCoverageResource {
 -
 -        private final GridGeometry gridGeometry;
 -        private final List<SampleDimension> sampleDimensions;
 -
++    private final class MockTiledResource extends TiledGridCoverageResource {
++        /**
++         * Creates a new resource mock.
++         */
+         MockTiledResource() {
+             super(null);
 -            final var extent = new GridExtent(
 -                    new DimensionNameType[] {
 -                        DimensionNameType.COLUMN,
 -                        DimensionNameType.ROW,
 -                        DimensionNameType.VERTICAL
 -                    },
 -                    new long[3],
 -                    new long[] {
 -                        NUM_COLS * TILE_WIDTH  - 1,
 -                        NUM_ROWS * TILE_HEIGHT - 1,
 -                        NUM_SLICES - 1
 -                    },
 -                    true);
 -
 -            final int dimension = 3;
 -            final MatrixSIS gridToCRS = Matrices.createIdentity(dimension + 
1);
 -            gridToCRS.setNumber(0, 0, 0.5);
 -            gridToCRS.setNumber(1, 1, 0.5);
 -            gridToCRS.setNumber(2, 2, 100);
 -            gridGeometry = new GridGeometry(extent, PixelInCell.CELL_CORNER,
 -                    MathTransforms.linear(gridToCRS), HardCodedCRS.WGS84_3D);
 -
 -            sampleDimensions = List.of(new 
SampleDimension.Builder().setName("data").build());
+         }
+ 
++        /**
++         * Returns the same grid geometry for all mocks.
++         */
+         @Override
+         public GridGeometry getGridGeometry() {
+             return gridGeometry;
+         }
+ 
++        /**
++         * Returns the same sample dimensions for all mocks.
++         */
+         @Override
++        @SuppressWarnings("ReturnOfCollectionOrArrayField")
+         public List<SampleDimension> getSampleDimensions() {
+             return sampleDimensions;
+         }
+ 
++        /**
++         * Returns the size of all tiles for all images in this mock.
++         * The size is {@value #TILE_WIDTH} × {@value #TILE_HEIGHT} × 1.
++         */
+         @Override
+         protected int[] getTileSize() {
+             return new int[] {TILE_WIDTH, TILE_HEIGHT, 1};
+         }
+ 
 -        @Override
 -        protected ColorModel getColorModel(int[] bands) {
 -            return MODEL.getColorModel();
 -        }
 -
 -        @Override
 -        protected SampleModel getSampleModel(int[] bands) {
 -            return MODEL.getSampleModel();
 -        }
 -
++        /**
++         * Simulates a read operation, actually returning generated tiles.
++         */
+         @Override
+         protected TiledGridCoverage read(Subset subset) {
+             return new MockTiledCoverage(subset);
+         }
+     }
+ 
+     /**
 -     * A minimal {@link TiledGridCoverage} that creates empty rasters on 
demand.
++     * A minimal {@link TiledGridCoverage} that creates almost empty rasters 
on demand.
++     * The size of all rasters is {@value #TILE_WIDTH} × {@value 
#TILE_HEIGHT} pixels.
++     * The tile coordinates are stored as sample values on the diagonal 
starting from
++     * the upper-left corner.
+      */
+     private static final class MockTiledCoverage extends TiledGridCoverage {
 -
++        /**
++         * Creates a new mock pretending to be the result of a read operation.
++         */
+         MockTiledCoverage(TiledGridCoverageResource.Subset subset) {
+             super(subset);
+         }
+ 
++        /**
++         * Returns a dummy identifier. This is not used by the test.
++         */
+         @Override
+         protected GenericName getIdentifier() {
+             return Names.createLocalName(null, null, "test");
+         }
+ 
++        /**
++         * Returns all tiles in the given area of interest.
++         * For each raster, tile coordinates are written as sample
++         * values on a diagonal starting in the upper-left corner.
++         *
++         * @see #checkTileRaster(Raster, long[])
++         */
+         @Override
 -        protected Raster[] readTiles(TileIterator iterator) {
++        protected Raster[] readTiles(final TileIterator iterator) {
+             final Raster[] tiles = new Raster[iterator.tileCountInQuery];
+             do {
 -                final WritableRaster raster = iterator.createRaster();
 -                final var tileCoords = 
iterator.getTileCoordinatesInResource();
 -                for (int i = 0; i < tileCoords.length; i++) {
 -                    raster.setSample(raster.getMinX() + i, raster.getMinY() + 
i, 0, tileCoords[i]);
++                final WritableRaster tile = iterator.createRaster();
++                final int xmin = tile.getMinX();
++                final int ymin = tile.getMinY();
++                final long[] tileIndices = 
iterator.getTileCoordinatesInResource();
++                assertEquals(DIMENSION, tileIndices.length);
++                for (int i=0; i<DIMENSION; i++) {
++                    tile.setSample(xmin + i, ymin + i, 0, 
StrictMath.toIntExact(tileIndices[i]));
+                 }
 -                tiles[iterator.getTileIndexInResultArray()] = raster;
++                tiles[iterator.getTileIndexInResultArray()] = tile;
+             } while (iterator.next());
+             return tiles;
+         }
+     }
 -}
++}

Reply via email to