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 e4fc9a54a7cf2ebfb3110b5163dd38a607b1568c
Author: Martin Desruisseaux <martin.desruisse...@geomatys.com>
AuthorDate: Sun Apr 23 19:02:08 2023 +0200

    Make `CoverageCombiner` more suitable to public API:
    - infer `xdim` and `ydim` automatically.
    - check units of measurement.
---
 .../org/apache/sis/coverage/CoverageCombiner.java  | 134 +++++++++++++------
 .../sis/coverage/grid/GridCoverageBuilder.java     |   5 +
 .../org/apache/sis/coverage/grid/GridExtent.java   |  82 +++++++++++-
 .../java/org/apache/sis/image/ComputedImage.java   |  20 +--
 .../java/org/apache/sis/image/ImageCombiner.java   |  72 ++++------
 .../java/org/apache/sis/image/ImageProcessor.java  |   8 +-
 .../java/org/apache/sis/image/Visualization.java   |   2 +-
 .../sis/internal/coverage/SampleDimensions.java    |  36 +++++
 .../sis/internal/coverage/j2d/ImageLayout.java     |  62 +++++++--
 .../apache/sis/coverage/CoverageCombinerTest.java  |  70 ++++++++++
 .../apache/sis/coverage/grid/GridExtentTest.java   |  22 +++-
 .../apache/sis/test/suite/FeatureTestSuite.java    |   1 +
 .../operation/transform/MathTransforms.java        |  16 +++
 .../operation/transform/UnitConversion.java        | 145 +++++++++++++++++++++
 .../operation/transform/UnitConversionTest.java    |  59 +++++++++
 .../sis/test/suite/ReferencingTestSuite.java       |   1 +
 .../internal/storage/WritableResourceSupport.java  |   9 +-
 17 files changed, 624 insertions(+), 120 deletions(-)

diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/CoverageCombiner.java 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/CoverageCombiner.java
index 231f6946c1..aa2cbd7d8f 100644
--- 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/CoverageCombiner.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/CoverageCombiner.java
@@ -20,10 +20,14 @@ import java.util.Arrays;
 import java.awt.Dimension;
 import java.awt.image.RenderedImage;
 import java.awt.image.WritableRenderedImage;
+import javax.measure.IncommensurableException;
+import javax.measure.Unit;
 import org.opengis.geometry.Envelope;
 import org.opengis.referencing.datum.PixelInCell;
 import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransform1D;
 import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
 import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.coverage.grid.GridCoverage;
@@ -32,14 +36,17 @@ import org.apache.sis.image.ImageProcessor;
 import org.apache.sis.image.ImageCombiner;
 import org.apache.sis.image.Interpolation;
 import org.apache.sis.image.PlanarImage;
-import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.ArgumentChecks;
-import org.apache.sis.util.resources.Errors;
+import org.apache.sis.measure.NumberRange;
+import org.apache.sis.internal.coverage.SampleDimensions;
 
 import static java.lang.Math.round;
 import static org.apache.sis.internal.util.Numerics.saturatingAdd;
 import static org.apache.sis.internal.util.Numerics.saturatingSubtract;
 
+// Branch-dependent imports
+import org.opengis.coverage.CannotEvaluateException;
+
 
 /**
  * Combines an arbitrary number of coverages into a single one.
@@ -49,11 +56,14 @@ import static 
org.apache.sis.internal.util.Numerics.saturatingSubtract;
  * <ol>
  *   <li>Creates a {@code CoverageCombiner} with the destination coverage 
where to write.</li>
  *   <li>Configure with methods such as {@link #setInterpolation 
setInterpolation(…)}.</li>
- *   <li>Invoke {@link #apply apply(…)} methods for each list of coverages to 
combine.</li>
+ *   <li>Invoke {@link #acceptAll acceptAll(…)} methods for each list of 
coverages to combine.</li>
  *   <li>Get the combined coverage with {@link #result()}.</li>
  * </ol>
  *
+ * Coverage domains can have any number of dimensions.
  * Coverages are combined in the order they are specified.
+ * For each coverage, sample dimensions are combined in the order they appear, 
regardless their names.
+ * For each sample dimension, values are converted to the unit of measurement 
of the destination coverage.
  *
  * <h2>Limitations</h2>
  * The current implementation has the following limitations.
@@ -61,11 +71,10 @@ import static 
org.apache.sis.internal.util.Numerics.saturatingSubtract;
  *
  * <ul>
  *   <li>Supports only {@link GridCoverage} instances, not yet more generic 
coverages.</li>
- *   <li>No interpolation except in the two dimensions specified at 
construction time.
+ *   <li>No interpolation except in the two dimensions having the largest size 
(usually the 2 first).
  *       For all other dimensions, data are taken from the nearest neighbor 
two-dimensional slice.</li>
  *   <li>No expansion of the destination coverage for accommodating data of 
source coverages
  *       that are outside the destination coverage bounds.</li>
- *   <li>No verification of whether sample dimensions are in the same 
order.</li>
  * </ul>
  *
  * @author  Martin Desruisseaux (Geomatys)
@@ -95,29 +104,30 @@ public class CoverageCombiner {
     /**
      * The dimension to extract as {@link RenderedImage}s.
      * This is usually 0 for <var>x</var> and 1 for <var>y</var>.
+     * The other dimensions can have any size (not restricted to 1 cell).
      */
     private final int xdim, ydim;
 
+    /**
+     * Whether the {@linkplain #destination} uses converted values.
+     */
+    private final boolean isConverted;
+
     /**
      * Creates a coverage combiner which will write in the given coverage.
-     * The coverage is not cleared; cells that are not overwritten by calls
+     * The coverage is not cleared: cells that are not overwritten by calls
      * to the {@code accept(…)} method will be left unchanged.
      *
      * @param  destination  the destination coverage where to combine source 
coverages.
-     * @param  xdim         the dimension to extract as {@link RenderedImage} 
<var>x</var> axis. This is usually 0.
-     * @param  ydim         the dimension to extract as {@link RenderedImage} 
<var>y</var> axis. This is usually 1.
+     * @throws CannotEvaluateException if the coverage does not have at least 
2 dimensions.
      */
-    public CoverageCombiner(final GridCoverage destination, final int xdim, 
final int ydim) {
+    public CoverageCombiner(final GridCoverage destination) {
         ArgumentChecks.ensureNonNull("destination", destination);
-        this.destination = destination;
-        final int dimension = destination.getGridGeometry().getDimension();
-        ArgumentChecks.ensureBetween("xdim", 0, dimension-1, xdim);
-        ArgumentChecks.ensureBetween("ydim", 0, dimension-1, ydim);
-        if (xdim == ydim) {
-            throw new 
IllegalArgumentException(Errors.format(Errors.Keys.DuplicatedNumber_1, xdim));
-        }
-        this.xdim = xdim;
-        this.ydim = ydim;
+        this.destination = destination.forConvertedValues(true);
+        isConverted = (this.destination == destination);
+        final int[] dim = 
destination.getGridGeometry().getExtent().getLargestDimensions(BIDIMENSIONAL);
+        xdim = dim[0];
+        ydim = dim[1];
         processor = new ImageProcessor();
     }
 
@@ -166,35 +176,77 @@ public class CoverageCombiner {
         return new ImageRenderer(coverage, 
slice).getImageGeometry(BIDIMENSIONAL);
     }
 
+    /**
+     * Returns the conversions from source units to target units.
+     * Conversion is fetched for each pair of units at the same index.
+     *
+     * @param  sources  the source units. May contain null elements.
+     * @param  targets  the target units. May contain null elements.
+     * @return converters, or {@code null} if none. May contain null elements.
+     * @throws IncommensurableException if a pair of units are not convertible.
+     */
+    private static MathTransform1D[] createUnitConverters(final Unit<?>[] 
sources, final Unit<?>[] targets)
+            throws IncommensurableException
+    {
+        MathTransform1D[] converters = null;
+        final int n = Math.min(sources.length, targets.length);
+        for (int i=0; i<n; i++) {
+            final Unit<?> source = sources[i];
+            final Unit<?> target = targets[i];
+            if (source != null && target != null) {
+                final MathTransform1D c = 
MathTransforms.convert(source.getConverterToAny(target));
+                if (!c.isIdentity()) {
+                    if (converters == null) {
+                        converters = new MathTransform1D[n];
+                        Arrays.fill(converters, MathTransforms.identity(1));
+                    }
+                    converters[i] = c;
+                }
+            }
+        }
+        return converters;
+    }
+
     /**
      * Writes the given coverages on top of the destination coverage.
      * The given coverages are resampled to the grid geometry of the 
destination coverage.
      * Coverages that do not intercept with the destination coverage are 
silently ignored.
      *
+     * <h4>Performance note</h4>
+     * If there is many coverages to write, they should be specified in a 
single
+     * call to {@code acceptAll(…)} instead of invoking this method multiple 
times.
+     * Bulk operations can reduce the number of calls to {@link 
GridCoverage#render(GridExtent)}.
+     *
      * @param  sources  the coverages to write on top of destination coverage.
      * @return {@code true} on success, or {@code false} if at least one slice
      *         in the destination coverage is not writable.
      * @throws TransformException if the coordinates of a given coverage 
cannot be transformed
      *         to the coordinates of destination coverage.
+     * @throws IncommensurableException if the unit of measurement of at least 
one source sample dimension
+     *         is not convertible to the unit of measurement of the 
corresponding target sample dimension.
      */
-    public boolean apply(GridCoverage... sources) throws TransformException {
+    public boolean acceptAll(GridCoverage... sources) throws 
TransformException, IncommensurableException {
         ArgumentChecks.ensureNonNull("sources", sources);
         sources = sources.clone();
-        final GridGeometry    targetGG            = 
destination.getGridGeometry();
-        final GridExtent      targetEx            = targetGG.getExtent();
-        final int             dimension           = targetEx.getDimension();
-        final long[]          minIndices          = new long[dimension]; 
Arrays.fill(minIndices, Long.MAX_VALUE);
-        final long[]          maxIndices          = new long[dimension]; 
Arrays.fill(maxIndices, Long.MIN_VALUE);
-        final MathTransform[] toSourceSliceCorner = new 
MathTransform[sources.length];
-        final MathTransform[] toSourceSliceCenter = new 
MathTransform[sources.length];
+        final GridGeometry        targetGG            = 
destination.getGridGeometry();
+        final GridExtent          targetEx            = targetGG.getExtent();
+        final int                 dimension           = 
targetEx.getDimension();
+        final long[]              minIndices          = new long[dimension]; 
Arrays.fill(minIndices, Long.MAX_VALUE);
+        final long[]              maxIndices          = new long[dimension]; 
Arrays.fill(maxIndices, Long.MIN_VALUE);
+        final MathTransform[]     toSourceSliceCorner = new MathTransform  
[sources.length];
+        final MathTransform[]     toSourceSliceCenter = new MathTransform  
[sources.length];
+        final MathTransform1D[][] unitConverters      = new 
MathTransform1D[sources.length][];
+        final NumberRange<?>[][]  sourceRanges        = new NumberRange<?> 
[sources.length][];
+        final Unit<?>[]           destinationUnits    = 
SampleDimensions.units(destination);
         /*
          * Compute the intersection between `source` and `destination`, in 
units of destination cell indices.
-         * If a coverage does not intersect the destination, the corresponding 
element in the `sources` array
-         * will be set to null.
+         * If a coverage does not intersect the destination, it will be 
discarded.
          */
+        int numSources = 0;
 next:   for (int j=0; j<sources.length; j++) {
-            final GridCoverage source = sources[j];
+            GridCoverage source = sources[j];
             ArgumentChecks.ensureNonNullElement("sources", j, source);
+            source = source.forConvertedValues(true);
             final GridGeometry  sourceGG = source.getGridGeometry();
             final GridExtent    sourceEx = sourceGG.getExtent();
             final MathTransform toSource = 
targetGG.createTransformTo(sourceGG, PixelInCell.CELL_CORNER);
@@ -211,7 +263,6 @@ next:   for (int j=0; j<sources.length; j++) {
                 min[i] = Math.max(targetEx.getLow (i), 
round(env.getMinimum(i)));
                 max[i] = Math.min(targetEx.getHigh(i), round(env.getMaximum(i) 
- 1));
                 if (min[i] > max[i]) {
-                    sources[j] = null;
                     continue next;
                 }
             }
@@ -223,10 +274,15 @@ next:   for (int j=0; j<sources.length; j++) {
                 minIndices[i] = Math.min(minIndices[i], min[i]);
                 maxIndices[i] = Math.max(maxIndices[i], max[i]);
             }
-            toSourceSliceCenter[j] = targetGG.createTransformTo(sourceGG, 
PixelInCell.CELL_CENTER);
-            toSourceSliceCorner[j] = toSource;
+            toSourceSliceCenter[numSources] = 
targetGG.createTransformTo(sourceGG, PixelInCell.CELL_CENTER);
+            toSourceSliceCorner[numSources] = toSource;
+            sources            [numSources] = source;
+            unitConverters     [numSources] = 
createUnitConverters(SampleDimensions.units(source), destinationUnits);
+            sourceRanges       [numSources] = SampleDimensions.ranges(source);
+            numSources++;
         }
-        if (ArraysExt.allEquals(sources, null)) {
+        Arrays.fill(sources, numSources, sources.length, null);
+        if (numSources == 0) {
             return true;                                // No intersection. We 
"successfully" wrote nothing.
         }
         /*
@@ -251,11 +307,8 @@ next:   for (;;) {
             final RenderedImage targetSlice = 
destination.render(targetSliceExtent);
             if (targetSlice instanceof WritableRenderedImage) {
                 final ImageCombiner combiner = new 
ImageCombiner((WritableRenderedImage) targetSlice, processor);
-                for (int j=0; j<sources.length; j++) {
+                for (int j=0; j<numSources; j++) {
                     final GridCoverage source = sources[j];
-                    if (source == null) {
-                        continue;
-                    }
                     /*
                      * Compute the bounds of the source image to load (with a 
margin for rounding and interpolations).
                      * For all dimensions other than the slice dimensions, we 
take the center of the slice to read.
@@ -278,9 +331,14 @@ next:   for (;;) {
                     }
                     /*
                      * Get the source image and combine with the corresponding 
slice of destination coverage.
+                     * Data are converted to the destination units before the 
resampling is applied.
                      */
                     GridExtent sourceSliceExtent = new GridExtent(null, 
minSourceIndices, maxSourceIndices, true);
                     RenderedImage sourceSlice = 
source.render(sourceSliceExtent);
+                    MathTransform1D[] converters = unitConverters[j];
+                    if (converters != null) {
+                        sourceSlice = processor.convert(sourceSlice, 
sourceRanges[j], converters, combiner.getBandType());
+                    }
                     MathTransform toSource =
                             getGridGeometry(targetSlice, destination, 
targetSliceExtent).createTransformTo(
                             getGridGeometry(sourceSlice, source,      
sourceSliceExtent), PixelInCell.CELL_CENTER);
@@ -321,6 +379,6 @@ next:   for (;;) {
      * @return the combination of destination coverage with all source 
coverages.
      */
     public GridCoverage result() {
-        return destination;
+        return destination.forConvertedValues(isConverted);
     }
 }
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java
 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java
index f0fd0b1218..80dccf4f38 100644
--- 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridCoverageBuilder.java
@@ -372,6 +372,11 @@ public class GridCoverageBuilder {
             size = new Dimension(size);
             ArgumentChecks.ensureStrictlyPositive("width",  size.width);
             ArgumentChecks.ensureStrictlyPositive("height", size.height);
+            final int length = Math.multiplyExact(size.width, size.height);
+            final int capacity = data.getSize();
+            if (length > capacity) {
+                throw new 
IllegalArgumentException(Errors.format(Errors.Keys.UnexpectedArrayLength_2, 
length, capacity));
+            }
         }
         this.size = size;
         buffer = data;
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridExtent.java 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridExtent.java
index e5098872c7..88d2370a8b 100644
--- 
a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridExtent.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridExtent.java
@@ -970,11 +970,7 @@ public class GridExtent implements GridEnvelope, 
LenientComparable, Serializable
      * @throws CannotEvaluateException if this grid extent does not have at 
least {@code numDim} dimensions.
      */
     public int[] getSubspaceDimensions(final int numDim) {
-        ArgumentChecks.ensurePositive("numDim", numDim);
-        final int m = getDimension();
-        if (numDim > m) {
-            throw new 
CannotEvaluateException(Resources.format(Resources.Keys.GridEnvelopeMustBeNDimensional_1,
 numDim));
-        }
+        final int m = ensureValidDimension(numDim);
         final int[] selected = new int[numDim];
         int count = 0;
         for (int i=0; i<m; i++) {
@@ -1004,6 +1000,82 @@ public class GridExtent implements GridEnvelope, 
LenientComparable, Serializable
         return selected;
     }
 
+    /**
+     * Ensures that 0 ≤ {@code numDim} ≤ <var>n</var>
+     * where <var>n</var> is the number of dimensions of this grid extent.
+     *
+     * @param  numDim  the user-supplied number of dimensions to validate.
+     * @return the number of dimensions in this grid extent.
+     * @throws CannotEvaluateException if this grid extent does not have at 
least {@code numDim} dimensions.
+     */
+    private int ensureValidDimension(final int numDim) {
+        ArgumentChecks.ensurePositive("numDim", numDim);
+        final int m = getDimension();
+        if (numDim > m) {
+            throw new 
CannotEvaluateException(Resources.format(Resources.Keys.GridEnvelopeMustBeNDimensional_1,
 numDim));
+        }
+        return m;
+    }
+
+    /**
+     * Returns the indices of the {@code numDim} dimensions having the largest 
sizes.
+     * This method can be used as an alternative to {@link 
#getSubspaceDimensions(int)}
+     * when it is acceptable that the omitted dimensions have sizes larger 
than 1 cell.
+     *
+     * @param  numDim  number of dimensions of the sub-space.
+     * @return indices of the {@code numDim} dimensions having the largest 
sizes, in increasing order.
+     * @throws CannotEvaluateException if this grid extent does not have at 
least {@code numDim} dimensions.
+     *
+     * @since 1.4
+     */
+    public int[] getLargestDimensions(final int numDim) {
+        return DimSize.sort(coordinates, ensureValidDimension(numDim), numDim);
+    }
+
+    /**
+     * A (dimension, size) tuple. Used for sorting dimensions by their size.
+     * This is used for {@link GridExtent#getLargestDimensions()} 
implementation.
+     */
+    private static final class DimSize extends 
org.apache.sis.internal.jdk17.Record implements Comparable<DimSize> {
+        /** Index of the dimension.      */ private final int  dim;
+        /** Size as an unsigned integer. */ private final long size;
+
+        /** Creates a new (dimension, size) tuple. */
+        private DimSize(final int dim, final long size) {
+            this.dim  = dim;
+            this.size = size;
+        }
+
+        /** Compares two tuples for order based on their size. */
+        @Override public int compareTo(final DimSize other) {
+            int c = Long.compareUnsigned(other.size, size);     // Reverse 
order.
+            if (c == 0) c = Integer.compare(dim, other.dim);
+            return c;
+        }
+
+        /** Implementation of {@link GridExtent#getLargestDimensions()}. */
+        static int[] sort(final long[] coordinates, final int m, final int 
numDim) {
+            if (numDim == m) {
+                return ArraysExt.range(0, numDim);      // Small optimization 
for a common case.
+            }
+            final var sizes = new DimSize[m];
+            for (int i=0; i<m; i++) {
+                /*
+                 * Do not use `getSize(int)` because the results may overflow.
+                 * It is okay because we will treat them as unsigned integers.
+                 */
+                sizes[i] = new DimSize(i, coordinates[m + i] - coordinates[i]);
+            }
+            Arrays.sort(sizes);
+            final int[] result = new int[numDim];
+            for (int i=0; i<numDim; i++) {
+                result[i] = sizes[i].dim;
+            }
+            Arrays.sort(result);
+            return result;
+        }
+    }
+
     /**
      * Returns the type (vertical, temporal, …) of grid axis at given 
dimension.
      * This information is provided because the grid axis type cannot always 
be inferred from the context.
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/ComputedImage.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/ComputedImage.java
index 4a1756a737..8d076eb8d7 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ComputedImage.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ComputedImage.java
@@ -306,20 +306,24 @@ public abstract class ComputedImage extends PlanarImage 
implements Disposable {
      *
      * If this method is invoked, then is should be done soon after 
construction time
      * before any tile computation starts.
+     *
+     * @param  target  the destination image, or {@code null} if none.
      */
     final void setDestination(final WritableRenderedImage target) {
         if (destination != null) {
             throw new 
IllegalStateException(Errors.format(Errors.Keys.AlreadyInitialized_1, 
"destination"));
         }
-        if (!sampleModel.equals(target.getSampleModel())) {
-            throw new 
IllegalArgumentException(Resources.format(Resources.Keys.MismatchedSampleModel));
-        }
-        if (target.getTileGridXOffset() != getTileGridXOffset() ||
-            target.getTileGridYOffset() != getTileGridYOffset())
-        {
-            throw new 
IllegalArgumentException(Resources.format(Resources.Keys.MismatchedTileGrid));
+        if (target != null) {
+            if (!sampleModel.equals(target.getSampleModel())) {
+                throw new 
IllegalArgumentException(Resources.format(Resources.Keys.MismatchedSampleModel));
+            }
+            if (target.getTileGridXOffset() != getTileGridXOffset() ||
+                target.getTileGridYOffset() != getTileGridYOffset())
+            {
+                throw new 
IllegalArgumentException(Resources.format(Resources.Keys.MismatchedTileGrid));
+            }
+            destination = target;
         }
-        destination = target;
     }
 
     /**
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/ImageCombiner.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/ImageCombiner.java
index c724a8ed30..47af72eca3 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ImageCombiner.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ImageCombiner.java
@@ -16,10 +16,8 @@
  */
 package org.apache.sis.image;
 
-import java.awt.Point;
 import java.awt.Rectangle;
 import java.awt.image.Raster;
-import java.awt.image.SampleModel;
 import java.awt.image.RenderedImage;
 import java.awt.image.WritableRenderedImage;
 import java.util.function.Consumer;
@@ -61,7 +59,7 @@ import org.apache.sis.measure.Units;
  * Only the intersection of both images is used.</p>
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
+ * @version 1.4
  * @since   1.1
  */
 public class ImageCombiner implements Consumer<RenderedImage> {
@@ -75,13 +73,6 @@ public class ImageCombiner implements 
Consumer<RenderedImage> {
      */
     private final WritableRenderedImage destination;
 
-    /**
-     * The value to use in calls to {@link 
ImageProcessor#setImageLayout(ImageLayout)}.
-     * We set this property before use of {@link #processor} because the value 
may change
-     * for each slice processed by {@link 
org.apache.sis.coverage.CoverageCombiner}.
-     */
-    private final Layout layout;
-
     /**
      * Creates an image combiner which will write in the given image. That 
image is not cleared;
      * pixels that are not overwritten by calls to the {@code accept(…)} or 
{@code resample(…)}
@@ -108,37 +99,6 @@ public class ImageCombiner implements 
Consumer<RenderedImage> {
         ArgumentChecks.ensureNonNull("processor", processor);
         this.destination = destination;
         this.processor = processor;
-        layout = new Layout(destination.getSampleModel());
-    }
-
-    /**
-     * Provides sample model of images created by resample operations.
-     * It must be the sample model of destination image, with the same tile 
size.
-     */
-    private static final class Layout extends ImageLayout {
-        /** Sample model of destination image. */
-        private final SampleModel sampleModel;
-
-        /** Indices of the first tile ({@code minTileX}, {@code minTileY}). */
-        final Point minTile;
-
-        /** Creates a new layout which will request the specified sample 
model. */
-        Layout(final SampleModel sampleModel) {
-            super(null, false);
-            ArgumentChecks.ensureNonNull("sampleModel", sampleModel);
-            this.sampleModel = sampleModel;
-            minTile = new Point();
-        }
-
-        /** Returns the target sample model for {@link ResampledImage} or 
other operations. */
-        @Override public SampleModel createCompatibleSampleModel(RenderedImage 
image, Rectangle bounds) {
-            return sampleModel;
-        }
-
-        /** Returns indices of the first tile, which must have been set in the 
{@link #minTile} field in advance. */
-        @Override public Point getMinTile() {
-            return minTile;
-        }
     }
 
     /**
@@ -192,6 +152,17 @@ public class ImageCombiner implements 
Consumer<RenderedImage> {
         processor.setPositionalAccuracyHints(hints);
     }
 
+    /**
+     * Returns the type of number used for representing the values of each 
band.
+     *
+     * @return the type of number capable to hold sample values of each band.
+     *
+     * @since 1.4
+     */
+    public DataType getBandType() {
+        return DataType.forBands(destination);
+    }
+
     /**
      * Writes the given image on top of destination image. The given source 
image shall use the same pixel
      * coordinate system than the destination image (but not necessarily the 
same tile indices).
@@ -279,14 +250,19 @@ public class ImageCombiner implements 
Consumer<RenderedImage> {
          */
         final RenderedImage result;
         synchronized (processor) {
-            final Point minTile = layout.minTile;
-            minTile.x = minTileX;
-            minTile.y = minTileY;
-            processor.setImageLayout(layout);
-            result = processor.resample(source, bounds, toSource);
+            final ImageLayout layout = ImageLayout.forDestination(destination, 
minTileX, minTileY);
+            final ImageLayout previous = processor.getImageLayout();
+            try {
+                processor.setImageLayout(layout);
+                result = processor.resample(source, bounds, toSource);
+            } finally {
+                processor.setImageLayout(previous);
+            }
         }
-        if (result instanceof ComputedImage) {
-            ((ComputedImage) result).setDestination(destination);
+        /*
+         * Check if the result is writing directly in the destination image.
+         */
+        if (result instanceof ComputedImage && ((ComputedImage) 
result).getDestination() == destination) {
             processor.prefetch(result, ImageUtilities.getBounds(destination));
         } else {
             accept(result);
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
index f3927fb049..bb33057a0f 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ImageProcessor.java
@@ -1203,9 +1203,11 @@ public class ImageProcessor implements Cloneable {
                 fillValues              = this.fillValues;
                 positionalAccuracyHints = this.positionalAccuracyHints;
             }
-            resampled = unique(new ResampledImage(source,
-                    layout.createCompatibleSampleModel(source, bounds), 
layout.getMinTile(),
-                    bounds, toSource, interpolation, fillValues, 
positionalAccuracyHints));
+            final SampleModel rsm = layout.createCompatibleSampleModel(source, 
bounds);
+            final var image = new ResampledImage(source, rsm, 
layout.getMinTile(), bounds, toSource,
+                                                 interpolation, fillValues, 
positionalAccuracyHints);
+            image.setDestination(layout.getDestination());
+            resampled = unique(image);
             break;
         }
         /*
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
index aedb0fcdfb..e6588276e3 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/Visualization.java
@@ -275,7 +275,7 @@ final class Visualization extends ResampledImage {
              */
             final boolean shortcut = toSource.isIdentity() && (bounds == null 
|| ImageUtilities.getBounds(source).contains(bounds));
             if (shortcut) {
-                layout = ImageLayout.fixedSize(source);
+                layout = ImageLayout.forTileSize(source);
             }
             /*
              * Sample values will be unconditionally converted to integers in 
the [0 … 255] range.
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java
 
b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java
index a8dcb60a16..9952fb69c8 100644
--- 
a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/SampleDimensions.java
@@ -21,6 +21,8 @@ import java.util.Optional;
 import java.util.function.DoubleUnaryOperator;
 import java.awt.Shape;
 import java.awt.image.RenderedImage;
+import javax.measure.Unit;
+import org.apache.sis.coverage.BandedCoverage;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.coverage.Category;
 import org.apache.sis.image.ImageProcessor;
@@ -71,6 +73,40 @@ public final class SampleDimensions extends Static {
     private SampleDimensions() {
     }
 
+    /**
+     * Returns the units of measurement for all bands of the given coverage.
+     * The length of the returned array is the number of sample dimensions.
+     * The array may contain {@code null} elements.
+     *
+     * @param  source  the coverage for which to get units of measurement.
+     * @return the unit of measurement of all bands in the given coverage.
+     */
+    public static Unit<?>[] units(final BandedCoverage source) {
+        final List<SampleDimension> bands = source.getSampleDimensions();
+        final var units = new Unit<?>[bands.size()];
+        for (int i=0; i<units.length; i++) {
+            units[i] = bands.get(i).getUnits().orElse(null);
+        }
+        return units;
+    }
+
+    /**
+     * Returns the range of sample values for all bands of the given coverage.
+     * The length of the returned array is the number of sample dimensions.
+     * The array may contain {@code null} elements.
+     *
+     * @param  source  the coverage for which to get sample value ranges.
+     * @return the sample value ranges of all bands in the given coverage.
+     */
+    public static NumberRange<?>[] ranges(final BandedCoverage source) {
+        final List<SampleDimension> bands = source.getSampleDimensions();
+        final var ranges = new NumberRange<?>[bands.size()];
+        for (int i=0; i<ranges.length; i++) {
+            ranges[i] = bands.get(i).getSampleRange().orElse(null);
+        }
+        return ranges;
+    }
+
     /**
      * Returns the background values of all bands in the given list.
      * The length of the returned array is the number of sample dimensions.
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageLayout.java
 
b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageLayout.java
index 3783305f0a..5d285a4e1b 100644
--- 
a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageLayout.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/j2d/ImageLayout.java
@@ -25,6 +25,7 @@ import java.awt.image.IndexColorModel;
 import java.awt.image.RenderedImage;
 import java.awt.image.SampleModel;
 import java.awt.image.BandedSampleModel;
+import java.awt.image.WritableRenderedImage;
 import org.apache.sis.math.MathFunctions;
 import org.apache.sis.image.ComputedImage;
 import org.apache.sis.util.ArraysExt;
@@ -108,22 +109,34 @@ public class ImageLayout {
      * @param  source  image from which to take tile size and indices.
      * @return layout giving exactly the tile size and indices of given image.
      */
-    public static ImageLayout fixedSize(final RenderedImage source) {
-        return new FixedSize(source);
+    public static ImageLayout forTileSize(final RenderedImage source) {
+        return new FixedSize(source, source.getMinTileX(), 
source.getMinTileY());
+    }
+
+    /**
+     * Creates a new layout for writing in the given destination.
+     *
+     * @param  source    image from which to take tile size and indices.
+     * @param  minTileX  column index of the first tile.
+     * @param  minTileY  row index of the first tile.
+     * @return layout giving exactly the tile size and indices of given image.
+     */
+    public static ImageLayout forDestination(final WritableRenderedImage 
source, final int minTileX, final int minTileY) {
+        return new FixedDestination(source, minTileX, minTileY);
     }
 
     /**
      * Override preferred tile size with a fixed size.
      */
-    private static final class FixedSize extends ImageLayout {
+    private static class FixedSize extends ImageLayout {
         /** Indices of the first tile. */
-        private final int xmin, ymin;
+        private final int minTileX, minTileY;
 
         /** Creates a new layout with exactly the tile size of given image. */
-        FixedSize(final RenderedImage source) {
+        FixedSize(final RenderedImage source, final int minTileX, final int 
minTileY) {
             super(new Dimension(source.getTileWidth(), 
source.getTileHeight()), false);
-            xmin = source.getMinTileX();
-            ymin = source.getMinTileY();
+            this.minTileX = minTileX;
+            this.minTileY = minTileY;
         }
 
         /** Returns the fixed tile size. All parameters are ignored. */
@@ -138,7 +151,31 @@ public class ImageLayout {
 
         /** Returns indices of the first tile. */
         @Override public Point getMinTile() {
-            return new Point(xmin, ymin);
+            return new Point(minTileX, minTileY);
+        }
+    }
+
+    /**
+     * Override sample model with the one of the destination.
+     */
+    private static final class FixedDestination extends FixedSize {
+        /** The destination image. */
+        private final WritableRenderedImage destination;
+
+        /** Creates a new layout with exactly the tile size of given image. */
+        FixedDestination(final WritableRenderedImage destination, final int 
minTileX, final int minTileY) {
+            super(destination, minTileX, minTileY);
+            this.destination = destination;
+        }
+
+        /** Returns an existing image where to write the computation result. */
+        @Override public WritableRenderedImage getDestination() {
+            return destination;
+        }
+
+        /** Returns the target sample model, which is fixed to the same than 
the destination image. */
+        @Override public SampleModel createCompatibleSampleModel(RenderedImage 
image, Rectangle bounds) {
+            return destination.getSampleModel();
         }
     }
 
@@ -380,6 +417,15 @@ public class ImageLayout {
         return null;
     }
 
+    /**
+     * Returns an existing image where to write the computation result, or 
{@code null} if none.
+     *
+     * @return preexisting destination of computation result, or {@code null} 
if none.
+     */
+    public WritableRenderedImage getDestination() {
+        return null;
+    }
+
     /**
      * Returns a string representation for debugging purpose.
      *
diff --git 
a/core/sis-feature/src/test/java/org/apache/sis/coverage/CoverageCombinerTest.java
 
b/core/sis-feature/src/test/java/org/apache/sis/coverage/CoverageCombinerTest.java
new file mode 100644
index 0000000000..1efaf699d2
--- /dev/null
+++ 
b/core/sis-feature/src/test/java/org/apache/sis/coverage/CoverageCombinerTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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;
+
+import java.awt.Dimension;
+import java.awt.image.DataBufferFloat;
+import javax.measure.IncommensurableException;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.grid.GridCoverageBuilder;
+import org.apache.sis.geometry.Envelope2D;
+import org.apache.sis.measure.Units;
+import org.apache.sis.test.TestCase;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+
+/**
+ * Tests {@link CoverageCombiner}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+public final class CoverageCombinerTest extends TestCase {
+    /**
+     * Tests a coverage combination involving unit conversion.
+     *
+     * @throws TransformException if the coordinates of a given coverage 
cannot be transformed.
+     * @throws IncommensurableException if the unit of measurement is not 
convertible.
+     */
+    @Test
+    public void testUnitConversion() throws TransformException, 
IncommensurableException {
+        final var s = new Dimension(2,2);
+        GridCoverage c1 = new GridCoverageBuilder()
+                .setDomain(new Envelope2D(null, 2, 2, s.width, s.height))
+                .setRanges(new SampleDimension.Builder().addQuantitative("C1", 
0, 10, Units.METRE).build())
+                .setValues(new DataBufferFloat(new float[] {4, 8, 2, 3}, 
s.width * s.height), s)
+                .build();
+
+        GridCoverage c2 = new GridCoverageBuilder()
+                .setDomain(new Envelope2D(null, 3, 2, s.width, s.height))
+                .setRanges(new SampleDimension.Builder().addQuantitative("C1", 
0, 10, Units.CENTIMETRE).build())
+                .setValues(new DataBufferFloat(new float[] {500, 600, 900, 
700}, s.width * s.height), s)
+                .build();
+
+        final var combiner = new CoverageCombiner(c1);
+        combiner.acceptAll(c2);
+        GridCoverage r = combiner.result();
+
+        float[] data = null;
+        data = r.render(null).getData().getSamples(0, 0, s.width, s.height, 0, 
data);
+        assertArrayEquals(new float[] {4, 5, 2, 9}, data, 0);
+    }
+}
diff --git 
a/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/GridExtentTest.java
 
b/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/GridExtentTest.java
index b1ceb4dc7d..e0ed90d131 100644
--- 
a/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/GridExtentTest.java
+++ 
b/core/sis-feature/src/test/java/org/apache/sis/coverage/grid/GridExtentTest.java
@@ -48,7 +48,7 @@ import static org.apache.sis.test.ReferencingAssert.*;
  * @author  Martin Desruisseaux (IRD, Geomatys)
  * @author  Alexis Manin (Geomatys)
  * @author  Johann Sorel (Geomatys)
- * @version 1.3
+ * @version 1.4
  * @since   1.0
  */
 public final class GridExtentTest extends TestCase {
@@ -350,16 +350,16 @@ public final class GridExtentTest extends TestCase {
     }
 
     /**
-     * Tests {@link GridExtent#getSubspaceDimensions(int)}.
+     * Tests {@link GridExtent#getSubspaceDimensions(int)} and {@link 
GridExtent#getLargestDimensions(int)}.
      * Opportunistically tests {@link GridExtent#getSliceCoordinates()} since 
the two methods closely related.
      */
     @Test
     public void testGetSubspaceDimensions() {
         final GridExtent extent = new GridExtent(null, new long[] {100, 5, 
200, 40}, new long[] {500, 5, 800, 40}, true);
         assertMapEquals(Map.of(1, 5L, 3, 40L), extent.getSliceCoordinates());
-        assertArrayEquals(new int[] {0,  2  }, 
extent.getSubspaceDimensions(2));
-        assertArrayEquals(new int[] {0,1,2  }, 
extent.getSubspaceDimensions(3));
-        assertArrayEquals(new int[] {0,1,2,3}, 
extent.getSubspaceDimensions(4));
+        assertSubspaceEquals(extent, 0,  2  );
+        assertSubspaceEquals(extent, 0,1,2  );
+        assertSubspaceEquals(extent, 0,1,2,3);
         try {
             extent.getSubspaceDimensions(1);
             fail("Should not reduce to 1 dimension.");
@@ -368,6 +368,18 @@ public final class GridExtentTest extends TestCase {
         }
     }
 
+    /**
+     * Verifies the result of {@code getSubspaceDimensions(…)} and {@code 
getLargestDimensions(…)}.
+     * In this test, the two methods should produce the same results.
+     *
+     * @param extent    the grid extent to test.
+     * @param expected  the expected result.
+     */
+    private static void assertSubspaceEquals(final GridExtent extent, final 
int... expected) {
+        assertArrayEquals(expected, 
extent.getSubspaceDimensions(expected.length));
+        assertArrayEquals(expected, extent.getLargestDimensions 
(expected.length));
+    }
+
     /**
      * Tests {@link GridExtent#cornerToCRS(Envelope, long, int[])}.
      */
diff --git 
a/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
 
b/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
index 4f233d0744..d0984716c4 100644
--- 
a/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
+++ 
b/core/sis-feature/src/test/java/org/apache/sis/test/suite/FeatureTestSuite.java
@@ -110,6 +110,7 @@ import org.junit.runners.Suite;
     org.apache.sis.coverage.CategoryListTest.class,
     org.apache.sis.coverage.SampleDimensionTest.class,
     org.apache.sis.coverage.SampleRangeFormatTest.class,
+    org.apache.sis.coverage.CoverageCombinerTest.class,
     org.apache.sis.coverage.grid.PixelTranslationTest.class,
     org.apache.sis.coverage.grid.GridOrientationTest.class,
     org.apache.sis.coverage.grid.GridExtentTest.class,
diff --git 
a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/MathTransforms.java
 
b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/MathTransforms.java
index 74c4b9c99c..9b7cbae31f 100644
--- 
a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/MathTransforms.java
+++ 
b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/MathTransforms.java
@@ -21,6 +21,7 @@ import java.util.List;
 import java.util.BitSet;
 import java.util.Optional;
 import java.awt.geom.AffineTransform;
+import javax.measure.UnitConverter;
 import org.opengis.util.FactoryException;
 import org.opengis.geometry.Envelope;
 import org.opengis.geometry.DirectPosition;
@@ -251,6 +252,21 @@ public final class MathTransforms extends Static {
         }
     }
 
+    /**
+     * Converts the given unit converter to a math transform.
+     * This is a bridge between Unit API and referencing API.
+     *
+     * @param  converter  the unit converter.
+     * @return a transform doing the same computation than the given unit 
converter.
+     *
+     * @since 1.4
+     */
+    @SuppressWarnings("fallthrough")
+    public static MathTransform1D convert(final UnitConverter converter) {
+        ArgumentChecks.ensureNonNull("converter", converter);
+        return UnitConversion.create(converter);
+    }
+
     /**
      * Creates a transform for the <i>y=f(x)</i> function where <var>y</var> 
are computed by a linear interpolation.
      * Both {@code preimage} (the <var>x</var>) and {@code values} (the 
<var>y</var>) arguments can be null:
diff --git 
a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/UnitConversion.java
 
b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/UnitConversion.java
new file mode 100644
index 0000000000..9a39fb2508
--- /dev/null
+++ 
b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/UnitConversion.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.referencing.operation.transform;
+
+import java.io.Serializable;
+import javax.measure.UnitConverter;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.MathTransform1D;
+import org.opengis.referencing.operation.MathTransformFactory;
+import org.opengis.referencing.operation.TransformException;
+import org.opengis.util.FactoryException;
+import org.apache.sis.internal.referencing.Resources;
+import org.apache.sis.measure.Units;
+
+
+/**
+ * Bridge between Unit API and referencing API.
+ * This is used only when the converter is non-linear or is not a recognized 
implementation.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+final class UnitConversion extends AbstractMathTransform1D implements 
Serializable {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = -7344042406568682405L;
+
+    /**
+     * The unit converter to wrap.
+     */
+    @SuppressWarnings("serial")                 // Apache SIS implementation 
is serializable.
+    private final UnitConverter converter;
+
+    /**
+     * The inverse conversion, computed when first needed.
+     */
+    private UnitConversion inverse;
+
+    /**
+     * Creates a new wrapper.
+     *
+     * @param converter the unit converter to wrap.
+     */
+    private UnitConversion(final UnitConverter converter) {
+        this.converter = converter;
+    }
+
+    /**
+     * Converts the given unit converter to a math transform.
+     */
+    @SuppressWarnings("fallthrough")
+    static MathTransform1D create(final UnitConverter converter) {
+        Number[] coefficients = Units.coefficients(converter);
+        if (coefficients != null) {
+            Number scale = 1, offset = 0;
+            switch (coefficients.length) {
+                case 2: scale  = coefficients[1];     // Fall through
+                case 1: offset = coefficients[0];     // Fall through
+                case 0: return LinearTransform1D.create(scale, offset);
+            }
+        }
+        return new UnitConversion(converter);
+    }
+
+    /**
+     * Tests whether this transform changes any value.
+     */
+    @Override
+    public boolean isIdentity() {
+        return converter.isIdentity();
+    }
+
+    /**
+     * Converts the given value.
+     *
+     * @param  value  the value to convert.
+     * @return the converted value.
+     */
+    @Override
+    public double transform(double value) {
+        return converter.convert(value);
+    }
+
+    /**
+     * Computes the derivative at the given value.
+     *
+     * @param  value  the value for which to compute derivative.
+     * @return the derivative for the given value.
+     * @throws TransformException if the derivative cannot be computed.
+     */
+    @Override
+    public double derivative(double value) throws TransformException {
+        final double derivative = Units.derivative(converter, value);
+        if (Double.isNaN(derivative) && !Double.isNaN(value)) {
+            throw new 
TransformException(Resources.format(Resources.Keys.CanNotComputeDerivative));
+        }
+        return derivative;
+    }
+
+    /**
+     * Returns the inverse transform of this object.
+     */
+    @Override
+    public synchronized MathTransform1D inverse() {
+        if (inverse == null) {
+            inverse = new UnitConversion(converter.inverse());
+            inverse.inverse = this;
+        }
+        return inverse;
+    }
+
+    /**
+     * Concatenates or pre-concatenates in an optimized way this math 
transform with the given one, if possible.
+     *
+     * @return the math transforms combined in an optimized way, or {@code 
null} if no such optimization is available.
+     */
+    @Override
+    protected MathTransform tryConcatenate(boolean applyOtherFirst, 
MathTransform other, MathTransformFactory factory)
+            throws FactoryException
+    {
+        if (other instanceof UnitConversion) {
+            final var that = (UnitConversion) other;
+            return create(applyOtherFirst
+                    ? that.converter.concatenate(this.converter)
+                    : this.converter.concatenate(that.converter));
+        }
+        return super.tryConcatenate(applyOtherFirst, other, factory);
+    }
+}
diff --git 
a/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/UnitConversionTest.java
 
b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/UnitConversionTest.java
new file mode 100644
index 0000000000..f19fdcc6fd
--- /dev/null
+++ 
b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/UnitConversionTest.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.referencing.operation.transform;
+
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.measure.Units;
+import org.apache.sis.test.TestCase;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+
+/**
+ * Tests {@link UnitConversion}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   1.4
+ */
+public final class UnitConversionTest extends TestCase {
+    /**
+     * Tests a linear conversion.
+     */
+    @Test
+    public void testLinear() {
+        final MathTransform tr = 
MathTransforms.convert(Units.KILOMETRE.getConverterTo(Units.METRE));
+        final var linear = (LinearTransform1D) tr;
+        assertEquals(1000, linear.scale,  STRICT);
+        assertEquals(   0, linear.offset, STRICT);
+    }
+
+    /**
+     * Tests a non-linear conversion.
+     *
+     * @throws TransformException if a test value cannot be transformed.
+     */
+    @Test
+    public void testLogarithmic() throws TransformException {
+        final MathTransform tr = 
MathTransforms.convert(Units.UNITY.getConverterTo(Units.DECIBEL));
+        final var wrapper = (UnitConversion) tr;
+        assertEquals(20, wrapper.transform(10), STRICT);
+        assertEquals(10, wrapper.inverse().transform(20), STRICT);
+    }
+}
diff --git 
a/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java
 
b/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java
index cc9fa9ab8f..9102570e02 100644
--- 
a/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java
+++ 
b/core/sis-referencing/src/test/java/org/apache/sis/test/suite/ReferencingTestSuite.java
@@ -133,6 +133,7 @@ import org.junit.BeforeClass;
     
org.apache.sis.referencing.operation.transform.ExponentialTransform1DTest.class,
     
org.apache.sis.referencing.operation.transform.LogarithmicTransform1DTest.class,
     org.apache.sis.referencing.operation.transform.CopyTransformTest.class,
+    org.apache.sis.referencing.operation.transform.UnitConversionTest.class,
     
org.apache.sis.referencing.operation.transform.PassThroughTransformTest.class,
     
org.apache.sis.referencing.operation.transform.ConcatenatedTransformTest.class,
     
org.apache.sis.referencing.operation.transform.TransformSeparatorTest.class,
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/WritableResourceSupport.java
 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/WritableResourceSupport.java
index c65932fdc8..bf8dfedfbf 100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/WritableResourceSupport.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/WritableResourceSupport.java
@@ -20,6 +20,7 @@ import java.util.Locale;
 import java.io.IOException;
 import java.nio.channels.WritableByteChannel;
 import java.awt.geom.AffineTransform;
+import javax.measure.IncommensurableException;
 import org.opengis.util.FactoryException;
 import org.opengis.referencing.operation.MathTransform;
 import org.opengis.referencing.operation.TransformException;
@@ -50,7 +51,7 @@ import org.opengis.coverage.CannotEvaluateException;
  * Helper classes for the management of {@link 
WritableGridCoverageResource.CommonOption}.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.2
+ * @version 1.4
  * @since   1.2
  */
 public final class WritableResourceSupport implements Localized {
@@ -177,12 +178,12 @@ public final class WritableResourceSupport implements 
Localized {
      */
     public final GridCoverage update(final GridCoverage coverage) throws 
DataStoreException {
         final GridCoverage existing = resource.read(null, null);
-        final CoverageCombiner combiner = new CoverageCombiner(existing, 0, 1);
+        final CoverageCombiner combiner = new CoverageCombiner(existing);
         try {
-            if (!combiner.apply(coverage)) {
+            if (!combiner.acceptAll(coverage)) {
                 throw new ReadOnlyStorageException(canNotWrite());
             }
-        } catch (TransformException e) {
+        } catch (TransformException | IncommensurableException e) {
             throw new DataStoreReferencingException(canNotWrite(), e);
         }
         return existing;

Reply via email to