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 348c8c8cd9c097213ec9f1d34eebf4fb6b8603e9 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Thu Nov 25 11:01:56 2021 +0100 Add a `CoverageCanvas.resourceProperty` in replacement of previous WeakReference to resource "originator". Add `MultiResolutionCoverageLoader` class for loading `GridCoverage` from a resource at different resolution. Together, those changes allow `CoverageCanvas` to use data loaded at a pyramid level determined from the zoom level. --- .../apache/sis/gui/coverage/CoverageCanvas.java | 314 ++++++++++++----- .../apache/sis/gui/coverage/CoverageControls.java | 82 ++--- .../apache/sis/gui/coverage/CoverageExplorer.java | 283 ++++++++++----- .../org/apache/sis/gui/coverage/GridControls.java | 11 +- .../java/org/apache/sis/gui/coverage/GridView.java | 8 +- .../org/apache/sis/gui/coverage/ImageRequest.java | 77 ++-- .../gui/coverage/MultiResolutionImageLoader.java | 156 +++++++++ .../org/apache/sis/gui/coverage/RenderingData.java | 224 +++++++++--- .../apache/sis/gui/coverage/ViewAndControls.java | 3 +- .../java/org/apache/sis/gui/map/MapCanvas.java | 6 +- .../org/apache/sis/internal/gui/LogHandler.java | 14 +- .../coverage/MultiResolutionCoverageLoader.java | 388 +++++++++++++++++++++ .../sis/internal/map/coverage/package-info.java} | 32 +- .../MultiResolutionCoverageLoaderTest.java | 180 ++++++++++ .../apache/sis/test/suite/PortrayalTestSuite.java | 5 +- 15 files changed, 1400 insertions(+), 383 deletions(-) diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java index b9b5825..d689e89 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageCanvas.java @@ -33,7 +33,6 @@ import java.awt.BasicStroke; import java.awt.geom.AffineTransform; import java.awt.geom.NoninvertibleTransformException; import java.awt.geom.Rectangle2D; -import java.lang.ref.Reference; import javafx.scene.paint.Color; import javafx.scene.layout.Region; import javafx.scene.layout.Background; @@ -48,14 +47,12 @@ import javax.measure.Quantity; import javax.measure.quantity.Length; import org.opengis.geometry.Envelope; import org.opengis.geometry.DirectPosition; -import org.opengis.util.FactoryException; import org.opengis.referencing.operation.TransformException; import org.opengis.referencing.crs.CoordinateReferenceSystem; import org.apache.sis.coverage.SampleDimension; import org.apache.sis.coverage.grid.GridCoverage; import org.apache.sis.coverage.grid.GridExtent; import org.apache.sis.coverage.grid.GridGeometry; -import org.apache.sis.coverage.grid.ImageRenderer; import org.apache.sis.referencing.operation.matrix.AffineTransforms2D; import org.apache.sis.referencing.operation.transform.LinearTransform; import org.apache.sis.referencing.operation.transform.MathTransforms; @@ -66,6 +63,7 @@ import org.apache.sis.geometry.Shapes2D; import org.apache.sis.image.PlanarImage; import org.apache.sis.image.Interpolation; import org.apache.sis.coverage.Category; +import org.apache.sis.storage.GridCoverageResource; import org.apache.sis.gui.map.MapCanvas; import org.apache.sis.gui.map.MapCanvasAWT; import org.apache.sis.gui.map.StatusBar; @@ -80,12 +78,13 @@ import org.apache.sis.internal.system.Modules; import org.apache.sis.util.logging.Logging; import org.apache.sis.io.TableAppender; import org.apache.sis.measure.Units; -import org.apache.sis.storage.Resource; import org.apache.sis.util.Debug; /** - * A canvas for {@link RenderedImage} provided by a {@link GridCoverage}. + * A canvas for {@link RenderedImage} provided by a {@link GridCoverage} or a {@link GridCoverageResource}. + * In the later case where the source of data is specified by {@link #resourceProperty}, the grid coverage + * instance (given by {@link #coverageProperty}) will change automatically according the zoom level. * * @author Martin Desruisseaux (Geomatys) * @version 1.2 @@ -115,25 +114,61 @@ public class CoverageCanvas extends MapCanvasAWT { static final boolean TRACE = false; /** - * The data shown in this canvas. Note that setting this property to a non-null value may not - * modify the canvas content immediately. Instead, a background process will request the tiles. + * The source of coverage data shown in this canvas. If this property value is non-null, + * then {@link #coverageProperty} value will change at any time (potentially many times) + * depending on the zoom level or other user interaction. Conversely if a value is set + * explicitly on {@link #coverageProperty}, then this {@code resourceProperty} is cleared. + * + * @see #getResource() + * @see #setResource(GridCoverageResource) + * @see CoverageExplorer#resourceProperty + * + * @since 1.2 + */ + public final ObjectProperty<GridCoverageResource> resourceProperty; + + /** + * The data shown in this canvas. This property value may be set implicitly or explicitly: + * <ul> + * <li>If the {@link #resourceProperty} value is non-null, then the value will change + * automatically at any time (potentially many times) depending on user interaction.</li> + * <li>Conversely if an explicit value is set on this property, + * then the {@link #resourceProperty} is cleared.</li> + * </ul> + * + * Note that a change in this property value may not modify the canvas content immediately. + * Instead, a background process will request the tiles and update the canvas content later, + * when data are ready. * * <p>Current implementation is restricted to {@link GridCoverage} instances, but a future * implementation may generalize to {@link org.opengis.coverage.Coverage} instances.</p> * * @see #getCoverage() * @see #setCoverage(GridCoverage) + * @see CoverageExplorer#coverageProperty */ public final ObjectProperty<GridCoverage> coverageProperty; /** + * Whether {@link #resourceProperty} or {@link #coverageProperty} is in process of being adjusted. + * This is used for preventing never-ending loop when a change of resource causes a change of coverage + * or conversely. + * + * @see #onPropertySpecified(ObjectProperty) + */ + private boolean isCoverageAdjusting; + + /** * A subspace of the grid coverage extent where all dimensions except two have a size of 1 cell. * May be {@code null} if the grid coverage has only two dimensions with a size greater than 1 cell. * * @see #getSliceExtent() * @see #setSliceExtent(GridExtent) * @see GridCoverage#render(GridExtent) + * + * @deprecated We will need a different mechanism for specifying slice dimensions. */ + @Deprecated public final ObjectProperty<GridExtent> sliceExtentProperty; /** @@ -193,14 +228,6 @@ public class CoverageCanvas extends MapCanvasAWT { private volatile LogRecord errorReport; /** - * The resource from which the data has been read, or {@code null} if unknown. - * This is used only for determining a target window for logging records. - * - * @see #setOriginator(Reference) - */ - private Reference<Resource> originator; - - /** * Renderer of isolines, or {@code null} if none. The presence of this field in this class may be temporary. * A future version may replace this field by a more complete styling framework. Note that this class holds * references to {@link javafx.scene.control.TableView} list of items, which are the list of isoline levels @@ -224,11 +251,13 @@ public class CoverageCanvas extends MapCanvasAWT { super(locale); data = new RenderingData((report) -> errorReport = report.getDescription()); derivedImages = new EnumMap<>(Stretching.class); + resourceProperty = new SimpleObjectProperty<>(this, "resource"); coverageProperty = new SimpleObjectProperty<>(this, "coverage"); sliceExtentProperty = new SimpleObjectProperty<>(this, "sliceExtent"); interpolationProperty = new SimpleObjectProperty<>(this, "interpolation", data.processor.getInterpolation()); - coverageProperty .addListener((p,o,n) -> onImageSpecified()); - sliceExtentProperty .addListener((p,o,n) -> onImageSpecified()); + resourceProperty .addListener((p,o,n) -> onPropertySpecified(n, null, coverageProperty)); + coverageProperty .addListener((p,o,n) -> onPropertySpecified(null, n, resourceProperty)); + sliceExtentProperty .addListener((p,o,n) -> onPropertySpecified(getResource(), getCoverage(), null)); interpolationProperty.addListener((p,o,n) -> onInterpolationSpecified(n)); imageMargin.set(new Insets(100)); } @@ -256,18 +285,41 @@ public class CoverageCanvas extends MapCanvasAWT { } /** - * Sets the resource from which the data has been read. - * This is used only for determining a target window for logging records. + * Returns the source of coverages for this viewer. + * This method, like all other methods in this class, shall be invoked from the JavaFX thread. * - * @param originator the resource from which the data has been read, or {@code null} if unknown. + * @return the source of coverages shown in this viewer, or {@code null} if none. + * + * @see #resourceProperty + * + * @since 1.2 */ - final void setOriginator(final Reference<Resource> originator) { - this.originator = originator; + public final GridCoverageResource getResource() { + return resourceProperty.get(); + } + + /** + * Sets the source of coverages shown in this viewer. + * This method shall be invoked from JavaFX thread and returns immediately. + * The new data are loaded in a background thread and the {@link #coverageProperty} + * value will be updated after an undetermined amount of time. + * + * @param resource the source of data to show in this viewer, or {@code null} if none. + * + * @see #resourceProperty + * + * @since 1.2 + */ + public final void setResource(final GridCoverageResource resource) { + resourceProperty.set(resource); + // Will indirectly invoke `onPropertySpecified(…)`. } /** * Returns the source of image for this viewer. * This method, like all other methods in this class, shall be invoked from the JavaFX thread. + * Note that this value may change at any time (depending on user interaction) + * if the {@link #resourceProperty} has a non-null value. * * @return the coverage shown in this explorer, or {@code null} if none. * @@ -283,13 +335,31 @@ public class CoverageCanvas extends MapCanvasAWT { * The new data are loaded in a background thread and will appear after an * undetermined amount of time. * + * <p>Invoking this method sets the {@link #resourceProperty} value to {@code null}.</p> + * * @param coverage the data to show in this viewer, or {@code null} if none. * * @see #coverageProperty */ public final void setCoverage(final GridCoverage coverage) { - assert Platform.isFxApplicationThread(); coverageProperty.set(coverage); + // Will indirectly invoke `onPropertySpecified(…)`. + } + + /** + * Sets both resource and coverage properties together. Typically only one of those properties is non-null. + * If both are non-null, then it is caller's responsibility to ensure that they are consistent. + */ + final void setCoverage(final GridCoverageResource resource, final GridCoverage coverage) { + final boolean p = isCoverageAdjusting; + try { + isCoverageAdjusting = true; + setResource(resource); + setCoverage(coverage); + } finally { + isCoverageAdjusting = p; + } + onPropertySpecified(resource, coverage, null); } /** @@ -299,7 +369,10 @@ public class CoverageCanvas extends MapCanvasAWT { * * @see #sliceExtentProperty * @see GridCoverage#render(GridExtent) + * + * @deprecated We will need a different mechanism for specifying slice dimensions. */ + @Deprecated public final GridExtent getSliceExtent() { return sliceExtentProperty.get(); } @@ -311,10 +384,13 @@ public class CoverageCanvas extends MapCanvasAWT { * * @see #sliceExtentProperty * @see GridCoverage#render(GridExtent) + * + * @deprecated We will need a different mechanism for specifying slice dimensions. */ + @Deprecated public final void setSliceExtent(final GridExtent sliceExtent) { - assert Platform.isFxApplicationThread(); sliceExtentProperty.set(sliceExtent); + // Will indirectly invoke `onPropertySpecified(…)`. } /** @@ -384,7 +460,7 @@ public class CoverageCanvas extends MapCanvasAWT { */ @Override public void setObjectiveCRS(final CoordinateReferenceSystem newValue, DirectPosition anchor) throws RenderException { - final Long id = LogHandler.loadingStart(originator); + final Long id = LogHandler.loadingStart(getResource()); try { super.setObjectiveCRS(newValue, anchor); } finally { @@ -403,7 +479,7 @@ public class CoverageCanvas extends MapCanvasAWT { */ @Override public void setGridGeometry(final GridGeometry newValue) throws RenderException { - final Long id = LogHandler.loadingStart(originator); + final Long id = LogHandler.loadingStart(getResource()); try { super.setGridGeometry(newValue); } finally { @@ -413,40 +489,70 @@ public class CoverageCanvas extends MapCanvasAWT { } /** - * Invoked when a new coverage has been specified or when the slice extent changed. - * This method fetches the image (which may imply data loading) in a background thread. + * Invoked when a new value has been set on {@link #resourceProperty} or {@link #coverageProperty}. + * This method fetches information such as the grid geometry and sample dimensions in a background thread. + * Those information will be used for initializing "objective CRS" and "objective to display" to new values. + * Rendering will happen in another background computation. + * + * @param resource the new resource, or {@code null} if none. + * @param coverage the new coverage, or {@code null} if none. + * @param toClear the property which is an alternative to the property that has been set. */ - private void onImageSpecified() { - final GridCoverage coverage = getCoverage(); - if (coverage == null) { + private void onPropertySpecified(final GridCoverageResource resource, final GridCoverage coverage, + final ObjectProperty<?> toClear) + { + if (isCoverageAdjusting) { + return; + } + if (toClear != null) try { + isCoverageAdjusting = true; + toClear.set(null); + } finally { + isCoverageAdjusting = false; + } + if (resource == null && coverage == null) { runAfterRendering(this::clear); } else { - final GridExtent sliceExtent = getSliceExtent(); - BackgroundThreads.execute(new Task<RenderedImage>() { - /** - * The coverage geometry reduced to two dimensions and with a translation taking in account - * the {@code sliceExtent}. That value will be stored in {@link CoverageCanvas#dataGeometry}. - */ - private GridGeometry imageGeometry; + BackgroundThreads.execute(new Task<Envelope>() { + /** The coverage geometry reduced to two dimensions. */ + private GridGeometry domain; - /** - * Invoked in a background thread for fetching the image and computing its geometry. The image - * geometry should be provided by {@value PlanarImage#GRID_GEOMETRY_KEY} property. But if that - * property is not provided, {@link ImageRenderer} is used as a fallback for computing it. - */ - @Override protected RenderedImage call() throws FactoryException { - final Long id = LogHandler.loadingStart(originator); + /** Information about all bands. */ + private List<SampleDimension> ranges; + + /** Fetch coverage domain, range and geospatial envelope. */ + @Override protected Envelope call() throws Exception { + final Long id = LogHandler.loadingStart(resource); try { - final RenderedImage image = coverage.render(sliceExtent); - final Object value = image.getProperty(PlanarImage.GRID_GEOMETRY_KEY); - imageGeometry = (value instanceof GridGeometry) ? (GridGeometry) value - : new ImageRenderer(coverage, sliceExtent).getImageGeometry(BIDIMENSIONAL); - return image; + if (coverage != null) { + domain = coverage.getGridGeometry(); + ranges = coverage.getSampleDimensions(); + } else { + domain = resource.getGridGeometry(); + ranges = resource.getSampleDimensions(); + } + domain = MultiResolutionImageLoader.slice(domain); + if (domain != null) { + if (domain.isDefined(GridGeometry.ENVELOPE)) { + return domain.getEnvelope(); + } + if (domain.isDefined(GridGeometry.EXTENT)) { + final GeneralEnvelope ge = domain.getExtent().toEnvelope(MathTransforms.identity(BIDIMENSIONAL)); + ge.setCoordinateReferenceSystem(CommonCRS.Engineering.DISPLAY.crs()); + return ge; + } + } + return null; } finally { LogHandler.loadingStop(id); } } + /** Invoked in JavaFX thread for setting the grid geometry we just fetched. */ + @Override protected void succeeded() { + runAfterRendering(() -> setNewSource(getValue(), domain, ranges)); + } + /** * Invoked when an error occurred while loading an image or processing it. * This method popups the dialog box immediately because it is considered @@ -457,49 +563,32 @@ public class CoverageCanvas extends MapCanvasAWT { errorOccurred(ex); ExceptionReporter.canNotUseResource(fixedPane, ex); } - - /** - * Invoked in JavaFX thread for setting the image to the instance we just fetched. - */ - @Override protected void succeeded() { - final RenderedImage image = getValue(); - final GridGeometry gg = imageGeometry; - List<SampleDimension> sd = coverage.getSampleDimensions(); - runAfterRendering(() -> setRawImage(image, gg, sd)); - } }); } } /** - * Invoked when a new image has been successfully loaded. The given image must be the "raw" image, - * without resampling and without color ramp stretching. The call to this method is followed by a - * a repaint event, which will cause the image to be resampled in a background thread. + * Invoked when a new resource or coverage has been specified. + * The call to this method is followed by a repaint event, + * which will cause the image to be loaded and resampled in a background thread. * * <p>All arguments can be {@code null} for clearing the canvas. * This method is invoked in JavaFX thread.</p> + * + * @param bounds geospatial bounds, or {@code null} if none or unknown. + * @param domain the two-dimensional grid geometry, or {@code null} if there is no data. + * @param ranges descriptions of bands, or {@code null} if there is no data. */ - private void setRawImage(final RenderedImage image, final GridGeometry domain, final List<SampleDimension> ranges) { + private void setNewSource(final Envelope bounds, final GridGeometry domain, final List<SampleDimension> ranges) { if (TRACE) { - trace("setRawImage(…): the new source of data is:%n\t%s", image); + trace("setNewSource(…): the new domain of data is:%n\t%s", domain); } clearError(); clearIsolines(); resampledImage = null; derivedImages.clear(); - data.setImage(image, domain, ranges); - Envelope bounds = null; - if (domain != null) { - if (domain.isDefined(GridGeometry.ENVELOPE)) { - bounds = domain.getEnvelope(); - } else if (domain.isDefined(GridGeometry.EXTENT)) try { - final GeneralEnvelope ge = domain.getExtent().toEnvelope(MathTransforms.identity(BIDIMENSIONAL)); - ge.setCoordinateReferenceSystem(CommonCRS.Engineering.DISPLAY.crs()); - bounds = ge; - } catch (TransformException e) { - unexpectedException(e); // Should never happen. - } - } + data.clear(); + data.setCoverageSpace(domain, ranges); setObjectiveBounds(bounds); requestRepaint(); // Cause `Worker` class to be executed. } @@ -540,10 +629,11 @@ public class CoverageCanvas extends MapCanvasAWT { /** * Resample and paint image in the canvas. This class performs some or all of the following tasks, in order. - * It is possible to skip the first tasks if they are already done, but after the work started at some point - * all remaining points are executed: + * It is possible to skip the two first tasks if they were already done, but after the work started at some + * point all remaining points are executed: * * <ol> + * <li>Read a new coverage if zoom as changed more than some threshold value.</li> * <li>Compute statistics on sample values (if needed).</li> * <li>Stretch the color ramp (if requested).</li> * <li>Resample the image and convert to integer values.</li> @@ -552,6 +642,21 @@ public class CoverageCanvas extends MapCanvasAWT { */ private static final class Worker extends Renderer { /** + * The resource from which the data has been read, or {@code null} if unknown. + * This is used for loading a coverage if none were explicitly specified, + * and for determining a target window for logging records. + */ + private final GridCoverageResource resource; + + /** + * If a coverage has been loaded from the {@linkplain #resource}, the coverage. + * Otherwise {@code null} for meaning "no change". + */ + private GridCoverage coverage; + + @Deprecated private final GridExtent sliceExtent; + + /** * Value of {@link CoverageCanvas#data} at the time this worker has been initialized. */ private final RenderingData data; @@ -622,12 +727,6 @@ public class CoverageCanvas extends MapCanvasAWT { private AffineTransform resampledToDisplay; /** - * The resource from which the data has been read, or {@code null} if unknown. - * This is used only for determining a target window for logging records. - */ - private final Reference<Resource> originator; - - /** * Snapshot of information required for rendering isolines, or {@code null} if none. */ private IsolineRenderer.Snapshot[] isolines; @@ -636,8 +735,10 @@ public class CoverageCanvas extends MapCanvasAWT { * Creates a new renderer. Shall be invoked in JavaFX thread. */ Worker(final CoverageCanvas canvas) { - originator = canvas.originator; + resource = canvas.getResource(); + coverage = canvas.getCoverage(); data = canvas.data.clone(); + sliceExtent = canvas.getSliceExtent(); objectiveCRS = canvas.getObjectiveCRS(); objectiveToDisplay = canvas.getObjectiveToDisplay(); displayBounds = canvas.getDisplayBounds(); @@ -648,10 +749,12 @@ public class CoverageCanvas extends MapCanvasAWT { } final Insets margin = canvas.imageMargin.get(); if (margin != null) { - displayBounds.x -= margin.getLeft(); - displayBounds.width += margin.getLeft() + margin.getRight(); - displayBounds.y -= margin.getTop(); - displayBounds.height += margin.getTop() + margin.getBottom(); + final double top = margin.getTop(); + final double left = margin.getLeft(); + displayBounds.x -= left; + displayBounds.width += left + margin.getRight(); + displayBounds.y -= top; + displayBounds.height += top + margin.getBottom(); } if (canvas.isolines != null) try { isolines = canvas.isolines.prepare(); @@ -685,9 +788,20 @@ public class CoverageCanvas extends MapCanvasAWT { @Override @SuppressWarnings("PointlessBitwiseExpression") protected void render() throws Exception { - final Long id = LogHandler.loadingStart(originator); + final Long id = LogHandler.loadingStart(resource); try { /* + * Update the transform from data CRS to objective CRS if that transform needs to be computed + * (otherwise do nothing). After the objective CRS is set, we can compute the pyramid level. + * It will determine if data needs to be loaded again. + */ + data.setObjectiveCRS(objectiveCRS); + if (resource != null) { + data.coverageLoader = MultiResolutionImageLoader.getInstance(resource, data.coverageLoader); + coverage = data.ensureCoverageLoaded(objectiveToDisplay, objectivePOI); + } + data.ensureImageLoaded(coverage, sliceExtent); + /* * Find whether resampling to apply is different than the resampling used last time that the image * has been rendered, ignoring translations. Translations do not require new resampling operations * because we can manage translations by changing `RenderedImage` coordinates. @@ -718,7 +832,7 @@ public class CoverageCanvas extends MapCanvasAWT { trace("render(): recolor by application of %s.", data.selectedDerivative); } } - resampledImage = data.resampleAndConvert(recoloredImage, objectiveCRS, objectiveToDisplay, objectivePOI); + resampledImage = data.resampleAndConvert(recoloredImage, objectiveToDisplay, objectivePOI); resampledToDisplay = data.getTransform(objectiveToDisplay); if (TRACE) { trace("render(): resampling result:%n\t%s", resampledImage); @@ -836,6 +950,18 @@ public class CoverageCanvas extends MapCanvasAWT { errorReport = null; errorOccurred(report.getThrown()); } + /* + * If the coverage changed, notify user. Note that a null coverage means "no change". + */ + if (!isCoverageAdjusting) { + final GridCoverage coverage = worker.coverage; + if (coverage != null) try { + isCoverageAdjusting = true; + setCoverage(coverage); + } finally { + isCoverageAdjusting = false; + } + } } /** @@ -915,7 +1041,7 @@ public class CoverageCanvas extends MapCanvasAWT { if (TRACE) { trace("clear()"); } - setRawImage(null, null, null); + setNewSource(null, null, null); super.clear(); } diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java index 1f5d9ca..49cff1b 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageControls.java @@ -17,7 +17,6 @@ package org.apache.sis.gui.coverage; import java.util.Locale; -import java.lang.ref.WeakReference; import javafx.scene.control.Accordion; import javafx.scene.control.Control; import javafx.scene.control.TitledPane; @@ -26,19 +25,16 @@ import javafx.scene.layout.GridPane; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; import javafx.collections.ObservableList; -import javafx.concurrent.Task; import javafx.scene.control.Label; import javafx.scene.control.TableView; import javafx.scene.control.Tooltip; import javafx.scene.paint.Color; import org.apache.sis.coverage.Category; import org.apache.sis.coverage.grid.GridCoverage; -import org.apache.sis.storage.DataStoreException; -import org.apache.sis.storage.Resource; +import org.apache.sis.storage.GridCoverageResource; import org.apache.sis.gui.map.MapMenu; import org.apache.sis.gui.map.StatusBar; import org.apache.sis.internal.gui.control.ValueColorMapper; -import org.apache.sis.internal.gui.BackgroundThreads; import org.apache.sis.internal.gui.Styles; import org.apache.sis.internal.gui.Resources; import org.apache.sis.util.resources.Vocabulary; @@ -159,76 +155,54 @@ final class CoverageControls extends ViewAndControls { final TitledPane p4 = new TitledPane(vocabulary.getString(Vocabulary.Keys.Properties), null); controls = new Accordion(p1, p2, p3, p4); controls.setExpandedPane(p1); - view.coverageProperty.addListener((p,o,n) -> coverageChanged(null, n)); + /* + * Set listeners: changes on `CoverageCanvas` properties are propagated to the corresponding + * `CoverageExplorer` properties. This constructor does not install listeners in the opposite + * direction; instead `CoverageExplorer` will invoke `load(ImageRequest)`. + */ + view.resourceProperty.addListener((p,o,n) -> onPropertySet(n, null)); + view.coverageProperty.addListener((p,o,n) -> onPropertySet(null, n)); p4.expandedProperty().addListener(new PropertyPaneCreator(view, p4)); } /** - * Invoked in JavaFX thread after {@link CoverageCanvas#setCoverage(GridCoverage)}. - * This method updates the GUI with new information available. + * Invoked in JavaFX thread after {@link CoverageCanvas} resource or coverage property value changed. + * This method updates the controls GUI with new information available and update the corresponding + * {@link CoverageExplorer} properties. * - * @param source the new source of coverage, or {@code null} if none. - * @param data the new coverage, or {@code null} if none. + * @param resource the new source of coverage, or {@code null} if none. + * @param coverage the new coverage, or {@code null} if none. */ - private void coverageChanged(final Resource source, final GridCoverage data) { + private void onPropertySet(final GridCoverageResource resource, final GridCoverage coverage) { final ObservableList<Category> items = categoryTable.getItems(); - if (data == null) { + if (coverage == null) { items.clear(); } else { final int visibleBand = 0; // TODO: provide a selector for the band to show. - items.setAll(data.getSampleDimensions().get(visibleBand).getCategories()); + items.setAll(coverage.getSampleDimensions().get(visibleBand).getCategories()); } - owner.coverageChanged(source, data); + owner.notifyDataChanged(resource, coverage); } /** * Sets the view content to the given coverage. - * This method starts a background thread. + * This method is invoked when a new source of data (either a resource or a coverage) is specified, + * or when a previously hidden view is made visible. This implementation starts a background thread. * - * @param request the coverage to set, or {@code null} for clearing the view. + * @param request the resource or coverage to set, or {@code null} for clearing the view. */ @Override final void load(final ImageRequest request) { - if (request == null) { - view.setOriginator(null); - view.setCoverage(null); + final GridCoverageResource resource; + final GridCoverage coverage; + if (request != null) { + resource = request.resource; + coverage = request.getCoverage().orElse(null); } else { - view.setOriginator(request.resource != null ? new WeakReference<>(request.resource) : null); - request.getCoverage().ifPresentOrElse(view::setCoverage, - () -> BackgroundThreads.execute(new Loader(request))); - } - } - - /** - * A task for loading {@link GridCoverage} from a resource in a background thread. - * - * @todo Remove this loader, replace by a {@code resourceProperty} in {@link CoverageCanvas}. - */ - private final class Loader extends Task<GridCoverage> { - /** The coverage resource together with optional parameters for reading only a subset. */ - private final ImageRequest request; - - /** Creates a new task for loading a coverage from the specified resource. */ - Loader(final ImageRequest request) { - this.request = request; - } - - /** Invoked in background thread for loading the coverage. */ - @Override protected GridCoverage call() throws DataStoreException { - request.load(this, true, false); - return request.getCoverage().orElse(null); - } - - /** Invoked in JavaFX thread after successful loading. */ - @Override protected void succeeded() { - view.setCoverage(getValue()); - } - - /** Invoked in JavaFX thread on failure. */ - @Override protected void failed() { - view.setCoverage(null); - request.reportError(imageAndStatus, getException()); + resource = null; + coverage = null; } + view.setCoverage(resource, coverage); } /** diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageExplorer.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageExplorer.java index 698945a..6ddd16a 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageExplorer.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/CoverageExplorer.java @@ -29,6 +29,7 @@ import javafx.scene.layout.Region; import javafx.event.ActionEvent; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; +import org.apache.sis.storage.GridCoverageResource; import org.apache.sis.coverage.grid.GridCoverage; import org.apache.sis.coverage.grid.GridGeometry; import org.apache.sis.internal.gui.Resources; @@ -38,7 +39,6 @@ import org.apache.sis.util.ArgumentChecks; import org.apache.sis.gui.referencing.RecentReferenceSystems; import org.apache.sis.gui.map.StatusBar; import org.apache.sis.gui.Widget; -import org.apache.sis.storage.Resource; /** @@ -118,54 +118,93 @@ public class CoverageExplorer extends Widget { } /** - * The coverage shown in this explorer. Note that setting this property to a non-null value may not - * modify the view content immediately. Instead, a background process will request the tiles. + * The type of view (image or tabular data) shown in this explorer. + * + * @see #getViewType() + * @see #setViewType(View) + */ + public final ObjectProperty<View> viewTypeProperty; + + /** + * The source of coverage data shown in this explorer. If this property value is non-null, + * then {@link #coverageProperty} value will change at any time (potentially many times) + * depending on the zoom level or other user interaction. Conversely if a value is set + * explicitly on {@link #coverageProperty}, then this {@code resourceProperty} is cleared. + * + * <h4>Relationship with view properties</h4> + * This property is "weakly bound" to {@link CoverageCanvas#resourceProperty}: + * the two properties generally have the same value but are not necessarily updated in same time. + * After a value is set on one property, the other property may be updated only after some background process + * (e.g. loading) finished. If a view is not the {@linkplain #getViewType() currently visible view}, + * its property may be updated only when the view become visible. + * + * @see #getResource() + * @see #setResource(GridCoverageResource) + * @see CoverageCanvas#resourceProperty + * + * @since 1.2 + */ + public final ObjectProperty<GridCoverageResource> resourceProperty; + + /** + * The data shown in this canvas. This property value may be set implicitly or explicitly: + * <ul> + * <li>If the {@link #resourceProperty} value is non-null, then the value will change + * automatically at any time (potentially many times) depending on user interaction.</li> + * <li>Conversely if an explicit value is set on this property, + * then the {@link #resourceProperty} is cleared.</li> + * </ul> + * + * Note that a change in this property value may not modify the canvas content immediately. + * Instead, a background process will request the tiles and update the canvas content later, + * when data are ready. * * <p>Current implementation is restricted to {@link GridCoverage} instances, but a future * implementation may generalize to {@link org.opengis.coverage.Coverage} instances.</p> * + * <h4>Relationship with view properties</h4> + * This property is "weakly bound" to {@link CoverageCanvas#coverageProperty}: + * the two properties generally have the same value but are not necessarily updated in same time. + * After a value is set on one property, the other property may be updated only after some background process + * (e.g. loading) finished. If a view is not the {@linkplain #getViewType() currently visible view}, + * its property may be updated only when the view become visible. + * * @see #getCoverage() * @see #setCoverage(GridCoverage) * @see #setCoverage(ImageRequest) + * @see CoverageCanvas#coverageProperty */ public final ObjectProperty<GridCoverage> coverageProperty; /** - * The type of view (image or tabular data) shown in this explorer. - * - * @see #getViewType() - * @see #setViewType(View) - */ - public final ObjectProperty<View> viewTypeProperty; - - /** * Whether the {@link #coverageProperty} is in process of being set, * in which case some listeners should not react. */ private boolean isCoverageAdjusting; /** - * The control that put everything together, created when first requested. - * The type of control may change in any future SIS version. - * - * @see #getView() - * @see #onViewTypeSpecified(View) + * Handles the {@link javafx.scene.control.ChoiceBox} and menu items for selecting a CRS. */ - private SplitPane content; + final RecentReferenceSystems referenceSystems; /** * The different views we can provide on {@link #coverageProperty}, together with associated controls. * Values in this map are initially null and created when first needed. * Concrete classes are {@link GridControls} and {@link CoverageControls}. * - * @see #getControl(View) + * @see #getDataView(View) + * @see #getControls(View) */ private final EnumMap<View,ViewAndControls> views; /** - * Handles the {@link javafx.scene.control.ChoiceBox} and menu items for selecting a CRS. + * The control that put everything together, created when first requested. + * The type of control may change in any future SIS version. + * + * @see #getView() + * @see #onViewTypeSet(View) */ - final RecentReferenceSystems referenceSystems; + private SplitPane content; /** * Creates an initially empty explorer with default view type. @@ -192,24 +231,22 @@ public class CoverageExplorer extends Widget { */ public CoverageExplorer(final View type) { ArgumentChecks.ensureNonNull("type", type); - coverageProperty = new SimpleObjectProperty<>(this, "coverage"); + views = new EnumMap<>(View.class); viewTypeProperty = new NonNullObjectProperty<>(this, "viewType", type); - viewTypeProperty.addListener((p,o,n) -> onViewTypeSpecified(n)); - coverageProperty.addListener((p,o,n) -> onCoverageSpecified(n)); + resourceProperty = new SimpleObjectProperty<> (this, "resource"); + coverageProperty = new SimpleObjectProperty<> (this, "coverage"); referenceSystems = new RecentReferenceSystems(); referenceSystems.addUserPreferences(); referenceSystems.addAlternatives("EPSG:4326", "EPSG:3395"); // WGS 84 / World Mercator - /* - * The coverage property may be shown in various ways (tabular data, image). - * Each visualization way is a value in the `views` map. - * Elements will be created when first needed. - */ - views = new EnumMap<>(View.class); + viewTypeProperty.addListener((p,o,n) -> onViewTypeSet(n)); + resourceProperty.addListener((p,o,n) -> onPropertySet(n, null, coverageProperty)); + coverageProperty.addListener((p,o,n) -> onPropertySet(null, n, resourceProperty)); } /** * Returns the view-control pair for the given view type. * The view-control pair is created when first needed. + * Invoking this method may cause data to be loaded in a background thread. * * @param type type of view to obtain. * @param load whether to force loading of data in the new type. @@ -233,9 +270,10 @@ public class CoverageExplorer extends Widget { * and became selected (visible). */ if (load) { + final GridCoverageResource resource = getResource(); final GridCoverage coverage = getCoverage(); - if (coverage != null) { - c.load(new ImageRequest(coverage, null)); + if (resource != null || coverage != null) { + c.load(new ImageRequest(resource, coverage)); } } return c; @@ -295,7 +333,7 @@ public class CoverageExplorer extends Widget { * in any future version. * * @param type whether to obtain a {@link GridView} or {@link CoverageCanvas}. - * @return the requested view for the {@link #coverageProperty}. + * @return the requested view for the value of {@link #resourceProperty} or {@link #coverageProperty}. */ public final Region getDataView(final View type) { assert Platform.isFxApplicationThread(); @@ -319,6 +357,7 @@ public class CoverageExplorer extends Widget { /** * The action to execute when the user selects a view. + * This is used by the toolbar buttons in the widget created by {@link #getView()}. */ private final class Selector extends ToolbarButton { /** The view to select when the button is pressed. */ @@ -342,8 +381,83 @@ public class CoverageExplorer extends Widget { } /** + * Returns the type of view (image or tabular data) shown in this explorer. + * The default value is {@link View#TABLE}. + * + * @return the way to show coverages in this explorer. + * + * @see #viewTypeProperty + */ + public final View getViewType() { + return viewTypeProperty.get(); + } + + /** + * Sets the type of view to show in this explorer. + * + * @param type the new way to show coverages in this explorer. + * + * @see #viewTypeProperty + */ + public final void setViewType(final View type) { + viewTypeProperty.set(type); + } + + /** + * Invoked when a new view type has been specified. + * + * @param type the new way to show coverages in this explorer. + */ + private void onViewTypeSet(final View type) { + final ViewAndControls c = getViewAndControls(type, true); + if (content != null) { + content.getItems().setAll(c.controls(), c.view()); + final Toggle selector = c.selector; + if (selector != null) { + selector.setSelected(true); + } + } + } + + /** + * Returns the source of coverages for this explorer. + * This method, like all other methods in this class, shall be invoked from the JavaFX thread. + * + * @return the source of coverages shown in this explorer, or {@code null} if none. + * + * @see #resourceProperty + * + * @since 1.2 + */ + public final GridCoverageResource getResource() { + return resourceProperty.get(); + } + + /** + * Sets the source of coverages shown in this explorer. + * This method shall be invoked from JavaFX thread and returns immediately. + * The new data are loaded in a background thread and the {@link #coverageProperty} + * value will be updated after an undetermined amount of time. + * + * @param resource the source of data to show in this explorer, or {@code null} if none. + * + * @see #resourceProperty + * + * @since 1.2 + */ + public final void setResource(final GridCoverageResource resource) { + resourceProperty.set(resource); + /* + * `onCoverageSpecified(…)` is indirectly invoked, + * which in turn invokes `setCoverage(ImageRequest)`. + */ + } + + /** * Returns the source of sample values for this explorer. * This method, like all other methods in this class, shall be invoked from the JavaFX thread. + * Note that this value may change at any time (depending on user interaction) + * if the {@link #resourceProperty} has a non-null value. * * @return the coverage shown in this explorer, or {@code null} if none. * @@ -356,11 +470,13 @@ public class CoverageExplorer extends Widget { } /** - * Sets the coverage to show in this view. + * Sets the coverage to show in this explorer. * This method shall be invoked from JavaFX thread and returns immediately. * The new data are loaded in a background thread and will appear after an * undetermined amount of time. * + * <p>Invoking this method sets the {@link #resourceProperty} value to {@code null}.</p> + * * @param coverage the data to show in this explorer, or {@code null} if none. * * @see #coverageProperty @@ -369,7 +485,10 @@ public class CoverageExplorer extends Widget { */ public final void setCoverage(final GridCoverage coverage) { coverageProperty.set(coverage); - // `onCoverageSpecified(…)` is indirectly invoked. + /* + * `onCoverageSpecified(…)` is indirectly invoked, + * which in turn invokes `setCoverage(ImageRequest)`. + */ } /** @@ -389,84 +508,68 @@ public class CoverageExplorer extends Widget { c.load(c == current ? source : null); } if (current == null) { - coverageChanged(null, null); + notifyDataChanged(null, null); } - // Else `coverageChanged(…)` will be invoked later after background thread finishes its work. + // Else `notifyDataChanged(…)` will be invoked later after background thread finishes its work. } /** - * Invoked when a new coverage has been set on the {@link #coverageProperty}. - * This method notifies the GUI controls about the change then starts loading - * data in a background thread. + * Invoked when a new value has been set on {@link #resourceProperty} or {@link #coverageProperty}. + * This method sets the resource or coverage on the currently visible view, which in turn will load + * data in its own background thread. * - * @param coverage the new coverage. + * @param resource the new resource, or {@code null} if none. + * @param coverage the new coverage, or {@code null} if none. + * @param toClear the property which is an alternative to the property that has been set. */ - private void onCoverageSpecified(final GridCoverage coverage) { + private void onPropertySet(final GridCoverageResource resource, final GridCoverage coverage, + final ObjectProperty<?> toClear) + { if (!isCoverageAdjusting) { - setCoverage((coverage != null) ? new ImageRequest(coverage, null) : null); + isCoverageAdjusting = true; + try { + toClear.set(null); + } finally { + isCoverageAdjusting = false; + } + // Indirectly start a background thread which will invoke `notifyDataChanged(…)` later. + setCoverage((resource != null || coverage != null) ? new ImageRequest(resource, coverage) : null); } } /** - * Invoked in JavaFX thread after {@link #setCoverage(ImageRequest)} completion for notifying controls - * about the coverage change. Controls should update the GUI with new information available, - * in particular the coordinate reference system and the list of sample dimensions. + * Invoked in JavaFX thread after the current view finished to load the new coverage. + * It is the responsibility of all {@link ViewAndControls} subclasses to listen to change events + * emitted by their views ({@link GridView} or {@link CoverageCanvas}) and to invoke this method. + * This method will then update the properties of this {@code CoverageExplorer} for the new data. + * + * <p>Note that view data may have been changed either by user changing directly a {@link GridView} + * or {@link CoverageCanvas} property, or indirectly by user changing {@link #resourceProperty} or + * {@link #coverageProperty}. In the later case, the {@code resource} and {@code coverage} arguments + * given to this method may be the value that the properties already have.</p> * - * @param source the new source of coverage, or {@code null} if none. - * @param data the new coverage, or {@code null} if none. + * @param resource the new source of coverage, or {@code null} if none. + * @param coverage the new coverage, or {@code null} if none. */ - final void coverageChanged(final Resource source, final GridCoverage data) { - if (data != null) { - final GridGeometry gg = data.getGridGeometry(); + final void notifyDataChanged(final GridCoverageResource resource, final GridCoverage coverage) { + if (coverage != null) { + final GridGeometry gg = coverage.getGridGeometry(); referenceSystems.areaOfInterest.set(gg.isDefined(GridGeometry.ENVELOPE) ? gg.getEnvelope() : null); if (gg.isDefined(GridGeometry.CRS)) { referenceSystems.setPreferred(true, gg.getCoordinateReferenceSystem()); } } + /* + * Following calls will NOT forward the new values to the views because this `notifyDataChanged(…)` + * method is invoked as a consequence of view properties being updated. Those views should already + * have the new property values at this moment. + */ isCoverageAdjusting = true; try { - setCoverage(data); + setResource(resource); + setCoverage(coverage); } finally { isCoverageAdjusting = false; } } - - /** - * Returns the type of view (image or tabular data) shown in this explorer. - * The default value is {@link View#TABLE}. - * - * @return the way to show coverages in this explorer. - * - * @see #viewTypeProperty - */ - public final View getViewType() { - return viewTypeProperty.get(); - } - - /** - * Sets the type of view to show in this explorer. - * - * @param type the new way to show coverages in this explorer. - * - * @see #viewTypeProperty - */ - public final void setViewType(final View type) { - viewTypeProperty.set(type); - } - - /** - * Invoked when a new view type has been specified. - * - * @param type the new way to show coverages in this explorer. - */ - private void onViewTypeSpecified(final View type) { - final ViewAndControls c = getViewAndControls(type, true); - if (content != null) { - content.getItems().setAll(c.controls(), c.view()); - final Toggle selector = c.selector; - if (selector != null) { - selector.setSelected(true); - } - } - } } diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridControls.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridControls.java index d0d84b0..b986725 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridControls.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridControls.java @@ -27,9 +27,9 @@ import javafx.scene.control.TitledPane; import javafx.scene.layout.GridPane; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; -import org.apache.sis.storage.Resource; -import org.apache.sis.coverage.SampleDimension; +import org.apache.sis.storage.GridCoverageResource; import org.apache.sis.coverage.grid.GridCoverage; +import org.apache.sis.coverage.SampleDimension; import org.apache.sis.util.resources.Vocabulary; import org.apache.sis.internal.gui.Styles; @@ -125,7 +125,7 @@ final class GridControls extends ViewAndControls { * @param source the new source of coverage, or {@code null} if none. * @param data the new coverage, or {@code null} if none. */ - final void coverageChanged(final Resource source, final GridCoverage data) { + final void notifyDataChanged(final GridCoverageResource source, final GridCoverage data) { final ObservableList<SampleDimension> items = sampleDimensions.getItems(); if (data != null) { items.setAll(data.getSampleDimensions()); @@ -133,12 +133,13 @@ final class GridControls extends ViewAndControls { } else { items.clear(); } - owner.coverageChanged(source, data); + owner.notifyDataChanged(source, data); } /** * Sets the view content to the given image. - * This method starts a background thread. + * This method is invoked when a new source of data (either a resource or a coverage) is specified, + * or when a previously hidden view is made visible. This implementation starts a background thread. * * @param request the image to set, or {@code null} for clearing the view. */ diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridView.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridView.java index 33ed675..d0ddb60 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridView.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/GridView.java @@ -211,7 +211,7 @@ public class GridView extends Control { * This is used only for notifications; a future version may use a more generic listener. * We use this specific mechanism because there is no {@code coverageProperty} in this class. * - * @see GridControls#coverageChanged(GridCoverage) + * @see GridControls#notifyDataChanged(GridCoverageResource, GridCoverage) */ private final GridControls controls; @@ -322,7 +322,7 @@ public class GridView extends Control { if (source == null) { setImage((RenderedImage) null); if (controls != null) { - controls.coverageChanged(null, null); + controls.notifyDataChanged(null, null); } } else { cancelLoader(); @@ -342,7 +342,7 @@ public class GridView extends Control { setImage(image); request.configure(statusBar); if (controls != null) { - controls.coverageChanged(request.resource, request.getCoverage().orElse(null)); + controls.notifyDataChanged(request.resource, request.getCoverage().orElse(null)); } } @@ -375,7 +375,7 @@ public class GridView extends Control { */ @Override protected RenderedImage call() throws DataStoreException { - return request.load(this, true, true); + return request.load(this); } /** diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageRequest.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageRequest.java index df3566d..93b3f3d 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageRequest.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ImageRequest.java @@ -21,7 +21,6 @@ import java.util.concurrent.FutureTask; import java.awt.image.RenderedImage; import javafx.scene.Node; import org.apache.sis.storage.GridCoverageResource; -import org.apache.sis.coverage.grid.GridDerivation; import org.apache.sis.coverage.grid.GridCoverage; import org.apache.sis.coverage.grid.GridGeometry; import org.apache.sis.coverage.grid.GridExtent; @@ -50,11 +49,6 @@ import org.apache.sis.storage.event.StoreListeners; */ public class ImageRequest { /** - * The {@value} value, for identifying code that assume two-dimensional objects. - */ - private static final int BIDIMENSIONAL = 2; - - /** * The source from where to read the image, specified at construction time. * May be {@code null} if {@link #coverage} instance was specified at construction time. */ @@ -96,12 +90,20 @@ public class ImageRequest { private GridExtent sliceExtent; /** - * The relative position of slice in dimensions other than the 2 visible dimensions, - * as a ratio between 0 and 1. This may become configurable in a future version. + * Creates a new request with both a resource and a coverage. At least one argument shall be non-null. + * If both arguments are non-null, then {@code data} must be the result of reading the given resource. + * In the later case we will not actually read data (because they are already read) and this instance + * is used only for transferring data e.g. from {@link CoverageExplorer} to {@link CoverageCanvas}. * - * @see GridDerivation#sliceByRatio(double, int[]) + * <p>This constructor is not in public API because users should supply only a resource or a coverage, + * not both.</p> */ - private static final double SLICE_RATIO = 0; + ImageRequest(final GridCoverageResource source, final GridCoverage data) { + resource = source; + coverage = data; + domain = null; + range = null; + } /** * Creates a new request for loading an image from the specified resource. @@ -241,29 +243,6 @@ public class ImageRequest { } /** - * Computes a two dimensional slice of the given grid geometry. - * This method selects the two first dimensions having a size greater than 1 cell. - * - * @todo Give control to user over which dimensions are selected. - * - * @param domain the grid geometry in which to choose a two-dimensional slice. - * @return a builder configured for returning the desired two-dimensional slice. - */ - private static GridDerivation slice(final GridGeometry domain) { - final GridExtent extent = domain.getExtent(); - final int dimension = extent.getDimension(); - final int[] sliceDimensions = new int[BIDIMENSIONAL]; - int k = 0; - for (int i=0; i<dimension; i++) { - if (extent.getLow(i) != extent.getHigh(i)) { - sliceDimensions[k] = i; - if (++k >= BIDIMENSIONAL) break; - } - } - return domain.derive().sliceByRatio(ImageRequest.SLICE_RATIO, sliceDimensions); - } - - /** * Loads the image. If the coverage has more than {@value #BIDIMENSIONAL} dimensions, * only two of them are taken for the image; for all other dimensions, only the values * at lowest index will be read. @@ -275,43 +254,29 @@ public class ImageRequest { * This class does not need to be thread-safe because it should be used only once in a well-defined life cycle. * We nevertheless synchronize as a safety (e.g. user could give the same {@code ImageRequest} to two different * {@link CoverageExplorer} instances). In such case the {@link GridCoverage} will be loaded only once, - * but no caching is done for the {@link RenderedImage}. Image caching is generally not needed because - * {@link CoverageCanvas} does its own image rendering (it invokes this method with {@code render = false}). - * If two image renderings happen anyway, we rely on {@link org.apache.sis.storage.DataStore} caching.</p> + * but no caching is done for the {@link RenderedImage} (because usually not needed). * - * @param task the task invoking this method (for checking for cancellation). - * @param converted {@code true} for a coverage containing converted values, - * or {@code false} for a coverage containing packed values. - * @param render {@code false} if only coverage reading is desired. + * @param task the task invoking this method (for checking for cancellation). * @return the image loaded from the source given at construction time, or {@code null} - * if the task has been cancelled or if {@code render} is {@code false}. + * if the task has been cancelled. * @throws DataStoreException if an error occurred while loading the grid coverage. */ - final synchronized RenderedImage load(final FutureTask<?> task, final boolean converted, final boolean render) - throws DataStoreException - { + final synchronized RenderedImage load(final FutureTask<?> task) throws DataStoreException { GridCoverage cv = coverage; final Long id = LogHandler.loadingStart(resource); try { if (cv == null) { - GridGeometry gg = domain; - if (gg == null) { - gg = resource.getGridGeometry(); - } - if (gg != null && gg.getDimension() > BIDIMENSIONAL) { - gg = slice(gg).build(); - } - cv = resource.read(gg, range); + cv = MultiResolutionImageLoader.getInstance(resource, null).getOrLoad(domain, range); } - coverage = cv = cv.forConvertedValues(converted); - if (!render || task.isCancelled()) { + coverage = cv = cv.forConvertedValues(true); + if (task.isCancelled()) { return null; } GridExtent ex = sliceExtent; if (ex == null) { final GridGeometry gg = cv.getGridGeometry(); - if (gg != null && gg.getDimension() > BIDIMENSIONAL) { // Should never be null but we are paranoiac. - ex = slice(gg).getIntersection(); + if (gg.getDimension() > MultiResolutionImageLoader.BIDIMENSIONAL) { + ex = MultiResolutionImageLoader.slice(gg.derive(), gg.getExtent()).getIntersection(); } } return cv.render(ex); diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/MultiResolutionImageLoader.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/MultiResolutionImageLoader.java new file mode 100644 index 0000000..79b25ae --- /dev/null +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/MultiResolutionImageLoader.java @@ -0,0 +1,156 @@ +/* + * 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.gui.coverage; + +import java.util.WeakHashMap; +import org.apache.sis.storage.GridCoverageResource; +import org.apache.sis.storage.DataStoreException; +import org.apache.sis.coverage.grid.GridCoverage; +import org.apache.sis.coverage.grid.GridDerivation; +import org.apache.sis.coverage.grid.GridExtent; +import org.apache.sis.coverage.grid.GridGeometry; +import org.apache.sis.internal.map.coverage.MultiResolutionCoverageLoader; + + +/** + * A helper class for reading two-dimensional slices of {@link GridCoverage}. + * The same instance may be shared by {@link GridView} and {@link CoverageCanvas}. + * {@code GridView} uses only level 0, while {@code CoverageCanvas} use any level. + * + * <h2>Multi-threading</h2> + * Instances of this class are immutable (except for the cache) and safe for use by multiple threads. + * The same instance may be shared by many {@link CoverageCanvas} or {@link GridView} objects. + * + * <h2>Limitations (TODO)</h2> + * Current implementation reads only the two first dimensions. + * We will need to define an API for specifying which dimensions to use for the slices. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.2 + * @since 1.2 + * @module + */ +final class MultiResolutionImageLoader extends MultiResolutionCoverageLoader { + /** + * The {@value} value, for identifying code that assume two-dimensional objects. + */ + static final int BIDIMENSIONAL = 2; + + /** + * The relative position of slice in dimensions other than the 2 visible dimensions, + * as a ratio between 0 and 1. This may become configurable in a future version. + * + * @see GridDerivation#sliceByRatio(double, int[]) + */ + private static final double SLICE_RATIO = 0; + + /** + * The loaders created for grid coverage resources. + */ + private static final WeakHashMap<GridCoverageResource, MultiResolutionImageLoader> CACHE = new WeakHashMap<>(); + + /** + * Creates a new loader of grid coverages from the given resource. The loader assumes a pyramid with + * the declared resolutions of given resource if present, or computes default resolutions otherwise. + * + * @param resource the resource from which to read grid coverages. + * @throws DataStoreException if an error occurred while querying the resource for resolutions. + */ + private MultiResolutionImageLoader(final GridCoverageResource resource) throws DataStoreException { + super(resource, null, null); + } + + /** + * Gets or creates a new loader of grid coverages from the given resource. The loader assumes a pyramid + * with the declared resolutions of given resource if present, or computes default resolutions otherwise. + * This method returns cached instances if available. + * + * @param resource the resource from which to read grid coverages. + * @param cached a cached instance that may be reused, or {@code null} if none. + * @return loader for the specified resource (never {@code null}). + * @throws DataStoreException if an error occurred while querying the resource for resolutions. + */ + static MultiResolutionImageLoader getInstance(final GridCoverageResource resource, + MultiResolutionImageLoader cached) throws DataStoreException + { + if (cached == null || cached.resource != resource) { + synchronized (CACHE) { + cached = CACHE.get(resource); + } + if (cached == null) { + final MultiResolutionImageLoader loader = new MultiResolutionImageLoader(resource); + synchronized (CACHE) { + cached = CACHE.putIfAbsent(resource, loader); + } + if (cached == null) { + cached = loader; + } + } + } + return cached; + } + + /** + * Given a {@code GridGeometry} configured with the resolution to read, returns an amended domain + * for a two-dimensional slice. + * + * @param subgrid a grid geometry with the desired resolution. + * @return the domain to read from the {@linkplain #resource resource}. + */ + @Override + protected GridGeometry getReadDomain(final GridGeometry subgrid) { + return slice(subgrid); + } + + /** + * Returns the given grid geometry with grid indices narrowed to a two dimensional slice. + * If more than two dimensions are eligible, this method selects the 2 first ones. + * + * @param gg the grid geometry to reduce to two dimensions, or {@code null}. + * @return the given grid geometry reduced to 2 dimensions, or {@code null} if the geometry was null. + */ + static GridGeometry slice(GridGeometry gg) { + if (gg != null && gg.getDimension() > BIDIMENSIONAL && gg.isDefined(GridGeometry.EXTENT)) { + gg = slice(gg.derive(), gg.getExtent()).build(); + } + return gg; + } + + /** + * Configures the given {@link GridDerivation} for applying a two-dimensional slice. + * This method selects the two first dimensions having a size greater than 1 cell. + * + * @param subgrid a grid geometry builder pre-configured with the desired resolution. + * @param extent extent of the coverage to read, in units of the finest level. + * @return the builder configured for returning the desired two-dimensional slice. + */ + static GridDerivation slice(final GridDerivation subgrid, final GridExtent extent) { + final int dimension = extent.getDimension(); + if (dimension <= BIDIMENSIONAL) { + return subgrid; + } + final int[] sliceDimensions = new int[BIDIMENSIONAL]; + int k = 0; + for (int i=0; i<dimension; i++) { + if (extent.getLow(i) != extent.getHigh(i)) { + sliceDimensions[k] = i; + if (++k >= BIDIMENSIONAL) break; + } + } + return subgrid.sliceByRatio(SLICE_RATIO, sliceDimensions); + } +} diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java index 859d3bc..57a54b2 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/RenderingData.java @@ -31,20 +31,24 @@ import java.awt.geom.AffineTransform; import java.awt.geom.NoninvertibleTransformException; import org.opengis.util.FactoryException; import org.opengis.geometry.DirectPosition; +import org.opengis.metadata.extent.GeographicBoundingBox; import org.opengis.referencing.datum.PixelInCell; import org.opengis.referencing.operation.Matrix; import org.opengis.referencing.operation.CoordinateOperation; import org.opengis.referencing.operation.MathTransform; import org.opengis.referencing.operation.TransformException; import org.opengis.referencing.crs.CoordinateReferenceSystem; +import org.apache.sis.storage.DataStoreException; import org.apache.sis.coverage.grid.GridCoverage; import org.apache.sis.coverage.grid.GridGeometry; import org.apache.sis.coverage.grid.GridExtent; +import org.apache.sis.coverage.grid.ImageRenderer; import org.apache.sis.coverage.grid.PixelTranslation; import org.apache.sis.coverage.SampleDimension; import org.apache.sis.geometry.AbstractEnvelope; import org.apache.sis.geometry.Envelope2D; import org.apache.sis.geometry.Shapes2D; +import org.apache.sis.image.PlanarImage; import org.apache.sis.image.ErrorHandler; import org.apache.sis.image.ImageProcessor; import org.apache.sis.internal.coverage.SampleDimensions; @@ -57,7 +61,7 @@ import org.apache.sis.io.TableAppender; import org.apache.sis.math.Statistics; import org.apache.sis.measure.Quantities; import org.apache.sis.measure.Units; -import org.apache.sis.metadata.iso.extent.DefaultGeographicBoundingBox; +import org.apache.sis.metadata.iso.extent.Extents; import org.apache.sis.referencing.operation.transform.LinearTransform; import org.apache.sis.referencing.operation.transform.MathTransforms; import org.apache.sis.referencing.operation.matrix.AffineTransforms2D; @@ -89,8 +93,8 @@ import org.apache.sis.util.logging.Logging; * and rely on tiling for reducing actual computations to required tiles. Since pan gestures are expressed * in pixel coordinates, the translation terms in {@code resampledToDisplay} transform should stay integers. * - * @todo This class does not perform a special case for {@link BufferedImage}. We wait to see if this class - * works well in the general case before doing special cases. + * <p>Current version of this class does not perform a special case for {@link BufferedImage}. + * We wait to see if this class works well in the general case before doing special cases.</p> * * @author Martin Desruisseaux (Geomatys) * @version 1.2 @@ -107,6 +111,18 @@ final class RenderingData implements Cloneable { private static final boolean CREATE_INDEX_COLOR_MODEL = true; /** + * Loader for reading and caching coverages at various resolutions. Used if no image has been + * explicitly assigned to {@link #data}, or if the image may vary depending on the resolution. + * The same instance may be shared by many {@link RenderingData} objects. + */ + MultiResolutionImageLoader coverageLoader; + + /** + * The pyramid level of {@linkplain #data} loaded by the {@linkplain #coverageLoader}. + */ + private int currentPyramidLevel; + + /** * The data fetched from {@link GridCoverage#render(GridExtent)} for current {@code sliceExtent}. * This rendered image may be tiled and fetching those tiles may require computations to be performed * in background threads. Pixels in this {@code data} image are mapped to pixels in the display @@ -118,9 +134,12 @@ final class RenderingData implements Cloneable { * <li>{@link CoverageCanvas#getObjectiveToDisplay()}</li> * </ol> * + * This field is initially {@code null}. + * * @see #dataGeometry * @see #dataRanges - * @see #setImage(RenderedImage, GridGeometry, List) + * @see #isEmpty() + * @see #loadIfNeeded(LinearTransform, DirectPosition, GridExtent) */ private RenderedImage data; @@ -133,7 +152,7 @@ final class RenderingData implements Cloneable { * * @see #data * @see #dataRanges - * @see #setImage(RenderedImage, GridGeometry, List) + * @see #setCoverageSpace(GridGeometry, List) */ private GridGeometry dataGeometry; @@ -143,7 +162,7 @@ final class RenderingData implements Cloneable { * * @see #data * @see #dataGeometry - * @see #setImage(RenderedImage, GridGeometry, List) + * @see #setCoverageSpace(GridGeometry, List) */ private List<SampleDimension> dataRanges; @@ -216,9 +235,11 @@ final class RenderingData implements Cloneable { /** * Returns {@code true} if this object has no data. + * If {@code true}, then {@link CoverageCanvas} should paint + * an empty space without starting a background worker thread. */ final boolean isEmpty() { - return data == null; + return data == null && dataGeometry == null && dataRanges == null; } /** @@ -246,16 +267,36 @@ final class RenderingData implements Cloneable { } /** - * Sets the data to given image, which can be {@code null}. + * Clears this renderer. This method should be invoked when the source of data (resource or coverage) changed. + * The {@link #displayToObjective} transform will be recomputed from scratch when first needed. */ - @SuppressWarnings("AssignmentToCollectionOrArrayFieldFromParameter") - final void setImage(final RenderedImage data, final GridGeometry domain, final List<SampleDimension> ranges) { + final void clear() { clearCRS(); displayToObjective = null; statistics = null; - this.data = data; - this.dataGeometry = domain; - this.dataRanges = ranges; // Not cloned because already an unmodifiable list. + data = null; + dataRanges = null; + dataGeometry = null; + } + + /** + * Sets the input space (domain) and output space (ranges) of the coverage to be rendered. + * It should be followed by a call to {@link #ensureImageLoaded(GridCoverage, GridExtent)}. + * + * @param data the new image, or {@code null} if not yet known (i.e. loading may have been deferred). + * @param domain the two-dimensional grid geometry, or {@code null} if there is no data. + * @param ranges descriptions of bands, or {@code null} if there is no data. + * + * @see #isEmpty() + */ + @SuppressWarnings("AssignmentToCollectionOrArrayFieldFromParameter") + final void setCoverageSpace(final GridGeometry domain, final List<SampleDimension> ranges) { + dataRanges = ranges; // Not cloned because already an unmodifiable list. + dataGeometry = domain; + /* + * If the grid geometry does not define a "grid to CRS" transform, set it to an identity transform. + * We do that because this class needs a complete `GridGeometry` as much as possible. + */ if (domain != null && !domain.isDefined(GridGeometry.GRID_TO_CRS) && domain.isDefined(GridGeometry.EXTENT)) { @@ -270,6 +311,106 @@ final class RenderingData implements Cloneable { } /** + * Loads a new grid coverage if {@linkplain #data} is null or if the pyramid level changed. + * It is caller's responsibility to ensure that {@link #coverageLoader} has a non-null value + * and is using the right resource before to invoke this method. + * + * <p>Caller should invoke {@link #ensureImageLoaded(GridCoverage, GridExtent)} + * after this method (this is not done automatically).</p> + * + * @param objectiveToDisplay transform used for rendering the coverage on screen. + * @param objectivePOI point where to compute resolution, in coordinates of objective CRS. + * @return the loaded grid coverage, or {@code null} if no loading has been done + * (which means that the coverage is unchanged, not that it does not exist). + * + * @see #setCoverageSpace(GridGeometry, List) + */ + final GridCoverage ensureCoverageLoaded(final LinearTransform objectiveToDisplay, final DirectPosition objectivePOI) + throws TransformException, DataStoreException + { + final MathTransform dataToObjective = (changeOfCRS != null) ? changeOfCRS.getMathTransform() : null; + final int level = coverageLoader.findPyramidLevel(dataToObjective, objectiveToDisplay, objectivePOI); + if (data != null && level == currentPyramidLevel) { + return null; + } + data = null; + currentPyramidLevel = level; + return coverageLoader.getOrLoad(level).forConvertedValues(true); + } + + /** + * Fetches the rendered image if {@linkplain #data} is null. This method needs to be invoked at least + * once after {@link #setCoverageSpace(GridGeometry, List)}. The {@code coverage} given in argument + * may be the value returned by {@link #ensureCoverageLoaded(LinearTransform, DirectPosition)}. + * + * @param coverage the coverage from which to read data, or {@code null} if the coverage did not changed. + */ + final void ensureImageLoaded(final GridCoverage coverage, @Deprecated final GridExtent sliceExtent) + throws FactoryException, TransformException + { + if (data != null || coverage == null) { + return; + } + final GridGeometry old = dataGeometry; + final List<SampleDimension> ranges = coverage.getSampleDimensions(); + final RenderedImage image = coverage.render(sliceExtent); + final Object value = image.getProperty(PlanarImage.GRID_GEOMETRY_KEY); + final GridGeometry domain = (value instanceof GridGeometry) ? (GridGeometry) value + : new ImageRenderer(coverage, sliceExtent).getImageGeometry(MultiResolutionImageLoader.BIDIMENSIONAL); + setCoverageSpace(domain, ranges); + data = image; + /* + * Update the transforms in a way that preserve the current zoom level, translation, etc. + * We compute the change in the "data grid to objective CRS" transforms caused by the change + * in data grid geometry, then we concatenate that change to the existing transforms. + * That way, the objective CRS is kept unchanged. + */ + if (old != null && cornerToObjective != null && objectiveToCenter != null) { + MathTransform toNew = null, toOld = null; + if (old.isDefined(GridGeometry.CRS) && domain.isDefined(GridGeometry.CRS)) { + final CoordinateReferenceSystem oldCRS = old.getCoordinateReferenceSystem(); + final CoordinateReferenceSystem newCRS = dataGeometry.getCoordinateReferenceSystem(); + if (newCRS != oldCRS) { // Quick check for the vast majority of cases. + /* + * Transform computed below should always be the identity transform, + * but we check anyway as a safety. A non-identity transform would be + * a pyramid where the CRS changes according the pyramid level. + */ + final GeographicBoundingBox areaOfInterest = Extents.union( + dataGeometry.getGeographicExtent().orElse(null), + old.getGeographicExtent().orElse(null)); + toNew = CRS.findOperation(oldCRS, newCRS, areaOfInterest).getMathTransform(); + toOld = toNew.inverse(); + } + } + final MathTransform forward = concatenate(PixelInCell.CELL_CORNER, dataGeometry, old, toOld); + final MathTransform inverse = concatenate(PixelInCell.CELL_CENTER, old, dataGeometry, toNew); + cornerToObjective = MathTransforms.concatenate(forward, cornerToObjective); + objectiveToCenter = MathTransforms.concatenate(objectiveToCenter, inverse); + } + } + + /** + * Computes the transform that represent a change of "data grid to objective" transform + * + * @param anchor the cell part to map (center or corner). + * @param toCRS the grid geometry for which to use the "grid to CRS" transform. + * @param toGrid the grid geometry for which to use the "CRS to grid" transform. + * @param changeOfCRS transform from CRS of {@code toCRS} to CRS of {@code toGrid}. + */ + private static MathTransform concatenate(final PixelInCell anchor, final GridGeometry toCRS, + final GridGeometry toGrid, final MathTransform changeOfCRS) throws TransformException + { + final MathTransform forward = toCRS .getGridToCRS(anchor); + final MathTransform inverse = toGrid.getGridToCRS(anchor).inverse(); + if (changeOfCRS != null) { + return MathTransforms.concatenate(forward, changeOfCRS, inverse); + } else { + return MathTransforms.concatenate(forward, inverse); + } + } + + /** * Returns the position at the center of source data, or {@code null} if none. */ private DirectPosition getSourceMedian() { @@ -305,43 +446,48 @@ final class RenderingData implements Cloneable { } /** + * Sets the coordinate reference system of the display. This method does nothing if the CRS was already set. + * <em>It does not verify if CRS is the same</em>, it is caller responsibility to clear {@link #changeOfCRS} + * before to invoke this method for forcing a change of CRS. + * + * <p>This method updates the following fields only:</p> + * <ul> + * <li>{@link #changeOfCRS}</li> + * <li>{@link #processor} positional accuracy hint</li> + * </ul> + * + * @param objectiveCRS value of {@link CoverageCanvas#getObjectiveCRS()}. + */ + final void setObjectiveCRS(final CoordinateReferenceSystem objectiveCRS) throws TransformException { + if (changeOfCRS == null && objectiveCRS != null && dataGeometry.isDefined(GridGeometry.CRS)) try { + changeOfCRS = CRS.findOperation(dataGeometry.getCoordinateReferenceSystem(), objectiveCRS, + dataGeometry.getGeographicExtent().orElse(null)); + final double accuracy = CRS.getLinearAccuracy(changeOfCRS); + processor.setPositionalAccuracyHints( +// TODO: uncomment after https://issues.apache.org/jira/browse/SIS-497 is fixed. +// Quantities.create(0.25, Units.PIXEL), + (accuracy > 0) ? Quantities.create(accuracy, Units.METRE) : null); + } catch (FactoryException e) { + recoverableException(e); + // Leave `changeOfCRS` to null. + } + } + + /** * Creates the resampled image, then optionally applies an index color model. * This method will compute the {@link MathTransform} steps from image coordinate system * to display coordinate system if those steps have not already been computed. * * @param recoloredImage the image computed by {@link #recolor()}. - * @param objectiveCRS value of {@link CoverageCanvas#getObjectiveCRS()}. * @param objectiveToDisplay value of {@link CoverageCanvas#getObjectiveToDisplay()}. * @param objectivePOI value of {@link CoverageCanvas#getPointOfInterest(boolean)} in objective CRS. * @return image with operation applied and color ramp stretched. */ - final RenderedImage resampleAndConvert(final RenderedImage recoloredImage, - final CoordinateReferenceSystem objectiveCRS, - final LinearTransform objectiveToDisplay, - final DirectPosition objectivePOI) + final RenderedImage resampleAndConvert(final RenderedImage recoloredImage, + final LinearTransform objectiveToDisplay, + final DirectPosition objectivePOI) throws TransformException { - if (changeOfCRS == null && objectiveCRS != null && dataGeometry.isDefined(GridGeometry.CRS)) { - DefaultGeographicBoundingBox areaOfInterest = null; - if (dataGeometry.isDefined(GridGeometry.ENVELOPE)) try { - areaOfInterest = new DefaultGeographicBoundingBox(); - areaOfInterest.setBounds(dataGeometry.getEnvelope()); - } catch (TransformException e) { - recoverableException(e); - // Leave `areaOfInterest` to null. - } - try { - changeOfCRS = CRS.findOperation(dataGeometry.getCoordinateReferenceSystem(), objectiveCRS, areaOfInterest); - final double accuracy = CRS.getLinearAccuracy(changeOfCRS); - processor.setPositionalAccuracyHints( -// TODO: uncomment after https://issues.apache.org/jira/browse/SIS-497 is fixed. -// Quantities.create(0.25, Units.PIXEL), - (accuracy > 0) ? Quantities.create(accuracy, Units.METRE) : null); - } catch (FactoryException e) { - recoverableException(e); - // Leave `changeOfCRS` to null. - } - } /* * Following transforms are computed when first needed after the new data have been specified, * or after the objective CRS changed. If non-null, `objToCenterNoWrap` is the same transform diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ViewAndControls.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ViewAndControls.java index 795eaa4..9205b98 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ViewAndControls.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/coverage/ViewAndControls.java @@ -143,7 +143,8 @@ abstract class ViewAndControls { /** * Sets the view content to the given resource, coverage or image. - * This method may start a background thread. + * This method is invoked when a new source of data (either a resource or a coverage) is specified, + * or when a previously hidden view is made visible. Implementations may start a background thread. * * @param request the resource, coverage or image to set, or {@code null} for clearing the view. */ diff --git a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java index edd6cca..81d94ff 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/gui/map/MapCanvas.java @@ -783,7 +783,7 @@ public abstract class MapCanvas extends PlanarCanvas { * @see #setObjectiveCRS(CoordinateReferenceSystem, DirectPosition) */ protected void setObjectiveBounds(final Envelope visibleArea) { - ArgumentChecks.ensureDimensionMatches("bounds", BIDIMENSIONAL, visibleArea); + ArgumentChecks.ensureDimensionMatches("visibleArea", BIDIMENSIONAL, visibleArea); objectiveBounds = ImmutableEnvelope.castOrCopy(visibleArea); invalidObjectiveToDisplay = true; } @@ -1263,6 +1263,10 @@ public abstract class MapCanvas extends PlanarCanvas { * processing outside the {@link Renderer}. It does not need to be invoked if the error occurred * during the rendering process. * + * <p>If the error property already has a value, then the new error will be to the current error + * as a {@linkplain Throwable#addSuppressed(Throwable) suppressed exception}. The error property + * is cleared when a rendering operation completed successfully.</p> + * * @param ex the exception that occurred (can not be null). */ protected void errorOccurred(final Throwable ex) { diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/LogHandler.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/LogHandler.java index 396fea7..71c1389 100644 --- a/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/LogHandler.java +++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/gui/LogHandler.java @@ -16,7 +16,6 @@ */ package org.apache.sis.internal.gui; -import java.lang.ref.Reference; import java.util.WeakHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentHashMap; @@ -37,7 +36,7 @@ import org.apache.sis.storage.event.WarningEvent; * This class maintains both a global (system) list and a list of log records specific to each resource. * * @author Martin Desruisseaux (Geomatys) - * @version 1.1 + * @version 1.2 * @since 1.1 * @module */ @@ -117,17 +116,6 @@ public final class LogHandler extends Handler implements StoreListener<WarningEv * @param source the resource on which an operation is about to start in current thread. May be {@code null}. * @return key to use in call to {@link #loadingStop(Long)} when the operation is finished. May be {@code null}. */ - public static Long loadingStart(final Reference<Resource> source) { - return loadingStart(source != null ? source.get() : null); - } - - /** - * Notifies this {@code LogHandler} that an operation is about to start on the given resource. - * Call to this method must be followed by call to {@link #loadingStop(Long)} in a {@code finally} block. - * - * @param source the resource on which an operation is about to start in current thread. May be {@code null}. - * @return key to use in call to {@link #loadingStop(Long)} when the operation is finished. May be {@code null}. - */ public static Long loadingStart(final Resource source) { if (source == null) return null; final Long id = Thread.currentThread().getId(); diff --git a/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/MultiResolutionCoverageLoader.java b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/MultiResolutionCoverageLoader.java new file mode 100644 index 0000000..7c478a1 --- /dev/null +++ b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/MultiResolutionCoverageLoader.java @@ -0,0 +1,388 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sis.internal.map.coverage; + +import java.util.Arrays; +import java.lang.ref.Reference; +import java.lang.ref.SoftReference; +import java.text.NumberFormat; +import org.opengis.geometry.Envelope; +import org.opengis.geometry.DirectPosition; +import org.opengis.referencing.datum.PixelInCell; +import org.opengis.referencing.operation.Matrix; +import org.opengis.referencing.operation.MathTransform; +import org.opengis.referencing.operation.TransformException; +import org.apache.sis.referencing.operation.transform.MathTransforms; +import org.apache.sis.referencing.operation.transform.LinearTransform; +import org.apache.sis.storage.GridCoverageResource; +import org.apache.sis.storage.DataStoreException; +import org.apache.sis.coverage.grid.GridGeometry; +import org.apache.sis.coverage.grid.GridCoverage; +import org.apache.sis.coverage.grid.GridDerivation; +import org.apache.sis.coverage.grid.GridRoundingMode; +import org.apache.sis.internal.util.CollectionsExt; +import org.apache.sis.math.DecimalFunctions; +import org.apache.sis.io.TableAppender; +import org.apache.sis.util.ArraysExt; + + +/** + * A helper class for reading {@link GridCoverage} instances at various resolution. + * The resolution are inferred from {@link GridCoverageResource#getResolutions()}, + * using default values if necessary. The objective CRS does not need to be the same + * than the coverage CRS, in which case transformations are applied at the point in + * the center of the display bounds. + * + * <h2>Multi-threading</h2> + * Instances of this class are immutable (except for the cache) and safe for use by multiple threads. + * However it assumes that the {@link GridCoverageResource} given to the constructor is also thread-safe; + * this class does not synchronize accesses to the resource (because it may be used outside this class anyway). + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.2 + * @since 1.2 + * @module + */ +public class MultiResolutionCoverageLoader { + /** + * Approximate size in pixels of the pyramid level having coarsest resolution. + * This is used by {@link #defaultResolutions(GridGeometry, double[])} when no + * resolution levels are explicitly given by the {@linkplain #resource}. + */ + private static final int DEFAULT_SIZE = 512; + + /** + * Value of log₂(rₙ₊₁/rₙ) where rₙ is the resolution at a level and rₙ₊₁ is the resolution at the coarser level. + * The usual value is 1, which means that there is a scale factor of 2 between each level. We use a higher value + * because if the {@linkplain #resource} did not declared a pyramid, reading coverages at low resolution may be + * as costly as high resolution. in that case, we want to reduce the number of read operations. + */ + private static final int DEFAULT_SCALE_LOG = 3; // Scale factor of 2³ = 8. + + /** + * Arbitrary number of levels if we can not compute it from {@link #DEFAULT_SIZE} and {@link #DEFAULT_SCALE_LOG}. + * Reminder: the multiplication factor between two levels is 2^{@value #DEFAULT_SCALE_LOG}, so the resolution + * goes down very fast. + */ + private static final int DEFAULT_NUM_LEVELS = 4; + + /** + * The resource from which to read grid coverages. + */ + protected final GridCoverageResource resource; + + /** + * Squares of resolution at each pyramid level, from finest (smaller numbers) to coarsest (largest numbers). + * Note that this is the reverse order of {@link GridCoverageResource#getResolutions()}. For a given level, + * the array {@code resolutionSquared[level]} gives the squares of the resolution for each CRS dimension. + */ + private final double[][] resolutionSquared; + + /** + * The weak or soft references to coverages for each pyramid level. + * The array length is at least 1, even if {@link #resolutionSquared} is empty. + * Accesses to this array should be synchronized on {@code coverages}. + */ + private final Reference<GridCoverage>[] coverages; + + /** + * The area of interest in any CRS (transformations will be applied as needed), + * or {@code null} for not restricting the coverage to a sub-area. + */ + private final Envelope areaOfInterest; + + /** + * 0-based indices of sample dimensions to read, or {@code null} or an empty sequence for reading them all. + */ + private final int[] readRanges; + + /** + * Creates a new loader of grid coverages from the given resource. The loader assumes a pyramid with + * resolutions declared by the given resource if present, or computes default resolutions otherwise. + * + * @param resource the resource from which to read grid coverages. Should be thread-safe. + * @param domain desired spatiotemporal region in any CRS, or {@code null} for no sub-area. + * @param range 0-based indices of sample dimensions to read, or {@code null} for all. + * @throws DataStoreException if an error occurred while querying the resource for resolutions. + */ + @SuppressWarnings({"unchecked","rawtypes"}) // Generic array creation. + public MultiResolutionCoverageLoader(final GridCoverageResource resource, final Envelope domain, + final int[] range) throws DataStoreException + { + this.resource = resource; + areaOfInterest = domain; + readRanges = range; + double[][] resolutions = CollectionsExt.toArray(resource.getResolutions(), double[].class); + ArraysExt.reverse(resolutions); // From finest to coarsest resolution. + if (resolutions.length <= 1) { + final GridGeometry gg = resource.getGridGeometry(); + if (resolutions.length != 0) { + resolutions = defaultResolutions(gg, resolutions[0]); + } else if (gg.isDefined(GridGeometry.RESOLUTION)) { + resolutions = defaultResolutions(gg, gg.getResolution(true)); + } + } + resolutionSquared = resolutions; + for (final double[] r : resolutions) { + for (int i=0; i<r.length; i++) { + r[i] *= r[i]; + } + } + coverages = new Reference[Math.max(resolutions.length, 1)]; + } + + /** + * Computes default resolutions starting from the given finest level. + * This method uses a scale factor determined by {@link #DEFAULT_SCALE_LOG} between each level. + * The coarsest level will have a size of approximately {@value #DEFAULT_SIZE} pixels. + * + * @param envelope bounding box of the coverage in units of the coverage CRS. + * @param base resolution of the finest level. + * @return default resolutions from finest to coarsest. The first element is always {@code base}. + */ + private static double[][] defaultResolutions(final GridGeometry gg, double[] base) { + /* + * Estimate the number of levels in a pyramid starting from a level with `base` resolution + * up to a level having approximately `DEFAULT_SIZE` pixels, assuming that the resolution + * at each level is 8× the resolution at previous level. + */ + int numLevels = 1; + if (gg.isDefined(GridGeometry.ENVELOPE)) { + final Envelope envelope = gg.getEnvelope(); + for (int i = envelope.getDimension(); --i >= 0;) { + // Multiplication factor from finest resolution to coarsest one. + final double f = envelope.getSpan(i) / (DEFAULT_SIZE * base[i]); + int n = Math.getExponent(f); // floor(log₂(f)) + if (n < Double.MAX_EXPONENT && (n /= DEFAULT_SCALE_LOG) > numLevels) { + numLevels = n; + } + } + } else { + numLevels = DEFAULT_NUM_LEVELS; // Arbitrary number of levels if we can not compute it. + } + /* + * Build the arrays of resolutions from finest to coarsest. + * The `base` array is cloned then updated to become the base of next level. + */ + final double[][] resolutions = new double[numLevels][]; + resolutions[0] = base; + for (int j=1; j<numLevels; j++) { + resolutions[j] = base = base.clone(); + for (int i=0; i<base.length; i++) { + base[i] *= (1 << DEFAULT_SCALE_LOG); + } + } + return resolutions; + } + + /** + * Returns the pyramid level for a zoom defined by the given "objective to display" transform. + * Only the scale factors of the given transform will be considered; translations are ignored. + * + * @param dataToObjective transform from data CRS to the CRS for rendering, or {@code null} if none. + * @param objectiveToDisplay transform used for rendering the coverage on screen. + * @param objectivePOI point where to compute resolution, in coordinates of objective CRS. + * Can be null if {@code dataToObjective} is null or linear. + * @return pyramid level for the zoom determined by the given transform. Finest level is 0. + * @throws TransformException if an error occurred while computing resolution from given transforms. + */ + public final int findPyramidLevel(final MathTransform dataToObjective, final LinearTransform objectiveToDisplay, + final DirectPosition objectivePOI) throws TransformException + { + int level = Math.max(resolutionSquared.length - 1, 0); + if (level != 0) { + final LinearTransform displayToObjective = objectiveToDisplay.inverse(); + final Matrix m = displayToObjective.getMatrix(); + final Matrix d; + if (dataToObjective != null && !dataToObjective.isIdentity()) { + d = dataToObjective.inverse().derivative(objectivePOI); + } else { + d = null; + } + final int srcDim = m.getNumCol() - 1; + final int objDim = m.getNumRow() - 1; // -1 for ignoring the translation column. + final int tgtDim = (d != null) ? d.getNumRow() : objDim; // No -1 because `d` is not a transform. +dimensions: for (int j=0; j<tgtDim; j++) { + double sum = 0; + for (int i=0; i<srcDim; i++) { + double e; + if (d == null) { + e = m.getElement(j,i); + } else { + /* + * Compute the value of `(d × m).getElement(j,i)` where (d × m) is "display to objective" + * transform followed by "objective to data". We do the multiplication inline here instead + * of invoking `d.multiply(m)` because the two matrices do not have compatible size: + * `m` is an affine transform (including translations) while `d` is a Jacobian matrix. + * It also allows to skip some calculations if `level` become 0 early. + */ + e = 0; + for (int k=0; k<objDim; k++) { + e += d.getElement(j,k) * m.getElement(k,i); // TODO: use Math.fma(…) with JDK9. + } + } + sum += e * e; + } + /* + * Can not use `Arrays.binarySearch(…)` because elements are not guaranteed to be sorted. + * Even if `GridCoverageResource.getResolutions()` contract said "coarsest to finest", + * it may not be possible to respect this condition on all dimensions in same time. + * The main goal is to have a `level` value as high as possible while having a resolution + * equals or better than `sum`. + */ + int levelOfMin = level; + double minimum = Double.POSITIVE_INFINITY, r; + while ((r = resolutionSquared[level][j]) > sum) { + if (r < minimum) { + minimum = r; + levelOfMin = level; + } + if (level == 0) { + level = levelOfMin; + break dimensions; + } + level--; + } + } + } + return level; + } + + /** + * Returns the coverage at the given level if it is present in the cache, or loads and caches it otherwise. + * + * @param level pyramid level of the desired coverage. + * @return the coverage at the specified level (never null). + * @throws DataStoreException if an error occurred while loading the coverage. + */ + public final GridCoverage getOrLoad(final int level) throws DataStoreException { + synchronized (coverages) { + final Reference<GridCoverage> ref = coverages[level]; + if (ref != null) { + final GridCoverage coverage = ref.get(); + if (coverage != null) return coverage; + coverages[level] = null; + } + } + GridGeometry domain = null; + if (resolutionSquared.length != 0) { + final double[] resolutions = resolutionSquared[level].clone(); + for (int i=0; i<resolutions.length; i++) { + resolutions[i] = Math.sqrt(resolutions[i]); + } + final MathTransform gridToCRS = MathTransforms.scale(resolutions); + domain = new GridGeometry(PixelInCell.CELL_CORNER, gridToCRS, areaOfInterest, GridRoundingMode.ENCLOSING); + } + final GridCoverage coverage = resource.read(getReadDomain(domain), readRanges); + /* + * Cache and return the coverage. The returned coverage may be a different instance + * if another coverage has been cached concurrently for the same level. + */ + synchronized (coverages) { + final Reference<GridCoverage> ref = coverages[level]; + if (ref != null) { + final GridCoverage c = ref.get(); + if (c != null) return c; + } + coverages[level] = new SoftReference<>(coverage); + } + return coverage; + } + + /** + * If the a grid coverage for the given domain and range is in the cache, returns that coverage. + * Otherwise loads the coverage and eventually caches it. The caching happens only if the given + * domain and range and managed by this loader. + * + * @param domain desired grid extent and resolution, or {@code null} for reading the whole domain. + * @param range 0-based indices of sample dimensions to read, or {@code null} or an empty sequence for reading them all. + * @return the grid coverage for the specified domain and range. + * @throws DataStoreException if an error occurred while reading the grid coverage data. + */ + public final GridCoverage getOrLoad(GridGeometry domain, final int[] range) throws DataStoreException { + if (domain == null && areaOfInterest == null && Arrays.equals(readRanges, range)) { + /* + * Fot now we leverage the cache only at level 0. + * Future versions of this class may try to use the cache at other levels too. + */ + return getOrLoad(0); + } + if (domain == null) { + domain = resource.getGridGeometry(); + } + return resource.read(getReadDomain(domain), readRanges); + } + + /** + * Given a {@code GridGeometry} configured with the resolution to read, returns an amended domain. + * The default implementation returns {@code domain} unchanged. + * Subclasses can override typically for selecting a two-dimensional slice. + * + * <p>This method is invoked by {@link #getOrLoad(int)} default implementation before to read a coverage.</p> + * + * @param domain a grid geometry with the desired resolution. + * @return the domain to read from the {@linkplain #resource}. + * + * @see GridDerivation#slice(DirectPosition) + * @see GridDerivation#sliceByRatio(double, int...) + */ + protected GridGeometry getReadDomain(final GridGeometry domain) { + return domain; + } + + /** + * Returns a string representation of this loader for debugging purpose. + * Default implementation formats the resolution thresholds in a table + * with "cached" word after the level having a cached coverage. + * + * @return a string representation of this loader. + */ + @Override + public String toString() { + final int count = resolutionSquared.length - 1; + double delta = magnitude(0); + if (count != 0) { + delta = (magnitude(count) - delta) / count; + } + final int n = Math.max(Math.min(DecimalFunctions.fractionDigitsForDelta(delta, false), 6), 0); + final NumberFormat f = NumberFormat.getInstance(); + f.setMinimumFractionDigits(n); + f.setMaximumFractionDigits(n); + final TableAppender table = new TableAppender(" "); + table.setCellAlignment(TableAppender.ALIGN_RIGHT); + for (int level=0; level <= count; level++) { + final double[] rs = resolutionSquared[level]; + for (final double r : rs) { + table.append(f.format(Math.sqrt(r))); + table.nextColumn(); + } + final Reference<GridCoverage> ref = coverages[level]; + if (ref != null && ref.get() != null) { // TODO: use !refersTo(null) in JDK16. + table.append("cached"); + } + table.nextLine(); + } + return table.toString(); + } + + /** + * Returns the magnitude of resolution at the given level. + */ + private double magnitude(final int level) { + return Math.sqrt(Arrays.stream(resolutionSquared[level]).average().orElse(1)); + } +} diff --git a/core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/package-info.java similarity index 50% copy from core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java copy to core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/package-info.java index e22488f..ab1e628 100644 --- a/core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java +++ b/core/sis-portrayal/src/main/java/org/apache/sis/internal/map/coverage/package-info.java @@ -14,33 +14,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.sis.test.suite; - -import org.apache.sis.test.TestSuite; -import org.junit.BeforeClass; -import org.junit.runners.Suite; /** - * All tests from the {@code sis-portrayal} module, in rough dependency order. + * Helper classes for the rendering of grid coverages. + * + * This package is for internal use by SIS only. Classes in this package + * may change in incompatible ways in any future version without notice. * - * @author Johann Sorel (Geomatys) - * @version 2.0 - * @since 2.0 + * @author Martin Desruisseaux (Geomatys) + * @version 1.2 + * @since 1.2 * @module */ -@Suite.SuiteClasses({ - org.apache.sis.portrayal.MapLayersTest.class, - org.apache.sis.internal.map.SEPortrayerTest.class, -}) -public final strictfp class PortrayalTestSuite extends TestSuite { - /** - * Verifies the list of tests before to run the suite. - * See {@link #verifyTestList(Class, Class[])} for more information. - */ - @BeforeClass - public static void verifyTestList() { - assertNoMissingTest(PortrayalTestSuite.class); - verifyTestList(PortrayalTestSuite.class); - } -} +package org.apache.sis.internal.map.coverage; diff --git a/core/sis-portrayal/src/test/java/org/apache/sis/internal/map/coverage/MultiResolutionCoverageLoaderTest.java b/core/sis-portrayal/src/test/java/org/apache/sis/internal/map/coverage/MultiResolutionCoverageLoaderTest.java new file mode 100644 index 0000000..1be2007 --- /dev/null +++ b/core/sis-portrayal/src/test/java/org/apache/sis/internal/map/coverage/MultiResolutionCoverageLoaderTest.java @@ -0,0 +1,180 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.sis.internal.map.coverage; + +import java.util.List; +import java.util.Arrays; +import java.util.Collections; +import java.awt.image.RenderedImage; +import org.opengis.geometry.DirectPosition; +import org.opengis.referencing.datum.PixelInCell; +import org.opengis.referencing.operation.MathTransform; +import org.opengis.referencing.operation.TransformException; +import org.apache.sis.referencing.operation.transform.LinearTransform; +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.GridCoverage; +import org.apache.sis.internal.storage.AbstractGridResource; +import org.apache.sis.referencing.operation.transform.MathTransforms; +import org.apache.sis.storage.DataStoreException; +import org.apache.sis.test.TestCase; +import org.apache.sis.util.iso.Names; +import org.junit.Test; + +import static org.junit.Assert.*; + + +/** + * Test {@link MultiResolutionCoverageLoader}. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.2 + * @since 1.2 + * @module + */ +public final strictfp class MultiResolutionCoverageLoaderTest extends TestCase { + /** + * The loader being tested. + */ + private final MultiResolutionCoverageLoader loader; + + /** + * Transform from data CRS to the CRS for rendering, or {@code null} if none. + */ + private MathTransform dataToObjective; + + /** + * Point where to compute resolution, in coordinates of objective CRS. + * Can be null if {@link #dataToObjective} is null or linear. + */ + private DirectPosition objectivePOI; + + /** + * Verifies that a transform with the given scale factors result in the given level to be found. + */ + private void assertLevelEquals(final double sx, final double sy, final double sz, final int expected) + throws TransformException + { + final LinearTransform objectiveToDisplay = MathTransforms.scale(1/sx, 1/sy, 1/sz); + final int level = loader.findPyramidLevel(dataToObjective, objectiveToDisplay, objectivePOI); + assertEquals(expected, level); + } + + /** + * Verifies that loading a coverage at the specified level result in a grid coverage + * with the given scale factors. + */ + private void assertLoadEquals(final int level, final double sx, final double sy, final double sz) + throws DataStoreException + { + final GridCoverage coverage = loader.getOrLoad(level); + final MathTransform gridToCRS = coverage.getGridGeometry().getGridToCRS(PixelInCell.CELL_CORNER); + final MathTransform expected = MathTransforms.scale(sx, sy, sz); + assertEquals(expected, gridToCRS); + assertSame(coverage, loader.getOrLoad(level)); + } + + /** + * Creates a new test case with a loader for a dummy resource. + * + * @throws DataStoreException if an error occurred while querying the dummy resource. + */ + public MultiResolutionCoverageLoaderTest() throws DataStoreException { + loader = new MultiResolutionCoverageLoader(new DummyResource(), null, null); + } + + /** + * A dummy resource with arbitrary resolutions for testing purpose. + * Resolutions are ordered from coarsest (largest numbers) to finest (smallest numbers). + */ + private static final class DummyResource extends AbstractGridResource { + /** Creates a dummy resource. */ + DummyResource() { + super(null); + } + + /** Returns the preferred resolutions in units of CRS axes. */ + @Override public List<double[]> getResolutions() { + return Arrays.asList( + new double[] {8, 9, 5}, + new double[] {4, 4, 3}, + new double[] {2, 3, 1}); + } + + /** Returns a grid geometry with the resolution of finest level. */ + @Override public GridGeometry getGridGeometry() { + return new GridGeometry(new GridExtent(null, null, new long[] {10, 10, 10}, true), + PixelInCell.CELL_CORNER, MathTransforms.scale(2, 3, 1), null); + } + + /** Not needed for this test. */ + @Override public List<SampleDimension> getSampleDimensions() { + throw new UnsupportedOperationException(); + } + + /** Returns a dummy value (will not be used by this test). */ + @Override public GridCoverage read(final GridGeometry domain, final int... range) { + final SampleDimension band = new SampleDimension(Names.createLocalName(null, null, "dummy"), null, Collections.emptyList()); + return new GridCoverage(domain, Collections.singletonList(band)) { + @Override public RenderedImage render(GridExtent sliceExtent) { + throw new UnsupportedOperationException(); // Not needed by this test. + } + }; + } + } + + /** + * Tests {@link MultiResolutionCoverageLoader#findPyramidLevel(MathTransform, LinearTransform, DirectPosition)} + * with no "data to objective" transform. + * + * @throws TransformException if an error occurred while computing the resolution from a transform. + */ + @Test + public void testFindPyramidLevel() throws TransformException { + assertLevelEquals(3, 2, 2, 0); + assertLevelEquals(4, 5, 2, 0); + assertLevelEquals(4, 5, 4, 1); + assertLevelEquals(9, 9, 5, 2); + assertLevelEquals(9, 8, 5, 1); + } + + /** + * Tests {@link MultiResolutionCoverageLoader#findPyramidLevel(MathTransform, LinearTransform, DirectPosition)} + * with a "data to objective" transform set to a translation. Because translation has no effect on scale factors, + * the result should be identical to {@link #testFindPyramidLevel()}. + * + * @throws TransformException if an error occurred while computing the resolution from a transform. + */ + @Test + public void testFindWithTranslation() throws TransformException { + dataToObjective = MathTransforms.translation(-5, 7, 3); + testFindPyramidLevel(); + } + + /** + * Tests {@link MultiResolutionCoverageLoader#getOrLoad(int)}. + * + * @throws DataStoreException if an error occurred while querying the dummy resource. + */ + @Test + public void testGetOrLoad() throws DataStoreException { + assertLoadEquals(2, 8, 9, 5); + assertLoadEquals(0, 2, 3, 1); + assertLoadEquals(1, 4, 4, 3); + } +} diff --git a/core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java b/core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java index e22488f..8824566 100644 --- a/core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java +++ b/core/sis-portrayal/src/test/java/org/apache/sis/test/suite/PortrayalTestSuite.java @@ -25,13 +25,14 @@ import org.junit.runners.Suite; * All tests from the {@code sis-portrayal} module, in rough dependency order. * * @author Johann Sorel (Geomatys) - * @version 2.0 - * @since 2.0 + * @version 1.2 + * @since 1.2 * @module */ @Suite.SuiteClasses({ org.apache.sis.portrayal.MapLayersTest.class, org.apache.sis.internal.map.SEPortrayerTest.class, + org.apache.sis.internal.map.coverage.MultiResolutionCoverageLoaderTest.class }) public final strictfp class PortrayalTestSuite extends TestSuite { /**