Ticket #16472: 16472.3.patch

File 16472.3.patch, 122.0 KB (added by taylor.smock, 4 years ago)
  • src/org/openstreetmap/josm/data/ImageData.java

    diff --git a/src/org/openstreetmap/josm/data/ImageData.java b/src/org/openstreetmap/josm/data/ImageData.java
    index 7fba8372a5..da289dca82 100644
    a b public class ImageData implements Data {  
    131131        return selectedImagesIndex.stream().filter(i -> i > -1 && i < data.size()).map(data::get).collect(Collectors.toList());
    132132    }
    133133
     134    /**
     135     * Get the first image on the layer
     136     * @return The first image
     137     * @since xxx
     138     */
     139    public ImageEntry getFirstImage() {
     140        if (!this.data.isEmpty()) {
     141            return this.data.get(0);
     142        }
     143        return null;
     144    }
     145
    134146    /**
    135147     * Select the first image of the sequence
     148     * @deprecated Use {@link #getFirstImage()} in conjunction with {@link #setSelectedImage}
    136149     */
     150    @Deprecated
    137151    public void selectFirstImage() {
    138152        if (!data.isEmpty()) {
    139153            setSelectedImageIndex(0);
    140154        }
    141155    }
    142156
     157    /**
     158     * Get the last image in the layer
     159     * @return The last image
     160     * @since xxx
     161     */
     162    public ImageEntry getLastImage() {
     163        if (!this.data.isEmpty()) {
     164            return this.data.get(this.data.size() - 1);
     165        }
     166        return null;
     167    }
     168
    143169    /**
    144170     * Select the last image of the sequence
     171     * @deprecated Use {@link #getLastImage()} with {@link #setSelectedImage}
    145172     */
     173    @Deprecated
    146174    public void selectLastImage() {
    147175        setSelectedImageIndex(data.size() - 1);
    148176    }
    public class ImageData implements Data {  
    165193        return this.geoImages.search(bounds.toBBox());
    166194    }
    167195
     196    /**
     197     * Get the image next to the current image
     198     * @return The next image
     199     * @since xxx
     200     */
     201    public ImageEntry getNextImage() {
     202        if (this.hasNextImage()) {
     203            return this.data.get(this.selectedImagesIndex.get(0) + 1);
     204        }
     205        return null;
     206    }
     207
    168208    /**
    169209     * Select the next image of the sequence
     210     * @deprecated Use {@link #getNextImage()} in conjunction with {@link #setSelectedImage}
    170211     */
     212    @Deprecated
    171213    public void selectNextImage() {
    172214        if (hasNextImage()) {
    173215            setSelectedImageIndex(selectedImagesIndex.get(0) + 1);
    174216        }
    175217    }
    176218
     219    /**
     220     * Get the image previous to the current image
     221     * @return The previous image
     222     * @since xxx
     223     */
     224    public ImageEntry getPreviousImage() {
     225        if (this.hasPreviousImage()) {
     226            return this.data.get(Integer.max(0, selectedImagesIndex.get(0) - 1));
     227        }
     228        return null;
     229    }
     230
    177231    /**
    178232     *  Check if there is a previous image in the sequence
    179233     * @return {@code true} is there is a previous image, {@code false} otherwise
    public class ImageData implements Data {  
    184238
    185239    /**
    186240     * Select the previous image of the sequence
     241     * @deprecated Use {@link #getPreviousImage()} with {@link #setSelectedImage}
    187242     */
     243    @Deprecated
    188244    public void selectPreviousImage() {
    189245        if (data.isEmpty()) {
    190246            return;
  • src/org/openstreetmap/josm/data/gpx/GpxImageEntry.java

    diff --git a/src/org/openstreetmap/josm/data/gpx/GpxImageEntry.java b/src/org/openstreetmap/josm/data/gpx/GpxImageEntry.java
    index 030e2db117..d06b607693 100644
    a b package org.openstreetmap.josm.data.gpx;  
    33
    44import static org.openstreetmap.josm.tools.I18n.tr;
    55
     6import java.awt.Dimension;
     7import java.awt.image.BufferedImage;
    68import java.io.File;
    79import java.io.IOException;
    810import java.time.Instant;
    911import java.util.Date;
    1012import java.util.List;
    1113import java.util.Locale;
     14import java.util.Map;
    1215import java.util.Objects;
    1316import java.util.function.Consumer;
    14 
    15 import org.openstreetmap.josm.data.IQuadBucketType;
    16 import org.openstreetmap.josm.data.coor.CachedLatLon;
    17 import org.openstreetmap.josm.data.coor.LatLon;
    18 import org.openstreetmap.josm.data.osm.BBox;
    19 import org.openstreetmap.josm.tools.ExifReader;
    20 import org.openstreetmap.josm.tools.JosmRuntimeException;
    21 import org.openstreetmap.josm.tools.Logging;
     17import java.util.stream.Stream;
     18import javax.imageio.IIOParam;
    2219
    2320import com.drew.imaging.jpeg.JpegMetadataReader;
    2421import com.drew.imaging.jpeg.JpegProcessingException;
    import com.drew.metadata.exif.ExifIFD0Directory;  
    3330import com.drew.metadata.exif.GpsDirectory;
    3431import com.drew.metadata.iptc.IptcDirectory;
    3532import com.drew.metadata.jpeg.JpegDirectory;
     33import com.drew.metadata.xmp.XmpDirectory;
     34import org.openstreetmap.josm.data.IQuadBucketType;
     35import org.openstreetmap.josm.data.coor.CachedLatLon;
     36import org.openstreetmap.josm.data.coor.LatLon;
     37import org.openstreetmap.josm.data.imagery.street_level.Projections;
     38import org.openstreetmap.josm.data.osm.BBox;
     39import org.openstreetmap.josm.tools.ExifReader;
     40import org.openstreetmap.josm.tools.JosmRuntimeException;
     41import org.openstreetmap.josm.tools.Logging;
    3642
    3743/**
    3844 * Stores info about each image
    public class GpxImageEntry implements Comparable<GpxImageEntry>, IQuadBucketType  
    4450    private LatLon exifCoor;
    4551    private Double exifImgDir;
    4652    private Instant exifTime;
     53    private Projections cameraProjection = Projections.UNKNOWN;
    4754    /**
    4855     * Flag isNewGpsData indicates that the GPS data of the image is new or has changed.
    4956     * GPS data includes the position, speed, elevation, time (e.g. as extracted from the GPS track).
    public class GpxImageEntry implements Comparable<GpxImageEntry>, IQuadBucketType  
    753760            ifNotNull(ExifReader.readKeywords(dirIptc), this::setIptcKeywords);
    754761            ifNotNull(ExifReader.readObjectName(dirIptc), this::setIptcObjectName);
    755762        }
     763
     764        for (XmpDirectory xmpDirectory : metadata.getDirectoriesOfType(XmpDirectory.class)) {
     765            Map<String, String> properties = xmpDirectory.getXmpProperties();
     766            final String projectionType = "GPano:ProjectionType";
     767            if (properties.containsKey(projectionType)) {
     768                Stream.of(Projections.values()).filter(p -> p.name().equalsIgnoreCase(properties.get(projectionType)))
     769                        .findFirst().ifPresent(projection -> this.cameraProjection = projection);
     770                break;
     771            }
     772        }
     773    }
     774
     775    /**
     776     * Reads the image represented by this entry in the given target dimension.
     777     * @param target the desired dimension used for {@linkplain IIOParam#setSourceSubsampling subsampling} or {@code null}
     778     * @return the read image, or {@code null}
     779     * @throws IOException if any I/O error occurs
     780     */
     781    public BufferedImage read(Dimension target) throws IOException {
     782        throw new UnsupportedOperationException("read not implemented for " + this.getClass().getSimpleName());
    756783    }
    757784
    758785    private static class NoMetadataReaderWarning extends Exception {
    public class GpxImageEntry implements Comparable<GpxImageEntry>, IQuadBucketType  
    767794        }
    768795    }
    769796
     797    /**
     798     * Get the projection type for this entry
     799     * @return The projection type
     800     */
     801    public Projections getProjectionType() {
     802        return this.cameraProjection;
     803    }
     804
    770805    /**
    771806     * Returns a {@link WayPoint} representation of this GPX image entry.
    772807     * @return a {@code WayPoint} representation of this GPX image entry (containing position, instant and elevation)
  • new file src/org/openstreetmap/josm/data/imagery/street_level/IImageEntry.java

    diff --git a/src/org/openstreetmap/josm/data/imagery/street_level/IImageEntry.java b/src/org/openstreetmap/josm/data/imagery/street_level/IImageEntry.java
    new file mode 100644
    index 0000000000..685b8f8d5a
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.data.imagery.street_level;
     3
     4import java.awt.Dimension;
     5import java.awt.image.BufferedImage;
     6import java.io.File;
     7import java.io.IOException;
     8import java.time.Instant;
     9import java.util.List;
     10import javax.imageio.IIOParam;
     11
     12import org.openstreetmap.josm.data.coor.ILatLon;
     13import org.openstreetmap.josm.gui.layer.geoimage.ImageViewerDialog;
     14
     15/**
     16 * An interface for image entries that will be shown in {@link org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay}
     17 * @author Taylor Smock
     18 * @since xxx
     19 */
     20public interface IImageEntry<I extends IImageEntry<I>> {
     21    /**
     22     * Select the next image
     23     * @param imageViewerDialog The image viewer to update
     24     */
     25    default void selectNextImage(final ImageViewerDialog imageViewerDialog) {
     26        imageViewerDialog.displayImage(this.getNextImage());
     27    }
     28
     29    /**
     30     * Get what would be the next image
     31     * @return The next image
     32     */
     33    I getNextImage();
     34
     35    /**
     36     * Select the previous image
     37     * @param imageViewerDialog The image viewer to update
     38     */
     39    default void selectPreviousImage(final ImageViewerDialog imageViewerDialog) {
     40        imageViewerDialog.displayImage(this.getPreviousImage());
     41    }
     42
     43    /**
     44     * Get the previous image
     45     * @return The previous image
     46     */
     47    I getPreviousImage();
     48
     49    /**
     50     * Select the first image for the data or sequence
     51     * @param imageViewerDialog The image viewer to update
     52     */
     53    default void selectFirstImage(final ImageViewerDialog imageViewerDialog) {
     54        imageViewerDialog.displayImage(this.getFirstImage());
     55    }
     56
     57    /**
     58     * Get the first image for the data or sequence
     59     * @return The first image
     60     */
     61    I getFirstImage();
     62
     63    /**
     64     * Select the last image for the data or sequence
     65     * @param imageViewerDialog The image viewer to update
     66     */
     67    default void selectLastImage(final ImageViewerDialog imageViewerDialog) {
     68        imageViewerDialog.displayImage(this.getLastImage());
     69    }
     70
     71    /**
     72     * Get the last image for the data or sequence
     73     * @return The last image
     74     */
     75    I getLastImage();
     76
     77    /**
     78     * Remove the image
     79     * @return {@code true} if removal was successful
     80     * @throws UnsupportedOperationException If the implementation does not support removal.
     81     * Use {@link #isRemoveSupported()}} to check for support.
     82     */
     83    default boolean remove() {
     84        throw new UnsupportedOperationException("remove is not supported for " + this.getClass().getSimpleName());
     85    }
     86
     87    /**
     88     * Check if image removal is supported
     89     * @return {@code true} if removal is supported
     90     */
     91    default boolean isRemoveSupported() {
     92        return false;
     93    }
     94
     95    /**
     96     * Returns a display name for this entry (shown in image viewer title bar)
     97     * @return a display name for this entry
     98     */
     99    String getDisplayName();
     100
     101    /**
     102     * Reads the image represented by this entry in the given target dimension.
     103     * @param target the desired dimension used for {@linkplain IIOParam#setSourceSubsampling subsampling} or {@code null}
     104     * @return the read image, or {@code null}
     105     * @throws IOException if any I/O error occurs
     106     */
     107    BufferedImage read(Dimension target) throws IOException;
     108
     109    /**
     110     * Sets the width of this ImageEntry.
     111     * @param width set the width of this ImageEntry
     112     */
     113    void setWidth(int width);
     114
     115    /**
     116     * Sets the height of this ImageEntry.
     117     * @param height set the height of this ImageEntry
     118     */
     119    void setHeight(int height);
     120
     121    /**
     122     * Returns associated file.
     123     * @return associated file
     124     */
     125    File getFile();
     126
     127    /**
     128     * Returns the position value. The position value from the temporary copy
     129     * is returned if that copy exists.
     130     * @return the position value
     131     */
     132    ILatLon getPos();
     133
     134    /**
     135     * Returns the speed value. The speed value from the temporary copy is
     136     * returned if that copy exists.
     137     * @return the speed value
     138     */
     139    Double getSpeed();
     140
     141    /**
     142     * Returns the elevation value. The elevation value from the temporary
     143     * copy is returned if that copy exists.
     144     * @return the elevation value
     145     */
     146    Double getElevation();
     147
     148    /**
     149     * Returns the image direction. The image direction from the temporary
     150     * copy is returned if that copy exists.
     151     * @return The image camera angle
     152     */
     153    Double getExifImgDir();
     154
     155    /**
     156     * Convenient way to determine if this entry has a EXIF time, without the cost of building a defensive copy.
     157     * @return {@code true} if this entry has a EXIF time
     158     * @since 6450
     159     */
     160    boolean hasExifTime();
     161
     162    /**
     163     * Returns EXIF time
     164     * @return EXIF time
     165     */
     166    Instant getExifInstant();
     167
     168    /**
     169     * Convenient way to determine if this entry has a GPS time, without the cost of building a defensive copy.
     170     * @return {@code true} if this entry has a GPS time
     171     */
     172    boolean hasGpsTime();
     173
     174    /**
     175     * Returns the GPS time value. The GPS time value from the temporary copy
     176     * is returned if that copy exists.
     177     * @return the GPS time value
     178     */
     179    Instant getGpsInstant();
     180
     181    /**
     182     * Returns the IPTC caption.
     183     * @return the IPTC caption
     184     */
     185    String getIptcCaption();
     186
     187    /**
     188     * Returns the IPTC headline.
     189     * @return the IPTC headline
     190     */
     191    String getIptcHeadline();
     192
     193    /**
     194     * Returns the IPTC keywords.
     195     * @return the IPTC keywords
     196     */
     197    List<String> getIptcKeywords();
     198
     199    /**
     200     * Returns the IPTC object name.
     201     * @return the IPTC object name
     202     */
     203    String getIptcObjectName();
     204
     205    /**
     206     * Get the camera projection type
     207     * @return the camera projection type
     208     */
     209    default Projections getProjectionType() {
     210        return Projections.PERSPECTIVE;
     211    }
     212}
  • new file src/org/openstreetmap/josm/data/imagery/street_level/Projections.java

    diff --git a/src/org/openstreetmap/josm/data/imagery/street_level/Projections.java b/src/org/openstreetmap/josm/data/imagery/street_level/Projections.java
    new file mode 100644
    index 0000000000..ce5c2b58ea
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.data.imagery.street_level;
     3
     4/**
     5 * Projections for street level imagery
     6 * @author Taylor Smock
     7 * @since xxx
     8 */
     9public enum Projections {
     10    /** This is the image type from most cameras */
     11    PERSPECTIVE(1),
     12    /** This will probably not be seen often in JOSM, but someone might have a synchronized pair of fisheye camers */
     13    FISHEYE(1),
     14    /** 360 imagery using the equirectangular method (single image) */
     15    EQUIRECTANGULAR(1),
     16    /** 360 imagery using a cube map */
     17    CUBE_MAP(6),
     18    /*
     19     * Additional known projections: Equi-Angular Cubemap (EAC) from Google and the Pyramid format from Facebook
     20     * Neither are particularly well-documented at this point, although I believe the Pyramid format uses 30 images.
     21     */
     22    /** In the event that we have no clue what the projection should be. Defaults to perspective viewing. */
     23    UNKNOWN(Integer.MAX_VALUE);
     24
     25    private final int expectedImages;
     26
     27    /**
     28     * Create a new Projections enum
     29     * @param expectedImages The maximum images for the projection type
     30     */
     31    Projections(final int expectedImages) {
     32        this.expectedImages = expectedImages;
     33    }
     34
     35    /**
     36     * Get the maximum number of expected images for the projection
     37     * @return The number of expected images
     38     */
     39    public int getExpectedImages() {
     40        return this.expectedImages;
     41    }
     42}
  • src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java
    index cece730ab4..8728e886bc 100644
    a b import java.awt.Image;  
    1212import java.awt.Point;
    1313import java.awt.Rectangle;
    1414import java.awt.RenderingHints;
     15import java.awt.event.ComponentEvent;
     16import java.awt.event.MouseAdapter;
    1517import java.awt.event.MouseEvent;
    16 import java.awt.event.MouseListener;
    17 import java.awt.event.MouseMotionListener;
    1818import java.awt.event.MouseWheelEvent;
    19 import java.awt.event.MouseWheelListener;
    2019import java.awt.geom.Rectangle2D;
    2120import java.awt.image.BufferedImage;
    2221import java.io.IOException;
    2322import java.util.Objects;
    2423import java.util.concurrent.Future;
    25 
    2624import javax.swing.JComponent;
    2725import javax.swing.SwingUtilities;
    2826
     27import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
     28import org.openstreetmap.josm.data.imagery.street_level.Projections;
    2929import org.openstreetmap.josm.data.preferences.BooleanProperty;
    3030import org.openstreetmap.josm.data.preferences.DoubleProperty;
    3131import org.openstreetmap.josm.data.preferences.IntegerProperty;
    3232import org.openstreetmap.josm.gui.MainApplication;
     33import org.openstreetmap.josm.gui.layer.geoimage.viewers.projections.IImageViewer;
     34import org.openstreetmap.josm.gui.layer.geoimage.viewers.projections.ImageProjectionRegistry;
    3335import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings;
    3436import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings.FilterChangeListener;
    3537import org.openstreetmap.josm.gui.util.GuiHelper;
    import org.openstreetmap.josm.spi.preferences.PreferenceChangeEvent;  
    3840import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener;
    3941import org.openstreetmap.josm.tools.Destroyable;
    4042import org.openstreetmap.josm.tools.ImageProcessor;
     43import org.openstreetmap.josm.tools.JosmRuntimeException;
    4144import org.openstreetmap.josm.tools.Logging;
    4245
    4346/**
    import org.openstreetmap.josm.tools.Logging;  
    4851 */
    4952public class ImageDisplay extends JComponent implements Destroyable, PreferenceChangedListener, FilterChangeListener {
    5053
     54    /** The current image viewer */
     55    private IImageViewer iImageViewer;
     56
    5157    /** The file that is currently displayed */
    52     private ImageEntry entry;
     58    private IImageEntry<?> entry;
    5359
    5460    /** The previous file that is currently displayed. Cleared on paint. Only used to help improve UI error information. */
    55     private ImageEntry oldEntry;
     61    private IImageEntry<?> oldEntry;
    5662
    5763    /** The image currently displayed */
    5864    private transient BufferedImage image;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    245251    /** The thread that reads the images. */
    246252    protected class LoadImageRunnable implements Runnable {
    247253
    248         private final ImageEntry entry;
     254        private final IImageEntry<?> entry;
    249255
    250         LoadImageRunnable(ImageEntry entry) {
     256        LoadImageRunnable(IImageEntry<?> entry) {
    251257            this.entry = entry;
    252258        }
    253259
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    279285                    updateProcessedImage();
    280286                    // This will clear the loading info box
    281287                    ImageDisplay.this.oldEntry = ImageDisplay.this.entry;
    282                     visibleRect = new VisRect(0, 0, width, height);
     288                    visibleRect = getIImageViewer(entry).getDefaultVisibleRectangle(ImageDisplay.this, image);
    283289
    284290                    selectedRect = null;
    285291                    errorLoading = false;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    291297        }
    292298    }
    293299
    294     private class ImgDisplayMouseListener implements MouseListener, MouseWheelListener, MouseMotionListener {
     300    private class ImgDisplayMouseListener extends MouseAdapter {
    295301
    296302        private MouseEvent lastMouseEvent;
    297303        private Point mousePointInImg;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    314320        }
    315321
    316322        private void mouseWheelMovedImpl(int x, int y, int rotation, boolean refreshMousePointInImg) {
    317             ImageEntry entry;
    318             Image image;
    319             VisRect visibleRect;
     323            IImageEntry<?> currentEntry;
     324            IImageViewer imageViewer;
     325            Image currentImage;
     326            VisRect currentVisibleRect;
    320327
    321328            synchronized (ImageDisplay.this) {
    322                 entry = ImageDisplay.this.entry;
    323                 image = ImageDisplay.this.image;
    324                 visibleRect = ImageDisplay.this.visibleRect;
     329                currentEntry = ImageDisplay.this.entry;
     330                currentImage = ImageDisplay.this.image;
     331                currentVisibleRect = ImageDisplay.this.visibleRect;
     332                imageViewer = ImageDisplay.this.iImageViewer;
    325333            }
    326334
    327335            selectedRect = null;
    328336
    329             if (image == null)
     337            if (currentImage == null)
    330338                return;
    331339
    332340            // Calculate the mouse cursor position in image coordinates to center the zoom.
    333341            if (refreshMousePointInImg)
    334                 mousePointInImg = comp2imgCoord(visibleRect, x, y, getSize());
     342                mousePointInImg = comp2imgCoord(currentVisibleRect, x, y, getSize());
    335343
    336344            // Apply the zoom to the visible rectangle in image coordinates
    337345            if (rotation > 0) {
    338                 visibleRect.width = (int) (visibleRect.width * ZOOM_STEP.get());
    339                 visibleRect.height = (int) (visibleRect.height * ZOOM_STEP.get());
     346                currentVisibleRect.width = (int) (currentVisibleRect.width * ZOOM_STEP.get());
     347                currentVisibleRect.height = (int) (currentVisibleRect.height * ZOOM_STEP.get());
    340348            } else {
    341                 visibleRect.width = (int) (visibleRect.width / ZOOM_STEP.get());
    342                 visibleRect.height = (int) (visibleRect.height / ZOOM_STEP.get());
     349                currentVisibleRect.width = (int) (currentVisibleRect.width / ZOOM_STEP.get());
     350                currentVisibleRect.height = (int) (currentVisibleRect.height / ZOOM_STEP.get());
    343351            }
    344352
    345353            // Check that the zoom doesn't exceed MAX_ZOOM:1
    346             if (visibleRect.width < getSize().width / MAX_ZOOM.get()) {
    347                 visibleRect.width = (int) (getSize().width / MAX_ZOOM.get());
    348             }
    349             if (visibleRect.height < getSize().height / MAX_ZOOM.get()) {
    350                 visibleRect.height = (int) (getSize().height / MAX_ZOOM.get());
    351             }
     354            ensureMaxZoom(currentVisibleRect);
    352355
    353             // Set the same ratio for the visible rectangle and the display area
    354             int hFact = visibleRect.height * getSize().width;
    355             int wFact = visibleRect.width * getSize().height;
    356             if (hFact > wFact) {
    357                 visibleRect.width = hFact / getSize().height;
     356            // The size of the visible rectangle is limited by the image size or the viewer implementation.
     357            if (imageViewer != null) {
     358                imageViewer.checkAndModifyVisibleRectSize(currentImage, currentVisibleRect);
    358359            } else {
    359                 visibleRect.height = wFact / getSize().width;
     360                currentVisibleRect.checkRectSize();
    360361            }
    361362
    362             // The size of the visible rectangle is limited by the image size.
    363             visibleRect.checkRectSize();
    364 
    365363            // Set the position of the visible rectangle, so that the mouse cursor doesn't move on the image.
    366             Rectangle drawRect = calculateDrawImageRectangle(visibleRect, getSize());
    367             visibleRect.x = mousePointInImg.x + ((drawRect.x - x) * visibleRect.width) / drawRect.width;
    368             visibleRect.y = mousePointInImg.y + ((drawRect.y - y) * visibleRect.height) / drawRect.height;
     364            Rectangle drawRect = calculateDrawImageRectangle(currentVisibleRect, getSize());
     365            currentVisibleRect.x = mousePointInImg.x + ((drawRect.x - x) * currentVisibleRect.width) / drawRect.width;
     366            currentVisibleRect.y = mousePointInImg.y + ((drawRect.y - y) * currentVisibleRect.height) / drawRect.height;
    369367
    370368            // The position is also limited by the image size
    371             visibleRect.checkRectPos();
     369            currentVisibleRect.checkRectPos();
    372370
    373371            synchronized (ImageDisplay.this) {
    374                 if (ImageDisplay.this.entry == entry) {
    375                     ImageDisplay.this.visibleRect = visibleRect;
     372                if (ImageDisplay.this.entry == currentEntry) {
     373                    ImageDisplay.this.visibleRect = currentVisibleRect;
    376374                }
    377375            }
    378376            ImageDisplay.this.repaint();
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    400398        @Override
    401399        public void mouseClicked(MouseEvent e) {
    402400            // Move the center to the clicked point.
    403             ImageEntry entry;
    404             Image image;
    405             VisRect visibleRect;
     401            IImageEntry<?> currentEntry;
     402            Image currentImage;
     403            VisRect currentVisibleRect;
    406404
    407405            synchronized (ImageDisplay.this) {
    408                 entry = ImageDisplay.this.entry;
    409                 image = ImageDisplay.this.image;
    410                 visibleRect = ImageDisplay.this.visibleRect;
     406                currentEntry = ImageDisplay.this.entry;
     407                currentImage = ImageDisplay.this.image;
     408                currentVisibleRect = ImageDisplay.this.visibleRect;
    411409            }
    412410
    413             if (image == null)
     411            if (currentImage == null)
    414412                return;
    415413
    416414            if (ZOOM_ON_CLICK.get()) {
    417415                // click notions are less coherent than wheel, refresh mousePointInImg on each click
    418416                lastMouseEvent = null;
    419417
    420                 if (mouseIsZoomSelecting(e) && !isAtMaxZoom(visibleRect)) {
     418                if (mouseIsZoomSelecting(e) && !isAtMaxZoom(currentVisibleRect)) {
    421419                    // zoom in if clicked with the zoom button
    422420                    mouseWheelMovedImpl(e.getX(), e.getY(), -1, true);
    423421                    return;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    430428            }
    431429
    432430            // Calculate the translation to set the clicked point the center of the view.
    433             Point click = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
    434             Point center = getCenterImgCoord(visibleRect);
     431            Point click = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
     432            Point center = getCenterImgCoord(currentVisibleRect);
    435433
    436             visibleRect.x += click.x - center.x;
    437             visibleRect.y += click.y - center.y;
     434            currentVisibleRect.x += click.x - center.x;
     435            currentVisibleRect.y += click.y - center.y;
    438436
    439             visibleRect.checkRectPos();
     437            currentVisibleRect.checkRectPos();
    440438
    441439            synchronized (ImageDisplay.this) {
    442                 if (ImageDisplay.this.entry == entry) {
    443                     ImageDisplay.this.visibleRect = visibleRect;
     440                if (ImageDisplay.this.entry == currentEntry) {
     441                    ImageDisplay.this.visibleRect = currentVisibleRect;
    444442                }
    445443            }
    446444            ImageDisplay.this.repaint();
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    450448         * a picture part) */
    451449        @Override
    452450        public void mousePressed(MouseEvent e) {
    453             Image image;
    454             VisRect visibleRect;
     451            Image currentImage;
     452            VisRect currentVisibleRect;
    455453
    456454            synchronized (ImageDisplay.this) {
    457                 image = ImageDisplay.this.image;
    458                 visibleRect = ImageDisplay.this.visibleRect;
     455                currentImage = ImageDisplay.this.image;
     456                currentVisibleRect = ImageDisplay.this.visibleRect;
    459457            }
    460458
    461             if (image == null)
     459            if (currentImage == null)
    462460                return;
    463461
    464462            selectedRect = null;
    465463
    466464            if (mouseIsDragging(e) || mouseIsZoomSelecting(e))
    467                 mousePointInImg = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
     465                mousePointInImg = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
    468466        }
    469467
    470468        @Override
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    472470            if (!mouseIsDragging(e) && !mouseIsZoomSelecting(e))
    473471                return;
    474472
    475             ImageEntry entry;
    476             Image image;
    477             VisRect visibleRect;
     473            IImageEntry<?> imageEntry;
     474            Image currentImage;
     475            VisRect currentVisibleRect;
    478476
    479477            synchronized (ImageDisplay.this) {
    480                 entry = ImageDisplay.this.entry;
    481                 image = ImageDisplay.this.image;
    482                 visibleRect = ImageDisplay.this.visibleRect;
     478                imageEntry = ImageDisplay.this.entry;
     479                currentImage = ImageDisplay.this.image;
     480                currentVisibleRect = ImageDisplay.this.visibleRect;
    483481            }
    484482
    485             if (image == null)
     483            if (currentImage == null)
    486484                return;
    487485
    488486            if (mouseIsDragging(e) && mousePointInImg != null) {
    489                 Point p = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
    490                 visibleRect.isDragUpdate = true;
    491                 visibleRect.x += mousePointInImg.x - p.x;
    492                 visibleRect.y += mousePointInImg.y - p.y;
    493                 visibleRect.checkRectPos();
     487                Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
     488                getIImageViewer(entry).mouseDragged(this.mousePointInImg, p, currentVisibleRect);
     489                currentVisibleRect.checkRectPos();
    494490                synchronized (ImageDisplay.this) {
    495                     if (ImageDisplay.this.entry == entry) {
    496                         ImageDisplay.this.visibleRect = visibleRect;
     491                    if (ImageDisplay.this.entry == imageEntry) {
     492                        ImageDisplay.this.visibleRect = currentVisibleRect;
    497493                    }
    498494                }
     495                // We have to update the mousePointInImg for 360 image panning, as otherwise the panning
     496                // never stops.
     497                // This does not work well with the perspective viewer at this time (2021-08-26).
     498                if (entry != null && Projections.EQUIRECTANGULAR == entry.getProjectionType()) {
     499                    this.mousePointInImg = p;
     500                }
    499501                ImageDisplay.this.repaint();
    500502            }
    501503
    502504            if (mouseIsZoomSelecting(e) && mousePointInImg != null) {
    503                 Point p = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
    504                 visibleRect.checkPointInside(p);
    505                 VisRect selectedRect = new VisRect(
    506                         p.x < mousePointInImg.x ? p.x : mousePointInImg.x,
    507                         p.y < mousePointInImg.y ? p.y : mousePointInImg.y,
     505                Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
     506                currentVisibleRect.checkPointInside(p);
     507                VisRect selectedRectTemp = new VisRect(
     508                        Math.min(p.x, mousePointInImg.x),
     509                        Math.min(p.y, mousePointInImg.y),
    508510                        p.x < mousePointInImg.x ? mousePointInImg.x - p.x : p.x - mousePointInImg.x,
    509511                        p.y < mousePointInImg.y ? mousePointInImg.y - p.y : p.y - mousePointInImg.y,
    510                         visibleRect);
    511                 selectedRect.checkRectSize();
    512                 selectedRect.checkRectPos();
    513                 ImageDisplay.this.selectedRect = selectedRect;
     512                        currentVisibleRect);
     513                selectedRectTemp.checkRectSize();
     514                selectedRectTemp.checkRectPos();
     515                ImageDisplay.this.selectedRect = selectedRectTemp;
    514516                ImageDisplay.this.repaint();
    515517            }
    516 
    517518        }
    518519
    519520        @Override
    520521        public void mouseReleased(MouseEvent e) {
    521             ImageEntry entry;
    522             Image image;
    523             VisRect visibleRect;
     522            IImageEntry<?> currentEntry;
     523            Image currentImage;
     524            VisRect currentVisibleRect;
    524525
    525526            synchronized (ImageDisplay.this) {
    526                 entry = ImageDisplay.this.entry;
    527                 image = ImageDisplay.this.image;
    528                 visibleRect = ImageDisplay.this.visibleRect;
     527                currentEntry = ImageDisplay.this.entry;
     528                currentImage = ImageDisplay.this.image;
     529                currentVisibleRect = ImageDisplay.this.visibleRect;
    529530            }
    530531
    531             if (image == null)
     532            if (currentImage == null)
    532533                return;
    533534
    534535            if (mouseIsDragging(e)) {
    535                 visibleRect.isDragUpdate = false;
     536                currentVisibleRect.isDragUpdate = false;
    536537            }
    537538
    538539            if (mouseIsZoomSelecting(e) && selectedRect != null) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    540541                int oldHeight = selectedRect.height;
    541542
    542543                // Check that the zoom doesn't exceed MAX_ZOOM:1
    543                 if (selectedRect.width < getSize().width / MAX_ZOOM.get()) {
    544                     selectedRect.width = (int) (getSize().width / MAX_ZOOM.get());
    545                 }
    546                 if (selectedRect.height < getSize().height / MAX_ZOOM.get()) {
    547                     selectedRect.height = (int) (getSize().height / MAX_ZOOM.get());
    548                 }
    549 
    550                 // Set the same ratio for the visible rectangle and the display area
    551                 int hFact = selectedRect.height * getSize().width;
    552                 int wFact = selectedRect.width * getSize().height;
    553                 if (hFact > wFact) {
    554                     selectedRect.width = hFact / getSize().height;
    555                 } else {
    556                     selectedRect.height = wFact / getSize().width;
    557                 }
     544                ensureMaxZoom(selectedRect);
    558545
    559546                // Keep the center of the selection
    560547                if (selectedRect.width != oldWidth) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    569556            }
    570557
    571558            synchronized (ImageDisplay.this) {
    572                 if (entry == ImageDisplay.this.entry) {
     559                if (currentEntry == ImageDisplay.this.entry) {
    573560                    if (selectedRect == null) {
    574                         ImageDisplay.this.visibleRect = visibleRect;
     561                        ImageDisplay.this.visibleRect = currentVisibleRect;
    575562                    } else {
    576563                        ImageDisplay.this.visibleRect.setBounds(selectedRect);
    577564                        selectedRect = null;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    580567            }
    581568            ImageDisplay.this.repaint();
    582569        }
    583 
    584         @Override
    585         public void mouseEntered(MouseEvent e) {
    586             // Do nothing
    587         }
    588 
    589         @Override
    590         public void mouseExited(MouseEvent e) {
    591             // Do nothing
    592         }
    593 
    594         @Override
    595         public void mouseMoved(MouseEvent e) {
    596             // Do nothing
    597         }
    598570    }
    599571
    600572    /**
    601573     * Constructs a new {@code ImageDisplay} with no image processor.
    602574     */
    603575    public ImageDisplay() {
    604         this(image -> image);
     576        this(imageObject -> imageObject);
    605577    }
    606578
    607579    /**
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    636608     * Sets a new source image to be displayed by this {@code ImageDisplay}.
    637609     * @param entry new source image
    638610     * @return a {@link Future} representing pending completion of the image loading task
    639      * @since 18150
     611     * @since 18150 (xxx for IImageEntry)
    640612     */
    641     public Future<?> setImage(ImageEntry entry) {
     613    public Future<?> setImage(IImageEntry<?> entry) {
    642614        LoadImageRunnable runnable = setImage0(entry);
    643615        return runnable != null ? MainApplication.worker.submit(runnable) : null;
    644616    }
    645617
    646     protected LoadImageRunnable setImage0(ImageEntry entry) {
     618    protected LoadImageRunnable setImage0(IImageEntry<?> entry) {
    647619        synchronized (this) {
    648620            this.oldEntry = this.entry;
    649621            this.entry = entry;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    691663
    692664    private void updateProcessedImage() {
    693665        processedImage = image == null ? null : imageProcessor.process(image);
    694         GuiHelper.runInEDT(() -> repaint());
     666        GuiHelper.runInEDT(this::repaint);
    695667    }
    696668
    697669    @Override
    698670    public void paintComponent(Graphics g) {
    699         ImageEntry entry;
    700         ImageEntry oldEntry;
    701         BufferedImage image;
    702         VisRect visibleRect;
    703         boolean errorLoading;
     671        IImageEntry<?> currentEntry;
     672        IImageEntry<?> currentOldEntry;
     673        IImageViewer currentImageViewer;
     674        BufferedImage currentImage;
     675        VisRect currentVisibleRect;
     676        boolean currentErrorLoading;
    704677
    705678        synchronized (this) {
    706             image = this.processedImage;
    707             entry = this.entry;
    708             oldEntry = this.oldEntry;
    709             visibleRect = this.visibleRect;
    710             errorLoading = this.errorLoading;
     679            currentImage = this.processedImage;
     680            currentEntry = this.entry;
     681            currentOldEntry = this.oldEntry;
     682            currentVisibleRect = this.visibleRect;
     683            currentErrorLoading = this.errorLoading;
    711684        }
    712685
    713686        if (g instanceof Graphics2D) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    716689
    717690        Dimension size = getSize();
    718691        // Draw the image first, then draw error information
    719         if (image != null && (entry != null || oldEntry != null)) {
    720             Rectangle r = new Rectangle(visibleRect);
    721             Rectangle target = calculateDrawImageRectangle(visibleRect, size);
    722 
    723             g.drawImage(image,
    724                     target.x, target.y, target.x + target.width, target.y + target.height,
    725                     r.x, r.y, r.x + r.width, r.y + r.height, null);
    726 
    727             if (selectedRect != null) {
    728                 Point topLeft = img2compCoord(visibleRect, selectedRect.x, selectedRect.y, size);
    729                 Point bottomRight = img2compCoord(visibleRect,
    730                         selectedRect.x + selectedRect.width,
    731                         selectedRect.y + selectedRect.height, size);
    732                 g.setColor(new Color(128, 128, 128, 180));
    733                 g.fillRect(target.x, target.y, target.width, topLeft.y - target.y);
    734                 g.fillRect(target.x, target.y, topLeft.x - target.x, target.height);
    735                 g.fillRect(bottomRight.x, target.y, target.x + target.width - bottomRight.x, target.height);
    736                 g.fillRect(target.x, bottomRight.y, target.width, target.y + target.height - bottomRight.y);
    737                 g.setColor(Color.black);
    738                 g.drawRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
    739             }
    740             if (errorLoading && entry != null) {
    741                 String loadingStr = tr("Error on file {0}", entry.getDisplayName());
     692        if (currentImage != null && (currentEntry != null || currentOldEntry != null)) {
     693            currentImageViewer = this.getIImageViewer(currentEntry);
     694            Rectangle r = new Rectangle(currentVisibleRect);
     695            Rectangle target = calculateDrawImageRectangle(currentVisibleRect, size);
     696
     697            currentImageViewer.paintImage(g, currentImage, target, r);
     698            paintSelectedRect(g, target, currentVisibleRect, size);
     699            if (currentErrorLoading && currentEntry != null) {
     700                String loadingStr = tr("Error on file {0}", currentEntry.getDisplayName());
    742701                Rectangle2D noImageSize = g.getFontMetrics(g.getFont()).getStringBounds(loadingStr, g);
    743                 g.drawString(loadingStr,
    744                         (int) ((size.width - noImageSize.getWidth()) / 2),
     702                g.drawString(loadingStr, (int) ((size.width - noImageSize.getWidth()) / 2),
    745703                        (int) ((size.height - noImageSize.getHeight()) / 2));
    746704            }
    747             if (osdText != null) {
    748                 FontMetrics metrics = g.getFontMetrics(g.getFont());
    749                 int ascent = metrics.getAscent();
    750                 Color bkground = new Color(255, 255, 255, 128);
    751                 int lastPos = 0;
    752                 int pos = osdText.indexOf('\n');
    753                 int x = 3;
    754                 int y = 3;
    755                 String line;
    756                 while (pos > 0) {
    757                     line = osdText.substring(lastPos, pos);
    758                     Rectangle2D lineSize = metrics.getStringBounds(line, g);
    759                     g.setColor(bkground);
    760                     g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
    761                     g.setColor(Color.black);
    762                     g.drawString(line, x, y + ascent);
    763                     y += (int) lineSize.getHeight();
    764                     lastPos = pos + 1;
    765                     pos = osdText.indexOf('\n', lastPos);
    766                 }
    767 
    768                 line = osdText.substring(lastPos);
    769                 Rectangle2D lineSize = g.getFontMetrics(g.getFont()).getStringBounds(line, g);
    770                 g.setColor(bkground);
    771                 g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
    772                 g.setColor(Color.black);
    773                 g.drawString(line, x, y + ascent);
    774             }
     705            paintOsdText(g);
    775706        }
     707        paintErrorMessage(g, currentEntry, currentOldEntry, currentImage, currentErrorLoading, size);
     708    }
     709
     710    /**
     711     * Paint an error message
     712     * @param g The graphics to paint on
     713     * @param imageEntry The current image entry
     714     * @param oldImageEntry The old image entry
     715     * @param bufferedImage The image being painted
     716     * @param currentErrorLoading If there was an error loading the image
     717     * @param size The size of the component
     718     */
     719    private void paintErrorMessage(Graphics g, IImageEntry<?> imageEntry, IImageEntry<?> oldImageEntry,
     720            BufferedImage bufferedImage, boolean currentErrorLoading, Dimension size) {
    776721        final String errorMessage;
    777722        // If the new entry is null, then there is no image.
    778         if (entry == null) {
     723        if (imageEntry == null) {
    779724            if (emptyText == null) {
    780725                emptyText = tr("No image");
    781726            }
    782727            errorMessage = emptyText;
    783         } else if (image == null || !Objects.equals(entry, oldEntry)) {
     728        } else if (bufferedImage == null || !Objects.equals(imageEntry, oldImageEntry)) {
    784729            // The image is not necessarily null when loading anymore. If the oldEntry is not the same as the new entry,
    785730            // we are probably still loading the image. (oldEntry gets set to entry when the image finishes loading).
    786             if (!errorLoading) {
    787                 errorMessage = tr("Loading {0}", entry.getDisplayName());
     731            if (!currentErrorLoading) {
     732                errorMessage = tr("Loading {0}", imageEntry.getDisplayName());
    788733            } else {
    789                 errorMessage = tr("Error on file {0}", entry.getDisplayName());
     734                errorMessage = tr("Error on file {0}", imageEntry.getDisplayName());
    790735            }
    791736        } else {
    792737            errorMessage = null;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    812757        }
    813758    }
    814759
     760    /**
     761     * Paint OSD text
     762     * @param g The graphics to paint on
     763     */
     764    private void paintOsdText(Graphics g) {
     765        if (osdText != null) {
     766            FontMetrics metrics = g.getFontMetrics(g.getFont());
     767            int ascent = metrics.getAscent();
     768            Color bkground = new Color(255, 255, 255, 128);
     769            int lastPos = 0;
     770            int pos = osdText.indexOf('\n');
     771            int x = 3;
     772            int y = 3;
     773            String line;
     774            while (pos > 0) {
     775                line = osdText.substring(lastPos, pos);
     776                Rectangle2D lineSize = metrics.getStringBounds(line, g);
     777                g.setColor(bkground);
     778                g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
     779                g.setColor(Color.black);
     780                g.drawString(line, x, y + ascent);
     781                y += (int) lineSize.getHeight();
     782                lastPos = pos + 1;
     783                pos = osdText.indexOf('\n', lastPos);
     784            }
     785
     786            line = osdText.substring(lastPos);
     787            Rectangle2D lineSize = g.getFontMetrics(g.getFont()).getStringBounds(line, g);
     788            g.setColor(bkground);
     789            g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
     790            g.setColor(Color.black);
     791            g.drawString(line, x, y + ascent);
     792        }
     793    }
     794
     795    /**
     796     * Paint the selected rectangle
     797     * @param g The graphics to paint on
     798     * @param target The target area (i.e., the selection)
     799     * @param visibleRectTemp The current visible rect
     800     * @param size The size of the component
     801     */
     802    private void paintSelectedRect(Graphics g, Rectangle target, VisRect visibleRectTemp, Dimension size) {
     803        if (selectedRect != null) {
     804            Point topLeft = img2compCoord(visibleRectTemp, selectedRect.x, selectedRect.y, size);
     805            Point bottomRight = img2compCoord(visibleRectTemp,
     806                    selectedRect.x + selectedRect.width,
     807                    selectedRect.y + selectedRect.height, size);
     808            g.setColor(new Color(128, 128, 128, 180));
     809            g.fillRect(target.x, target.y, target.width, topLeft.y - target.y);
     810            g.fillRect(target.x, target.y, topLeft.x - target.x, target.height);
     811            g.fillRect(bottomRight.x, target.y, target.x + target.width - bottomRight.x, target.height);
     812            g.fillRect(target.x, bottomRight.y, target.width, target.y + target.height - bottomRight.y);
     813            g.setColor(Color.black);
     814            g.drawRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
     815        }
     816    }
     817
    815818    static Point img2compCoord(VisRect visibleRect, int xImg, int yImg, Dimension compSize) {
    816819        Rectangle drawRect = calculateDrawImageRectangle(visibleRect, compSize);
    817820        return new Point(drawRect.x + ((xImg - visibleRect.x) * drawRect.width) / visibleRect.width,
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    835838                         visibleRect.y + visibleRect.height / 2);
    836839    }
    837840
     841    /**
     842     * calculateDrawImageRectangle
     843     *
     844     * @param visibleRect the part of the image that should be drawn (in image coordinates)
     845     * @param compSize the part of the component where the image should be drawn (in component coordinates)
     846     * @return the part of compRect with the same width/height ratio as the image
     847     */
    838848    static VisRect calculateDrawImageRectangle(VisRect visibleRect, Dimension compSize) {
    839849        return calculateDrawImageRectangle(visibleRect, new Rectangle(0, 0, compSize.width, compSize.height));
    840850    }
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    888898     * the component size.
    889899     */
    890900    public void zoomBestFitOrOne() {
    891         ImageEntry entry;
    892         Image image;
    893         VisRect visibleRect;
     901        IImageEntry<?> currentEntry;
     902        Image currentImage;
     903        VisRect currentVisibleRect;
    894904
    895905        synchronized (this) {
    896             entry = this.entry;
    897             image = this.image;
    898             visibleRect = this.visibleRect;
     906            currentEntry = this.entry;
     907            currentImage = this.image;
     908            currentVisibleRect = this.visibleRect;
    899909        }
    900910
    901         if (image == null)
     911        if (currentImage == null)
    902912            return;
    903913
    904         if (visibleRect.width != image.getWidth(null) || visibleRect.height != image.getHeight(null)) {
     914        if (currentVisibleRect.width != currentImage.getWidth(null) || currentVisibleRect.height != currentImage.getHeight(null)) {
    905915            // The display is not at best fit. => Zoom to best fit
    906             visibleRect.reset();
     916            currentVisibleRect.reset();
    907917        } else {
    908918            // The display is at best fit => zoom to 1:1
    909             Point center = getCenterImgCoord(visibleRect);
    910             visibleRect.setBounds(center.x - getWidth() / 2, center.y - getHeight() / 2,
     919            Point center = getCenterImgCoord(currentVisibleRect);
     920            currentVisibleRect.setBounds(center.x - getWidth() / 2, center.y - getHeight() / 2,
    911921                    getWidth(), getHeight());
    912             visibleRect.checkRectSize();
    913             visibleRect.checkRectPos();
     922            currentVisibleRect.checkRectSize();
     923            currentVisibleRect.checkRectPos();
    914924        }
    915925
    916926        synchronized (this) {
    917             if (this.entry == entry) {
    918                 this.visibleRect = visibleRect;
     927            if (this.entry == currentEntry) {
     928                this.visibleRect = currentVisibleRect;
    919929            }
    920930        }
    921931        repaint();
    922932    }
     933
     934    /**
     935     * Get the image viewer for an entry
     936     * @param entry The entry to get the viewer for. May be {@code null}.
     937     * @return The new image viewer, may be {@code null}
     938     */
     939    private IImageViewer getIImageViewer(IImageEntry<?> entry) {
     940        IImageViewer imageViewer;
     941        IImageEntry<?> imageEntry;
     942        synchronized (this) {
     943            imageViewer = this.iImageViewer;
     944            imageEntry = entry == null ? this.entry : entry;
     945        }
     946        if (imageEntry == null || imageViewer != null && imageViewer.getSupportedProjections().contains(imageEntry.getProjectionType())) {
     947            return imageViewer;
     948        }
     949        try {
     950            imageViewer = ImageProjectionRegistry.getViewer(imageEntry.getProjectionType()).getConstructor().newInstance();
     951        } catch (ReflectiveOperationException e) {
     952            throw new JosmRuntimeException(e);
     953        }
     954        synchronized (this) {
     955            if (imageEntry.equals(this.entry)) {
     956                this.removeComponentListener(this.iImageViewer);
     957                this.iImageViewer = imageViewer;
     958                imageViewer.componentResized(new ComponentEvent(this, ComponentEvent.COMPONENT_RESIZED));
     959                this.addComponentListener(this.iImageViewer);
     960            }
     961        }
     962        return imageViewer;
     963    }
     964
     965    /**
     966     * Ensure that a rectangle isn't zoomed in too much
     967     * @param rectangle The rectangle to get (typically the visible area)
     968     */
     969    private void ensureMaxZoom(final Rectangle rectangle) {
     970        if (rectangle.width < getSize().width / MAX_ZOOM.get()) {
     971            rectangle.width = (int) (getSize().width / MAX_ZOOM.get());
     972        }
     973        if (rectangle.height < getSize().height / MAX_ZOOM.get()) {
     974            rectangle.height = (int) (getSize().height / MAX_ZOOM.get());
     975        }
     976
     977        // Set the same ratio for the visible rectangle and the display area
     978        int hFact = rectangle.height * getSize().width;
     979        int wFact = rectangle.width * getSize().height;
     980        if (hFact > wFact) {
     981            rectangle.width = hFact / getSize().height;
     982        } else {
     983            rectangle.height = wFact / getSize().width;
     984        }
     985    }
     986
     987    /**
     988     * Update the visible rectangle (ensure zoom does not exceed specified values).
     989     * Specifically only visible for {@link IImageViewer} implementations.
     990     * @since xxx
     991     */
     992    public void updateVisibleRectangle() {
     993        final VisRect currentVisibleRect;
     994        final Image mouseImage;
     995        final IImageViewer iImageViewer;
     996        synchronized (this) {
     997            currentVisibleRect = this.visibleRect;
     998            mouseImage = this.image;
     999            iImageViewer = this.getIImageViewer(this.entry);
     1000        }
     1001        if (mouseImage != null && currentVisibleRect != null && iImageViewer != null) {
     1002            final Image maxImageSize = iImageViewer.getMaxImageSize(this, mouseImage);
     1003            final VisRect maxVisibleRect = new VisRect(0, 0, maxImageSize.getWidth(null), maxImageSize.getHeight(null));
     1004            maxVisibleRect.setRect(currentVisibleRect);
     1005            ensureMaxZoom(maxVisibleRect);
     1006
     1007            maxVisibleRect.checkRectSize();
     1008            synchronized (this) {
     1009                this.visibleRect = maxVisibleRect;
     1010            }
     1011        }
     1012    }
    9231013}
  • src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java
    index c0d3412f4e..8847011a33 100644
    a b import java.net.MalformedURLException;  
    1515import java.net.URL;
    1616import java.util.Collections;
    1717import java.util.Objects;
    18 
    1918import javax.imageio.IIOParam;
    2019import javax.imageio.ImageReadParam;
    2120import javax.imageio.ImageReader;
    2221
    2322import org.openstreetmap.josm.data.ImageData;
    2423import org.openstreetmap.josm.data.gpx.GpxImageEntry;
     24import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
    2525import org.openstreetmap.josm.tools.ExifReader;
    2626import org.openstreetmap.josm.tools.ImageProvider;
    2727import org.openstreetmap.josm.tools.Logging;
    import org.openstreetmap.josm.tools.Logging;  
    3030 * Stores info about each image, with an optional thumbnail
    3131 * @since 2662
    3232 */
    33 public class ImageEntry extends GpxImageEntry {
     33public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry> {
    3434
    3535    private Image thumbnail;
    3636    private ImageData dataSet;
    public class ImageEntry extends GpxImageEntry {  
    135135        return Objects.equals(thumbnail, other.thumbnail) && Objects.equals(dataSet, other.dataSet);
    136136    }
    137137
     138    @Override
     139    public ImageEntry getNextImage() {
     140        return this.dataSet.getNextImage();
     141    }
     142
     143    @Override
     144    public void selectNextImage(final ImageViewerDialog imageViewerDialog) {
     145        IImageEntry.super.selectNextImage(imageViewerDialog);
     146        this.dataSet.setSelectedImage(this.getNextImage());
     147    }
     148
     149    @Override
     150    public ImageEntry getPreviousImage() {
     151        return this.dataSet.getPreviousImage();
     152    }
     153
     154    @Override
     155    public void selectPreviousImage(ImageViewerDialog imageViewerDialog) {
     156        IImageEntry.super.selectPreviousImage(imageViewerDialog);
     157        this.dataSet.setSelectedImage(this.getPreviousImage());
     158    }
     159
     160    @Override
     161    public ImageEntry getFirstImage() {
     162        return this.dataSet.getFirstImage();
     163    }
     164
     165    @Override
     166    public void selectFirstImage(ImageViewerDialog imageViewerDialog) {
     167        IImageEntry.super.selectFirstImage(imageViewerDialog);
     168        this.dataSet.setSelectedImage(this.getFirstImage());
     169    }
     170
     171    @Override
     172    public ImageEntry getLastImage() {
     173        return this.dataSet.getLastImage();
     174    }
     175
     176    @Override
     177    public void selectLastImage(ImageViewerDialog imageViewerDialog) {
     178        IImageEntry.super.selectLastImage(imageViewerDialog);
     179        this.dataSet.setSelectedImage(this.getLastImage());
     180    }
     181
     182    @Override
     183    public boolean isRemoveSupported() {
     184        return true;
     185    }
     186
     187    @Override
     188    public boolean remove() {
     189        this.dataSet.removeImage(this, false);
     190        return true;
     191    }
     192
    138193    /**
    139194     * Reads the image represented by this entry in the given target dimension.
    140195     * @param target the desired dimension used for {@linkplain IIOParam#setSourceSubsampling subsampling} or {@code null}
  • src/org/openstreetmap/josm/gui/layer/geoimage/ImageViewerDialog.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageViewerDialog.java b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageViewerDialog.java
    index fc74335f22..bf668a17ae 100644
    a b import java.awt.event.WindowEvent;  
    1515import java.time.ZoneOffset;
    1616import java.time.format.DateTimeFormatter;
    1717import java.time.format.FormatStyle;
     18import java.util.ArrayList;
    1819import java.util.Collections;
    1920import java.util.List;
    2021import java.util.Optional;
    2122import java.util.concurrent.Future;
    22 
     23import java.util.stream.Collectors;
    2324import javax.swing.AbstractAction;
    2425import javax.swing.Box;
    2526import javax.swing.JButton;
    import javax.swing.SwingConstants;  
    3233import org.openstreetmap.josm.actions.JosmAction;
    3334import org.openstreetmap.josm.data.ImageData;
    3435import org.openstreetmap.josm.data.ImageData.ImageDataUpdateListener;
     36import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
    3537import org.openstreetmap.josm.gui.ExtendedDialog;
    3638import org.openstreetmap.josm.gui.MainApplication;
    3739import org.openstreetmap.josm.gui.datatransfer.ClipboardUtils;
    38 import org.openstreetmap.josm.gui.dialogs.DialogsPanel.Action;
     40import org.openstreetmap.josm.gui.dialogs.DialogsPanel;
    3941import org.openstreetmap.josm.gui.dialogs.ToggleDialog;
    4042import org.openstreetmap.josm.gui.dialogs.layer.LayerVisibilityAction;
    4143import org.openstreetmap.josm.gui.layer.Layer;
    import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings;  
    4951import org.openstreetmap.josm.tools.ImageProvider;
    5052import org.openstreetmap.josm.tools.Logging;
    5153import org.openstreetmap.josm.tools.Shortcut;
    52 import org.openstreetmap.josm.tools.Utils;
    5354import org.openstreetmap.josm.tools.date.DateUtils;
    5455
    5556/**
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    220221
    221222        @Override
    222223        public void actionPerformed(ActionEvent e) {
    223             if (currentData != null) {
    224                 currentData.selectNextImage();
     224            if (ImageViewerDialog.this.currentEntry != null) {
     225                ImageViewerDialog.this.currentEntry.selectNextImage(ImageViewerDialog.this);
    225226            }
    226227        }
    227228    }
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    235236
    236237        @Override
    237238        public void actionPerformed(ActionEvent e) {
    238             if (currentData != null) {
    239                 currentData.selectPreviousImage();
     239            if (ImageViewerDialog.this.currentEntry != null) {
     240                ImageViewerDialog.this.currentEntry.selectPreviousImage(ImageViewerDialog.this);
    240241            }
    241242        }
    242243    }
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    250251
    251252        @Override
    252253        public void actionPerformed(ActionEvent e) {
    253             if (currentData != null) {
    254                 currentData.selectFirstImage();
     254            if (ImageViewerDialog.this.currentEntry != null) {
     255                ImageViewerDialog.this.currentEntry.selectFirstImage(ImageViewerDialog.this);
    255256            }
    256257        }
    257258    }
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    265266
    266267        @Override
    267268        public void actionPerformed(ActionEvent e) {
    268             if (currentData != null) {
    269                 currentData.selectLastImage();
     269            if (ImageViewerDialog.this.currentEntry != null) {
     270                ImageViewerDialog.this.currentEntry.selectLastImage(ImageViewerDialog.this);
    270271            }
    271272        }
    272273    }
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    308309
    309310        @Override
    310311        public void actionPerformed(ActionEvent e) {
    311             if (currentData != null) {
    312                 currentData.removeSelectedImages();
     312            if (ImageViewerDialog.this.currentEntry != null) {
     313                IImageEntry<?> imageEntry = ImageViewerDialog.this.currentEntry;
     314                if (imageEntry.isRemoveSupported()) {
     315                    imageEntry.remove();
     316                }
    313317            }
    314318        }
    315319    }
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    324328
    325329        @Override
    326330        public void actionPerformed(ActionEvent e) {
    327             if (currentData != null && currentData.getSelectedImage() != null) {
    328                 List<ImageEntry> toDelete = currentData.getSelectedImages();
     331            if (currentEntry != null) {
     332                List<IImageEntry<?>> toDelete = currentEntry instanceof ImageEntry ?
     333                        new ArrayList<>(((ImageEntry) currentEntry).getDataSet().getSelectedImages())
     334                        : Collections.singletonList(currentEntry);
    329335                int size = toDelete.size();
    330336
    331337                int result = new ExtendedDialog(
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    346352                        .getValue();
    347353
    348354                if (result == 2) {
    349                     for (ImageEntry delete : toDelete) {
    350                         if (Utils.deleteFile(delete.getFile())) {
    351                             currentData.removeImage(delete, false);
     355                    final List<ImageData> imageDataCollection = toDelete.stream().filter(ImageEntry.class::isInstance)
     356                            .map(ImageEntry.class::cast).map(ImageEntry::getDataSet).distinct().collect(Collectors.toList());
     357                    for (IImageEntry<?> delete : toDelete) {
     358                        if (delete.isRemoveSupported() && delete.remove()) {
    352359                            Logging.info("File {0} deleted.", delete.getFile());
    353360                        } else {
    354361                            JOptionPane.showMessageDialog(
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    359366                                    );
    360367                        }
    361368                    }
    362                     currentData.notifyImageUpdate();
    363                     currentData.updateSelectedImage();
     369                    imageDataCollection.forEach(data -> {
     370                        data.notifyImageUpdate();
     371                        data.updateSelectedImage();
     372                    });
    364373                }
    365374            }
    366375        }
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    375384
    376385        @Override
    377386        public void actionPerformed(ActionEvent e) {
    378             if (currentData != null) {
    379                 ClipboardUtils.copyString(String.valueOf(currentData.getSelectedImage().getFile()));
     387            if (currentEntry != null) {
     388                ClipboardUtils.copyString(String.valueOf(currentEntry.getFile()));
    380389            }
    381390        }
    382391    }
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    425434        return wasEnabled;
    426435    }
    427436
    428     private transient ImageData currentData;
    429     private transient ImageEntry currentEntry;
     437    private transient IImageEntry<?> currentEntry;
    430438
    431439    /**
    432440     * Displays a single image for the given layer.
    433      * @param data the image data
     441     * @param ignoredData the image data
    434442     * @param entry image entry
    435443     * @see #displayImages
    436444     */
    437     public void displayImage(ImageData data, ImageEntry entry) {
    438         displayImages(data, Collections.singletonList(entry));
     445    public void displayImage(ImageData ignoredData, ImageEntry entry) {
     446        displayImages(Collections.singletonList(entry));
     447    }
     448
     449    /**
     450     * Displays a single image for the given layer.
     451     * @param entry image entry
     452     * @see #displayImages
     453     */
     454    public void displayImage(IImageEntry<?> entry) {
     455        this.displayImages(Collections.singletonList(entry));
    439456    }
    440457
    441458    /**
    442459     * Displays images for the given layer.
    443      * @param data the image data
    444460     * @param entries image entries
    445      * @since 15333
     461     * @since xxx
    446462     */
    447     public void displayImages(ImageData data, List<ImageEntry> entries) {
     463    public void displayImages(List<IImageEntry<?>> entries) {
    448464        boolean imageChanged;
    449         ImageEntry entry = entries != null && entries.size() == 1 ? entries.get(0) : null;
     465        IImageEntry<?> entry = entries != null && entries.size() == 1 ? entries.get(0) : null;
    450466
    451467        synchronized (this) {
    452468            // TODO: pop up image dialog but don't load image again
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    457473                MainApplication.getMap().mapView.zoomTo(entry.getPos());
    458474            }
    459475
    460             currentData = data;
    461476            currentEntry = entry;
    462477        }
    463478
    464479        if (entry != null) {
    465             setNextEnabled(data.hasNextImage());
    466             setPreviousEnabled(data.hasPreviousImage());
    467             btnDelete.setEnabled(true);
    468             btnDeleteFromDisk.setEnabled(entry.getFile() != null);
    469             btnCopyPath.setEnabled(true);
    470 
    471             if (imageChanged) {
    472                 cancelLoadingImage();
    473                 // Set only if the image is new to preserve zoom and position if the same image is redisplayed
    474                 // (e.g. to update the OSD).
    475                 imgLoadingFuture = imgDisplay.setImage(entry);
    476             }
    477             setTitle(tr("Geotagged Images") + (!entry.getDisplayName().isEmpty() ? " - " + entry.getDisplayName() : ""));
    478             StringBuilder osd = new StringBuilder(entry.getDisplayName());
    479             if (entry.getElevation() != null) {
    480                 osd.append(tr("\nAltitude: {0} m", Math.round(entry.getElevation())));
    481             }
    482             if (entry.getSpeed() != null) {
    483                 osd.append(tr("\nSpeed: {0} km/h", Math.round(entry.getSpeed())));
    484             }
    485             if (entry.getExifImgDir() != null) {
    486                 osd.append(tr("\nDirection {0}\u00b0", Math.round(entry.getExifImgDir())));
    487             }
    488 
    489             DateTimeFormatter dtf = DateUtils.getDateTimeFormatter(FormatStyle.SHORT, FormatStyle.MEDIUM)
    490                     // Set timezone to UTC since UTC is assumed when parsing the EXIF timestamp,
    491                     // see see org.openstreetmap.josm.tools.ExifReader.readTime(com.drew.metadata.Metadata)
    492                     .withZone(ZoneOffset.UTC);
    493 
    494             if (entry.hasExifTime()) {
    495                 osd.append(tr("\nEXIF time: {0}", dtf.format(entry.getExifInstant())));
    496             }
    497             if (entry.hasGpsTime()) {
    498                 osd.append(tr("\nGPS time: {0}", dtf.format(entry.getGpsInstant())));
    499             }
    500             Optional.ofNullable(entry.getIptcCaption()).map(s -> tr("\nCaption: {0}", s)).ifPresent(osd::append);
    501             Optional.ofNullable(entry.getIptcHeadline()).map(s -> tr("\nHeadline: {0}", s)).ifPresent(osd::append);
    502             Optional.ofNullable(entry.getIptcKeywords()).map(s -> tr("\nKeywords: {0}", s)).ifPresent(osd::append);
    503             Optional.ofNullable(entry.getIptcObjectName()).map(s -> tr("\nObject name: {0}", s)).ifPresent(osd::append);
    504 
    505             imgDisplay.setOsdText(osd.toString());
     480            this.updateButtonsNonNullEntry(entry, imageChanged);
    506481        } else {
    507             boolean hasMultipleImages = entries != null && entries.size() > 1;
    508             // if this method is called to reinitialize dialog content with a blank image,
    509             // do not actually show the dialog again with a blank image if currently hidden (fix #10672)
    510             setTitle(tr("Geotagged Images"));
    511             imgDisplay.setImage(null);
    512             imgDisplay.setOsdText("");
    513             setNextEnabled(false);
    514             setPreviousEnabled(false);
    515             btnDelete.setEnabled(hasMultipleImages);
    516             btnDeleteFromDisk.setEnabled(hasMultipleImages);
    517             btnCopyPath.setEnabled(false);
    518             if (hasMultipleImages) {
    519                 imgDisplay.setEmptyText(tr("Multiple images selected"));
    520                 btnFirst.setEnabled(!isFirstImageSelected(data));
    521                 btnLast.setEnabled(!isLastImageSelected(data));
    522             }
    523             imgDisplay.setImage(null);
    524             imgDisplay.setOsdText("");
     482            this.updateButtonsNullEntry(entries);
    525483            return;
    526484        }
    527485        if (!isDialogShowing()) {
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    530488        } else {
    531489            if (isDocked && isCollapsed) {
    532490                expand();
    533                 dialogsPanel.reconstruct(Action.COLLAPSED_TO_DEFAULT, this);
     491                dialogsPanel.reconstruct(DialogsPanel.Action.COLLAPSED_TO_DEFAULT, this);
    534492            }
    535493        }
    536494    }
    537495
    538     private static boolean isLastImageSelected(ImageData data) {
    539         return data.isImageSelected(data.getImages().get(data.getImages().size() - 1));
     496    /**
     497     * Update buttons for null entry
     498     * @param entries {@code true} if multiple images are selected
     499     */
     500    private void updateButtonsNullEntry(List<IImageEntry<?>> entries) {
     501        boolean hasMultipleImages = entries != null && entries.size() > 1;
     502        // if this method is called to reinitialize dialog content with a blank image,
     503        // do not actually show the dialog again with a blank image if currently hidden (fix #10672)
     504        setTitle(tr("Geotagged Images"));
     505        imgDisplay.setImage(null);
     506        imgDisplay.setOsdText("");
     507        setNextEnabled(false);
     508        setPreviousEnabled(false);
     509        btnDelete.setEnabled(hasMultipleImages);
     510        btnDeleteFromDisk.setEnabled(hasMultipleImages);
     511        btnCopyPath.setEnabled(false);
     512        if (hasMultipleImages) {
     513            imgDisplay.setEmptyText(tr("Multiple images selected"));
     514            btnFirst.setEnabled(!isFirstImageSelected(entries));
     515            btnLast.setEnabled(!isLastImageSelected(entries));
     516        }
     517        imgDisplay.setImage(null);
     518        imgDisplay.setOsdText("");
     519    }
     520
     521    /**
     522     * Update the image viewer buttons for the new entry
     523     * @param entry The new entry
     524     * @param imageChanged {@code true} if it is not the same image as the previous image.
     525     */
     526    private void updateButtonsNonNullEntry(IImageEntry<?> entry, boolean imageChanged) {
     527        setNextEnabled(entry.getNextImage() != null);
     528        setPreviousEnabled(entry.getPreviousImage() != null);
     529        btnDelete.setEnabled(true);
     530        btnDeleteFromDisk.setEnabled(entry.getFile() != null);
     531        btnCopyPath.setEnabled(true);
     532
     533        if (imageChanged) {
     534            cancelLoadingImage();
     535            // Set only if the image is new to preserve zoom and position if the same image is redisplayed
     536            // (e.g. to update the OSD).
     537            imgLoadingFuture = imgDisplay.setImage(entry);
     538        }
     539        setTitle(tr("Geotagged Images") + (!entry.getDisplayName().isEmpty() ? " - " + entry.getDisplayName() : ""));
     540        StringBuilder osd = new StringBuilder(entry.getDisplayName());
     541        if (entry.getElevation() != null) {
     542            osd.append(tr("\nAltitude: {0} m", Math.round(entry.getElevation())));
     543        }
     544        if (entry.getSpeed() != null) {
     545            osd.append(tr("\nSpeed: {0} km/h", Math.round(entry.getSpeed())));
     546        }
     547        if (entry.getExifImgDir() != null) {
     548            osd.append(tr("\nDirection {0}\u00b0", Math.round(entry.getExifImgDir())));
     549        }
     550
     551        DateTimeFormatter dtf = DateUtils.getDateTimeFormatter(FormatStyle.SHORT, FormatStyle.MEDIUM)
     552                // Set timezone to UTC since UTC is assumed when parsing the EXIF timestamp,
     553                // see see org.openstreetmap.josm.tools.ExifReader.readTime(com.drew.metadata.Metadata)
     554                .withZone(ZoneOffset.UTC);
     555
     556        if (entry.hasExifTime()) {
     557            osd.append(tr("\nEXIF time: {0}", dtf.format(entry.getExifInstant())));
     558        }
     559        if (entry.hasGpsTime()) {
     560            osd.append(tr("\nGPS time: {0}", dtf.format(entry.getGpsInstant())));
     561        }
     562        Optional.ofNullable(entry.getIptcCaption()).map(s -> tr("\nCaption: {0}", s)).ifPresent(osd::append);
     563        Optional.ofNullable(entry.getIptcHeadline()).map(s -> tr("\nHeadline: {0}", s)).ifPresent(osd::append);
     564        Optional.ofNullable(entry.getIptcKeywords()).map(s -> tr("\nKeywords: {0}", s)).ifPresent(osd::append);
     565        Optional.ofNullable(entry.getIptcObjectName()).map(s -> tr("\nObject name: {0}", s)).ifPresent(osd::append);
     566
     567        imgDisplay.setOsdText(osd.toString());
     568    }
     569
     570    /**
     571     * Displays images for the given layer.
     572     * @param ignoredData the image data (unused, may be {@code null})
     573     * @param entries image entries
     574     * @since 15333 (xxx for IImageEntry<?>)
     575     * @deprecated Use {@link #displayImages(List)} (The data param is no longer used)
     576     */
     577    @Deprecated
     578    public void displayImages(ImageData ignoredData, List<IImageEntry<?>> entries) {
     579        this.displayImages(entries);
     580    }
     581
     582    private static boolean isLastImageSelected(List<IImageEntry<?>> data) {
     583        return data.stream().anyMatch(image -> data.contains(image.getLastImage()));
    540584    }
    541585
    542     private static boolean isFirstImageSelected(ImageData data) {
    543         return data.isImageSelected(data.getImages().get(0));
     586    private static boolean isFirstImageSelected(List<IImageEntry<?>> data) {
     587        return data.stream().anyMatch(image -> data.contains(image.getFirstImage()));
    544588    }
    545589
    546590    /**
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    575619    /**
    576620     * Returns the currently displayed image.
    577621     * @return Currently displayed image or {@code null}
    578      * @since 6392
     622     * @since 6392 (xxx for IImageEntry<?>)
    579623     */
    580     public static ImageEntry getCurrentImage() {
     624    public static IImageEntry<?> getCurrentImage() {
    581625        return getInstance().currentEntry;
    582626    }
    583627
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    598642
    599643    @Override
    600644    public void layerRemoving(LayerRemoveEvent e) {
    601         if (e.getRemovedLayer() instanceof GeoImageLayer) {
     645        if (e.getRemovedLayer() instanceof GeoImageLayer && this.currentEntry instanceof ImageEntry) {
    602646            ImageData removedData = ((GeoImageLayer) e.getRemovedLayer()).getImageData();
    603             if (removedData == currentData) {
    604                 displayImages(null, null);
     647            if (removedData == ((ImageEntry) this.currentEntry).getDataSet()) {
     648                displayImages(null);
    605649            }
    606650            removedData.removeImageDataUpdateListener(this);
    607651        }
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    626670    }
    627671
    628672    private void showLayer(Layer newLayer) {
    629         if (currentData == null && newLayer instanceof GeoImageLayer) {
    630             ((GeoImageLayer) newLayer).getImageData().selectFirstImage();
     673        if (this.currentEntry == null && newLayer instanceof GeoImageLayer) {
     674            ImageData imageData = ((GeoImageLayer) newLayer).getImageData();
     675            imageData.setSelectedImage(imageData.getFirstImage());
    631676        }
    632677    }
    633678
    public final class ImageViewerDialog extends ToggleDialog implements LayerChange  
    640685
    641686    @Override
    642687    public void selectedImageChanged(ImageData data) {
    643         displayImages(data, data.getSelectedImages());
     688        displayImages(new ArrayList<>(data.getSelectedImages()));
    644689    }
    645690
    646691    @Override
    647692    public void imageDataUpdated(ImageData data) {
    648         displayImages(data, data.getSelectedImages());
     693        displayImages(new ArrayList<>(data.getSelectedImages()));
    649694    }
    650695}
  • new file src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Equirectangular.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Equirectangular.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Equirectangular.java
    new file mode 100644
    index 0000000000..29ae11ae29
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.geoimage.viewers.projections;
     3
     4import java.awt.Component;
     5import java.awt.Graphics;
     6import java.awt.Image;
     7import java.awt.Point;
     8import java.awt.Rectangle;
     9import java.awt.event.ComponentAdapter;
     10import java.awt.event.ComponentEvent;
     11import java.awt.image.BufferedImage;
     12import java.util.Collections;
     13import java.util.Set;
     14
     15import org.openstreetmap.josm.data.imagery.street_level.Projections;
     16import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;
     17import org.openstreetmap.josm.gui.util.GuiHelper;
     18import org.openstreetmap.josm.gui.util.imagery.CameraPlane;
     19import org.openstreetmap.josm.gui.util.imagery.Vector3D;
     20
     21/**
     22 * A class for showing 360 images that use the equirectangular projection
     23 * @author Taylor Smock
     24 * @since xxx
     25 */
     26public class Equirectangular extends ComponentAdapter implements IImageViewer {
     27    private volatile CameraPlane cameraPlane;
     28    private volatile BufferedImage offscreenImage;
     29
     30    @Override
     31    public Set<Projections> getSupportedProjections() {
     32        return Collections.singleton(Projections.EQUIRECTANGULAR);
     33    }
     34
     35    @Override
     36    public void paintImage(Graphics g, BufferedImage image, Rectangle target, Rectangle visibleRect) {
     37        final CameraPlane currentCameraPlane;
     38        final BufferedImage currentOffscreenImage;
     39        synchronized (this) {
     40            currentCameraPlane = this.cameraPlane;
     41            currentOffscreenImage = this.offscreenImage;
     42        }
     43        currentCameraPlane.mapping(image, currentOffscreenImage);
     44        if (target == null) {
     45            target = new Rectangle(0, 0, currentOffscreenImage.getWidth(null), currentOffscreenImage.getHeight(null));
     46        }
     47        g.drawImage(currentOffscreenImage, target.x, target.y, target.x + target.width, target.y + target.height,
     48                visibleRect.x, visibleRect.y, visibleRect.x + visibleRect.width, visibleRect.y + visibleRect.height,
     49                null);
     50    }
     51
     52    @Override
     53    public ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image) {
     54        return new ImageDisplay.VisRect(0, 0, component.getSize().width, component.getSize().height);
     55    }
     56
     57    @Override
     58    public double getRotation() {
     59        return this.cameraPlane.getRotation().getAzimuthalAngle();
     60    }
     61
     62    @Override
     63    public void componentResized(ComponentEvent e) {
     64        final Component imgDisplay = e.getComponent();
     65        if (e.getComponent().getWidth() > 0
     66                && e.getComponent().getHeight() > 0) {
     67            // FIXME: Do something so that the types of the images are the same between the offscreenImage and
     68            // the image entry
     69            final CameraPlane currentCameraPlane;
     70            synchronized (this) {
     71                currentCameraPlane = this.cameraPlane;
     72            }
     73            final BufferedImage temporaryOffscreenImage = new BufferedImage(imgDisplay.getWidth(), imgDisplay.getHeight(),
     74                    BufferedImage.TYPE_4BYTE_ABGR);
     75
     76            Vector3D currentRotation = null;
     77            if (currentCameraPlane != null) {
     78                currentRotation = currentCameraPlane.getRotation();
     79            }
     80            final CameraPlane temporaryCameraPlane = new CameraPlane(imgDisplay.getWidth(), imgDisplay.getHeight());
     81            if (currentRotation != null) {
     82                temporaryCameraPlane.setRotation(currentRotation);
     83            }
     84            synchronized (this) {
     85                this.cameraPlane = temporaryCameraPlane;
     86                this.offscreenImage = temporaryOffscreenImage;
     87            }
     88            if (imgDisplay instanceof ImageDisplay) {
     89                ((ImageDisplay) imgDisplay).updateVisibleRectangle();
     90            }
     91            GuiHelper.runInEDT(imgDisplay::revalidate);
     92        }
     93    }
     94
     95    @Override
     96    public void mouseDragged(final Point from, final Point to, ImageDisplay.VisRect currentVisibleRect) {
     97        if (from != null && to != null) {
     98            this.cameraPlane.setRotationFromDelta(from, to);
     99        }
     100    }
     101
     102    @Override
     103    public void checkAndModifyVisibleRectSize(Image image, ImageDisplay.VisRect visibleRect) {
     104        IImageViewer.super.checkAndModifyVisibleRectSize(this.offscreenImage, visibleRect);
     105    }
     106
     107    @Override
     108    public Image getMaxImageSize(ImageDisplay imageDisplay, Image image) {
     109        return this.offscreenImage;
     110    }
     111}
  • new file src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java
    new file mode 100644
    index 0000000000..1c97a87b76
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.geoimage.viewers.projections;
     3
     4import java.awt.Component;
     5import java.awt.Graphics;
     6import java.awt.Image;
     7import java.awt.Point;
     8import java.awt.Rectangle;
     9import java.awt.event.ComponentListener;
     10import java.awt.image.BufferedImage;
     11import java.util.Set;
     12
     13import org.openstreetmap.josm.data.imagery.street_level.Projections;
     14import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;
     15
     16/**
     17 * An interface for image viewers for specific projections
     18 * @since xxx
     19 */
     20public interface IImageViewer extends ComponentListener {
     21    /**
     22     * Get the supported projections for the image viewer
     23     * @return The projections supported. Typically, only one.
     24     */
     25    Set<Projections> getSupportedProjections();
     26
     27    /**
     28     * Paint the image
     29     * @param g The graphics to paint on
     30     * @param image The image to paint
     31     * @param target The target area
     32     * @param visibleRect The visible rectangle
     33     */
     34    void paintImage(Graphics g, BufferedImage image, Rectangle target, Rectangle visibleRect);
     35
     36    /**
     37     * Get the default visible rectangle for the projection
     38     * @param component The component the image will be displayed in
     39     * @param image The image that will be shown
     40     * @return The default visible rectangle
     41     */
     42    ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image);
     43
     44    /**
     45     * Get the current rotation in the image viewer
     46     * @return The rotation
     47     */
     48    default double getRotation() {
     49        return 0;
     50    }
     51
     52    /**
     53     * Indicate that the mouse has been dragged to a point
     54     * @param from The point the mouse was dragged from
     55     * @param to The point the mouse has been dragged to
     56     * @param currentVisibleRect The currently visible rectangle (this is updated by the default implementation)
     57     */
     58    default void mouseDragged(Point from, Point to, ImageDisplay.VisRect currentVisibleRect) {
     59        currentVisibleRect.isDragUpdate = true;
     60        currentVisibleRect.x += from.x - to.x;
     61        currentVisibleRect.y += from.y - to.y;
     62    }
     63
     64    /**
     65     * Check and modify the visible rect size to appropriate dimensions
     66     * @param visibleRect the visible rectangle to update
     67     * @param image The image to use for checking
     68     */
     69    default void checkAndModifyVisibleRectSize(Image image, ImageDisplay.VisRect visibleRect) {
     70        if (visibleRect.width > image.getWidth(null)) {
     71            visibleRect.width = image.getWidth(null);
     72        }
     73        if (visibleRect.height > image.getHeight(null)) {
     74            visibleRect.height = image.getHeight(null);
     75        }
     76    }
     77
     78    /**
     79     * Get the maximum image size that can be displayed
     80     * @param imageDisplay The image display
     81     * @param image The image
     82     * @return The maximum image size (may be the original image passed in)
     83     */
     84    default Image getMaxImageSize(ImageDisplay imageDisplay, Image image) {
     85        return image;
     86    }
     87}
  • new file src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/ImageProjectionRegistry.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/ImageProjectionRegistry.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/ImageProjectionRegistry.java
    new file mode 100644
    index 0000000000..24f1f55197
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.geoimage.viewers.projections;
     3
     4import java.util.EnumMap;
     5import java.util.Map;
     6import java.util.Objects;
     7import java.util.stream.Collectors;
     8
     9import org.openstreetmap.josm.data.imagery.street_level.Projections;
     10import org.openstreetmap.josm.tools.JosmRuntimeException;
     11
     12/**
     13 * A class that holds a registry of viewers for image projections
     14 */
     15public final class ImageProjectionRegistry {
     16    private static final EnumMap<Projections, Class<? extends IImageViewer>> DEFAULT_VIEWERS = new EnumMap<>(Projections.class);
     17
     18    // Register the default viewers
     19    static {
     20        try {
     21            registerViewer(Perspective.class);
     22            registerViewer(Equirectangular.class);
     23        } catch (ReflectiveOperationException e) {
     24            throw new JosmRuntimeException(e);
     25        }
     26    }
     27
     28    private ImageProjectionRegistry() {
     29        // Prevent instantiations
     30    }
     31
     32    /**
     33     * Register a new viewer
     34     * @param clazz The class to register. The class <i>must</i> have a no args constructor
     35     * @return {@code true} if something changed
     36     * @throws ReflectiveOperationException if there is no no-args constructor, or it is not visible to us.
     37     */
     38    public static boolean registerViewer(Class<? extends IImageViewer> clazz) throws ReflectiveOperationException {
     39        Objects.requireNonNull(clazz, "null classes are hard to instantiate");
     40        final IImageViewer object = clazz.getConstructor().newInstance();
     41        boolean changed = false;
     42        for (Projections projections : object.getSupportedProjections()) {
     43            changed = clazz.equals(DEFAULT_VIEWERS.put(projections, clazz)) || changed;
     44        }
     45        return changed;
     46    }
     47
     48    /**
     49     * Remove a viewer
     50     * @param clazz The class to remove.
     51     * @return {@code true} if something changed
     52     */
     53    public static boolean removeViewer(Class<? extends IImageViewer> clazz) {
     54        boolean changed = false;
     55        for (Projections projections : DEFAULT_VIEWERS.entrySet().stream()
     56                .filter(entry -> entry.getValue().equals(clazz)).map(Map.Entry::getKey)
     57                .collect(Collectors.toList())) {
     58            changed = DEFAULT_VIEWERS.remove(projections, clazz) || changed;
     59        }
     60        return changed;
     61    }
     62
     63    /**
     64     * Get the viewer for a specific projection type
     65     * @param projection The projection to view
     66     * @return The class to use
     67     */
     68    public static Class<? extends IImageViewer> getViewer(Projections projection) {
     69        return DEFAULT_VIEWERS.getOrDefault(projection, DEFAULT_VIEWERS.getOrDefault(Projections.UNKNOWN, Perspective.class));
     70    }
     71}
  • new file src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java
    new file mode 100644
    index 0000000000..72ed12adc0
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.geoimage.viewers.projections;
     3
     4import java.awt.Component;
     5import java.awt.Graphics;
     6import java.awt.Image;
     7import java.awt.Rectangle;
     8import java.awt.event.ComponentAdapter;
     9import java.awt.image.BufferedImage;
     10import java.util.EnumSet;
     11import java.util.Set;
     12
     13import org.openstreetmap.josm.data.imagery.street_level.Projections;
     14import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;
     15
     16/**
     17 * The default perspective image viewer class.
     18 * This also handles (by default) unknown projections.
     19 */
     20public class Perspective extends ComponentAdapter implements IImageViewer {
     21
     22    @Override
     23    public Set<Projections> getSupportedProjections() {
     24        return EnumSet.of(Projections.PERSPECTIVE);
     25    }
     26
     27    @Override
     28    public void paintImage(Graphics g, BufferedImage image, Rectangle target, Rectangle r) {
     29        g.drawImage(image,
     30                target.x, target.y, target.x + target.width, target.y + target.height,
     31                r.x, r.y, r.x + r.width, r.y + r.height, null);
     32    }
     33
     34    @Override
     35    public ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image) {
     36        return new ImageDisplay.VisRect(0, 0, image.getWidth(null), image.getHeight(null));
     37    }
     38}
  • new file src/org/openstreetmap/josm/gui/util/imagery/CameraPlane.java

    diff --git a/src/org/openstreetmap/josm/gui/util/imagery/CameraPlane.java b/src/org/openstreetmap/josm/gui/util/imagery/CameraPlane.java
    new file mode 100644
    index 0000000000..b4eff0d256
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.util.imagery;
     3
     4import java.awt.Point;
     5import java.awt.geom.Point2D;
     6import java.awt.image.BufferedImage;
     7import java.awt.image.DataBuffer;
     8import java.awt.image.DataBufferDouble;
     9import java.awt.image.DataBufferInt;
     10import java.util.stream.IntStream;
     11import javax.annotation.Nullable;
     12
     13/**
     14 * The plane that the camera appears on and rotates around.
     15 */
     16public class CameraPlane {
     17    /** The field of view for the panorama at 0 zoom */
     18    static final double PANORAMA_FOV = Math.toRadians(110);
     19
     20    /** This determines the yaw direction. We may want to make it a config option, but maybe not */
     21    private static final byte YAW_DIRECTION = -1;
     22
     23    /** The width of the image */
     24    private final int width;
     25    /** The height of the image */
     26    private final int height;
     27
     28    private final Vector3D[][] vectors;
     29    private Vector3D rotation;
     30
     31    public static final double HALF_PI = Math.PI / 2;
     32    public static final double TWO_PI = 2 * Math.PI;
     33
     34    /**
     35     * Create a new CameraPlane with the default FOV (110 degrees).
     36     *
     37     * @param width The width of the image
     38     * @param height The height of the image
     39     */
     40    public CameraPlane(int width, int height) {
     41        this(width, height, (width / 2d) / Math.tan(PANORAMA_FOV / 2));
     42    }
     43
     44    /**
     45     * Create a new CameraPlane
     46     *
     47     * @param width The width of the image
     48     * @param height The height of the image
     49     * @param distance The radial distance of the photosphere
     50     */
     51    private CameraPlane(int width, int height, double distance) {
     52        this.width = width;
     53        this.height = height;
     54        this.rotation = new Vector3D(Vector3D.VectorType.RPA, distance, 0, 0);
     55        this.vectors = new Vector3D[width][height];
     56        IntStream.range(0, this.height).parallel().forEach(y -> IntStream.range(0, this.width).parallel()
     57            .forEach(x -> this.vectors[x][y] = this.getVector3D((double) x, y)));
     58    }
     59
     60    /**
     61     * Get the width of the image
     62     * @return The width of the image
     63     */
     64    public int getWidth() {
     65        return this.width;
     66    }
     67
     68    /**
     69     * Get the height of the image
     70     * @return The height of the image
     71     */
     72    public int getHeight() {
     73        return this.height;
     74    }
     75
     76    /**
     77     * Get the point for a vector
     78     *
     79     * @param vector the vector for which the corresponding point on the camera plane will be returned
     80     * @return the point on the camera plane to which the given vector is mapped, nullable
     81     */
     82    @Nullable
     83    public Point getPoint(final Vector3D vector) {
     84        final Vector3D rotatedVector = rotate(vector, -1);
     85        // Currently set to false due to change in painting
     86        if (rotatedVector.getZ() < 0) {
     87            // Ignores any points "behind the back", so they don't get painted a second time on the other
     88            // side of the sphere
     89            return null;
     90        }
     91        // This is a slightly faster than just doing the (brute force) method of Math.max(Math.min)). Reduces if
     92        // statements by 1 per call.
     93        final long x = Math
     94            .round((rotatedVector.getX() / rotatedVector.getZ()) * this.rotation.getRadialDistance() + width / 2d);
     95        final long y = Math
     96            .round((rotatedVector.getY() / rotatedVector.getZ()) * this.rotation.getRadialDistance() + height / 2d);
     97
     98        try {
     99            return new Point(Math.toIntExact(x), Math.toIntExact(y));
     100        } catch (ArithmeticException e) {
     101            return new Point((int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, x)),
     102                (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, y)));
     103        }
     104    }
     105
     106    /**
     107     * Convert a point to a 3D vector
     108     *
     109     * @param p The point to convert
     110     * @return The vector
     111     */
     112    public Vector3D getVector3D(final Point p) {
     113        return this.getVector3D(p.x, p.y);
     114    }
     115
     116    /**
     117     * Convert a point to a 3D vector (vectors are cached)
     118     *
     119     * @param x The x coordinate
     120     * @param y The y coordinate
     121     * @return The vector
     122     */
     123    public Vector3D getVector3D(final int x, final int y) {
     124        Vector3D res;
     125        try {
     126            res = rotate(vectors[x][y]);
     127        } catch (Exception e) {
     128            res = Vector3D.DEFAULT_VECTOR_3D;
     129        }
     130        return res;
     131    }
     132
     133    /**
     134     * Convert a point to a 3D vector. Warning: This method does not cache.
     135     *
     136     * @param x The x coordinate
     137     * @param y The y coordinate
     138     * @return The vector (the middle of the image is 0, 0)
     139     */
     140    public Vector3D getVector3D(final double x, final double y) {
     141        return new Vector3D(x - width / 2d, y - height / 2d, this.rotation.getRadialDistance()).normalize();
     142    }
     143
     144    /**
     145     * Set camera plane rotation by current plane position.
     146     *
     147     * @param p Point within current plane.
     148     */
     149    public void setRotation(final Point p) {
     150        setRotation(getVector3D(p));
     151    }
     152
     153    /**
     154     * Set the rotation from the difference of two points
     155     *
     156     * @param from The originating point
     157     * @param to The new point
     158     */
     159    public void setRotationFromDelta(final Point from, final Point to) {
     160        // Bound check (bounds are essentially the image viewer component)
     161        if (from.x < 0 || from.y < 0 || to.x < 0 || to.y < 0
     162            || from.x > this.vectors.length || from.y > this.vectors[0].length
     163            || to.x > this.vectors.length || to.y > this.vectors[0].length) {
     164            return;
     165        }
     166        Vector3D f1 = this.vectors[from.x][from.y];
     167        Vector3D t1 = this.vectors[to.x][to.y];
     168        double deltaPolarAngle = f1.getPolarAngle() - t1.getPolarAngle();
     169        double deltaAzimuthalAngle = t1.getAzimuthalAngle() - f1.getAzimuthalAngle();
     170        double polarAngle = this.rotation.getPolarAngle() + deltaPolarAngle;
     171        double azimuthalAngle = this.rotation.getAzimuthalAngle() + deltaAzimuthalAngle;
     172        this.setRotation(azimuthalAngle, polarAngle);
     173    }
     174
     175    /**
     176     * Set camera plane rotation by spherical vector.
     177     *
     178     * @param vec vector pointing new view position.
     179     */
     180    public void setRotation(Vector3D vec) {
     181        setRotation(vec.getPolarAngle(), vec.getAzimuthalAngle());
     182    }
     183
     184    public Vector3D getRotation() {
     185        return this.rotation;
     186    }
     187
     188    synchronized void setRotation(double azimuthalAngle, double polarAngle) {
     189        // Note: Something, somewhere, is switching the two.
     190        // FIXME: Figure out what is switching them and why
     191        // Prevent us from going much outside 2pi
     192        if (polarAngle < 0) {
     193            polarAngle = polarAngle + TWO_PI;
     194        } else if (polarAngle > TWO_PI) {
     195            polarAngle = polarAngle - TWO_PI;
     196        }
     197        // Avoid flipping the camera
     198        if (azimuthalAngle > HALF_PI) {
     199            azimuthalAngle = HALF_PI;
     200        } else if (azimuthalAngle < -HALF_PI) {
     201            azimuthalAngle = -HALF_PI;
     202        }
     203        this.rotation = new Vector3D(Vector3D.VectorType.RPA, this.rotation.getRadialDistance(), polarAngle, azimuthalAngle);
     204    }
     205
     206    private Vector3D rotate(final Vector3D vec) {
     207        return rotate(vec, 1);
     208    }
     209
     210    /**
     211     * Rotate a vector using the current rotation
     212     * @param vec The vector to rotate
     213     * @param rotationFactor Used to determine if using left hand rule or right hand rule (1 for RHR)
     214     * @return A rotated vector
     215     */
     216    private Vector3D rotate(final Vector3D vec, final int rotationFactor) {
     217        // @formatting:off
     218        /* Full rotation matrix for a yaw-pitch-roll
     219         * yaw = alpha, pitch = beta, roll = gamma (typical representations)
     220         * [cos(alpha), -sin(alpha), 0 ]   [cos(beta), 0, sin(beta) ]   [1,     0     ,     0      ]   [x]   [x1]
     221         * |sin(alpha), cos(alpha), 0  | . |0        , 1, 0         | . |0, cos(gamma), -sin(gamma)| . |y| = |y1|
     222         * [0         ,       0    , 1 ]   [-sin(beta), 0, cos(beta)]   [0, sin(gamma), cos(gamma) ]   [z]   [z1]
     223         * which becomes
     224         * x1 = y(cos(alpha)sin(beta)sin(gamma) - sin(alpha)cos(gamma)) + z(cos(alpha)sin(beta)cos(gamma) + sin(alpha)sin(gamma))
     225         *      + x cos(alpha)cos(beta)
     226         * y1 = y(sin(alpha)sin(beta)sin(gamma) + cos(alpha)cos(gamma)) + z(sin(alpha)sin(beta)cos(gamma) - cos(alpha)sin(gamma))
     227         *      + x sin(alpha)cos(beta)
     228         * z1 = y cos(beta)sin(gamma) + z cos(beta)cos(gamma) - x sin(beta)
     229         */
     230        // @formatting:on
     231        double vecX;
     232        double vecY;
     233        double vecZ;
     234        // We only do pitch/roll (we specifically do not do roll -- this would lead to tilting the image)
     235        // So yaw (alpha) -> azimuthalAngle, pitch (beta) -> polarAngle, roll (gamma) -> 0 (sin(gamma) -> 0, cos(gamma) -> 1)
     236        // gamma is set here just to make it slightly easier to tilt images in the future -- we just have to set the gamma somewhere else.
     237        // Ironically enough, the alpha (yaw) and gama (roll) got reversed somewhere. TODO figure out where and fix this.
     238        final int gamma = 0;
     239        final double sinAlpha = Math.sin(gamma);
     240        final double cosAlpha = Math.cos(gamma);
     241        final double cosGamma = this.rotation.getAzimuthalAngleCos();
     242        final double sinGamma = this.rotation.getAzimuthalAngleSin();
     243        final double cosBeta = this.rotation.getPolarAngleCos();
     244        final double sinBeta = this.rotation.getPolarAngleSin();
     245        final double x = vec.getX();
     246        final double y = YAW_DIRECTION * vec.getY();
     247        final double z = vec.getZ();
     248        vecX = y * (cosAlpha * sinBeta * sinGamma - sinAlpha * cosGamma)
     249                + z * (cosAlpha * sinBeta * cosGamma + sinAlpha * sinGamma) + x * cosAlpha * cosBeta;
     250        vecY = y * (sinAlpha * sinBeta * sinGamma + cosAlpha * cosGamma)
     251                + z * (sinAlpha * sinBeta * cosGamma - cosAlpha * sinGamma) + x * sinAlpha * cosBeta;
     252        vecZ = y * cosBeta * sinGamma + z * cosBeta * cosGamma - x * sinBeta;
     253        return new Vector3D(vecX, YAW_DIRECTION * vecY, vecZ);
     254    }
     255
     256    public void mapping(BufferedImage sourceImage, BufferedImage targetImage) {
     257        DataBuffer sourceBuffer = sourceImage.getRaster().getDataBuffer();
     258        DataBuffer targetBuffer = targetImage.getRaster().getDataBuffer();
     259        // Faster mapping
     260        if (sourceBuffer.getDataType() == DataBuffer.TYPE_INT && targetBuffer.getDataType() == DataBuffer.TYPE_INT) {
     261            int[] sourceImageBuffer = ((DataBufferInt) sourceImage.getRaster().getDataBuffer()).getData();
     262            int[] targetImageBuffer = ((DataBufferInt) targetImage.getRaster().getDataBuffer()).getData();
     263            IntStream.range(0, targetImage.getHeight()).parallel()
     264                    .forEach(y -> IntStream.range(0, targetImage.getWidth()).forEach(x -> {
     265                        final Point2D.Double p = mapPoint(x, y);
     266                        int tx = (int) (p.x * (sourceImage.getWidth() - 1));
     267                        int ty = (int) (p.y * (sourceImage.getHeight() - 1));
     268                        int color = sourceImageBuffer[ty * sourceImage.getWidth() + tx];
     269                        targetImageBuffer[y * targetImage.getWidth() + x] = color;
     270                    }));
     271        } else if (sourceBuffer.getDataType() == DataBuffer.TYPE_DOUBLE && targetBuffer.getDataType() == DataBuffer.TYPE_DOUBLE) {
     272            double[] sourceImageBuffer = ((DataBufferDouble) sourceImage.getRaster().getDataBuffer()).getData();
     273            double[] targetImageBuffer = ((DataBufferDouble) targetImage.getRaster().getDataBuffer()).getData();
     274            IntStream.range(0, targetImage.getHeight()).parallel()
     275                    .forEach(y -> IntStream.range(0, targetImage.getWidth()).forEach(x -> {
     276                        final Point2D.Double p = mapPoint(x, y);
     277                        int tx = (int) (p.x * (sourceImage.getWidth() - 1));
     278                        int ty = (int) (p.y * (sourceImage.getHeight() - 1));
     279                        double color = sourceImageBuffer[ty * sourceImage.getWidth() + tx];
     280                        targetImageBuffer[y * targetImage.getWidth() + x] = color;
     281                    }));
     282        } else {
     283            IntStream.range(0, targetImage.getHeight()).parallel()
     284                .forEach(y -> IntStream.range(0, targetImage.getWidth()).parallel().forEach(x -> {
     285                    final Point2D.Double p = mapPoint(x, y);
     286                    targetImage.setRGB(x, y, sourceImage.getRGB((int) (p.x * (sourceImage.getWidth() - 1)),
     287                        (int) (p.y * (sourceImage.getHeight() - 1))));
     288                }));
     289        }
     290    }
     291
     292    /**
     293     * Map a real point to the displayed point. This method uses cached vectors.
     294     * @param x The original x coordinate
     295     * @param y The original y coordinate
     296     * @return The scaled (0-1) point in the image. Use {@code p.x * (image.getWidth() - 1)} or {@code p.y * image.getHeight() - 1}.
     297     */
     298    public final Point2D.Double mapPoint(final int x, final int y) {
     299        final Vector3D vec = getVector3D(x, y);
     300        return UVMapping.getTextureCoordinate(vec);
     301    }
     302
     303    /**
     304     * Map a real point to the displayed point. This function does not use cached vectors.
     305     * @param x The original x coordinate
     306     * @param y The original y coordinate
     307     * @return The scaled (0-1) point in the image. Use {@code p.x * (image.getWidth() - 1)} or {@code p.y * image.getHeight() - 1}.
     308     */
     309    public final Point2D.Double mapPoint(final double x, final double y) {
     310        final Vector3D vec = getVector3D(x, y);
     311        return UVMapping.getTextureCoordinate(vec);
     312    }
     313}
  • new file src/org/openstreetmap/josm/gui/util/imagery/UVMapping.java

    diff --git a/src/org/openstreetmap/josm/gui/util/imagery/UVMapping.java b/src/org/openstreetmap/josm/gui/util/imagery/UVMapping.java
    new file mode 100644
    index 0000000000..1a4c7ab064
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.util.imagery;
     3
     4import java.awt.geom.Point2D;
     5
     6/**
     7 * A utility class for mapping a point onto a spherical coordinate system and vice versa
     8 * @since xxx
     9 */
     10public final class UVMapping {
     11    private static final double TWO_PI = 2 * Math.PI;
     12    private UVMapping() {
     13        // Private constructor to avoid instantiation
     14    }
     15
     16    /**
     17     * Returns the point of the texture image that is mapped to the given point in 3D space (given as {@link Vector3D})
     18     * See <a href="https://en.wikipedia.org/wiki/UV_mapping">the Wikipedia article on UV mapping</a>.
     19     *
     20     * @param vector the vector to which the texture point is mapped
     21     * @return a point on the texture image somewhere in the rectangle between (0, 0) and (1, 1)
     22     */
     23    public static Point2D.Double getTextureCoordinate(final Vector3D vector) {
     24        final double u = 0.5 + (Math.atan2(vector.getX(), vector.getZ()) / TWO_PI);
     25        final double v = 0.5 + (Math.asin(vector.getY()) / Math.PI);
     26        return new Point2D.Double(u, v);
     27    }
     28
     29    /**
     30     * For a given point of the texture (i.e. the image), return the point in 3D space where the point
     31     * of the texture is mapped to (as {@link Vector3D}).
     32     *
     33     * @param u x-coordinate of the point on the texture (in the range between 0 and 1, from left to right)
     34     * @param v y-coordinate of the point on the texture (in the range between 0 and 1, from top to bottom)
     35     * @return the vector from the origin to where the point of the texture is mapped on the sphere
     36     */
     37    public static Vector3D getVector(final double u, final double v) {
     38        if (u > 1 || u < 0 || v > 1 || v < 0) {
     39            throw new IllegalArgumentException("u and v must be between or equal to 0 and 1");
     40        }
     41        final double vectorY = Math.cos(v * Math.PI);
     42        final double vectorYSquared = Math.pow(vectorY, 2);
     43        return new Vector3D(-Math.sin(TWO_PI * u) * Math.sqrt(1 - vectorYSquared), -vectorY,
     44            -Math.cos(TWO_PI * u) * Math.sqrt(1 - vectorYSquared));
     45    }
     46}
  • new file src/org/openstreetmap/josm/gui/util/imagery/Vector3D.java

    diff --git a/src/org/openstreetmap/josm/gui/util/imagery/Vector3D.java b/src/org/openstreetmap/josm/gui/util/imagery/Vector3D.java
    new file mode 100644
    index 0000000000..ba98b7d884
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.util.imagery;
     3
     4import javax.annotation.concurrent.Immutable;
     5
     6/**
     7 * A basic 3D vector class
     8 * @author Taylor Smock (documentation, spherical conversions)
     9 * @since xxx
     10 */
     11@Immutable
     12public final class Vector3D {
     13    /**
     14     * This determines how arguments are used in {@link Vector3D#Vector3D(VectorType, double, double, double)}.
     15     */
     16    public enum VectorType {
     17        /** Standard cartesian coordinates (x, y, z) */
     18        XYZ,
     19        /** Physics (radial distance, polar angle, azimuthal angle) */
     20        RPA,
     21        /** Mathematics (radial distance, azimuthal angle, polar angle) */
     22        RAP
     23    }
     24
     25    /** A non-null default vector */
     26    public static final Vector3D DEFAULT_VECTOR_3D = new Vector3D(0, 0, 1);
     27
     28    private final double x;
     29    private final double y;
     30    private final double z;
     31    /* The following are all lazily calculated, but should always be the same */
     32    /** The radius r */
     33    private volatile double radialDistance = Double.NaN;
     34    /** The polar angle theta (inclination) */
     35    private volatile double polarAngle = Double.NaN;
     36    /** Cosine of polar angle (angle from Z axis, AKA straight up) */
     37    private volatile double polarAngleCos = Double.NaN;
     38    /** Sine of polar angle (angle from Z axis, AKA straight up) */
     39    private volatile double polarAngleSin = Double.NaN;
     40    /** The azimuthal angle phi */
     41    private volatile double azimuthalAngle = Double.NaN;
     42    /** Cosine of azimuthal angle (angle from X axis) */
     43    private volatile double azimuthalAngleCos = Double.NaN;
     44    /** Sine of azimuthal angle (angle from X axis) */
     45    private volatile double azimuthalAngleSin = Double.NaN;
     46
     47    /**
     48     * Create a new Vector3D object using the XYZ coordinate system
     49     *
     50     * @param x The x coordinate
     51     * @param y The y coordinate
     52     * @param z The z coordinate
     53     */
     54    public Vector3D(double x, double y, double z) {
     55        this(VectorType.XYZ, x, y, z);
     56    }
     57
     58    /**
     59     * Create a new Vector3D object. See ordering in {@link VectorType}.
     60     *
     61     * @param first The first coordinate
     62     * @param second The second coordinate
     63     * @param third The third coordinate
     64     * @param vectorType The coordinate type (determines how the other variables are treated)
     65     */
     66    public Vector3D(VectorType vectorType, double first, double second, double third) {
     67        if (vectorType == VectorType.XYZ) {
     68            this.x = first;
     69            this.y = second;
     70            this.z = third;
     71        } else {
     72            this.radialDistance = first;
     73            if (vectorType == VectorType.RPA) {
     74                this.azimuthalAngle = third;
     75                this.polarAngle = second;
     76            } else {
     77                this.azimuthalAngle = second;
     78                this.polarAngle = third;
     79            }
     80            // Since we have to run the calculations anyway, ensure they are cached.
     81            this.x = this.radialDistance * this.getAzimuthalAngleCos() * this.getPolarAngleSin();
     82            this.y = this.radialDistance * this.getAzimuthalAngleSin() * this.getPolarAngleSin();
     83            this.z = this.radialDistance * this.getPolarAngleCos();
     84        }
     85    }
     86
     87    /**
     88     * Get the x coordinate
     89     *
     90     * @return The x coordinate
     91     */
     92    public double getX() {
     93        return x;
     94    }
     95
     96    /**
     97     * Get the y coordinate
     98     *
     99     * @return The y coordinate
     100     */
     101    public double getY() {
     102        return y;
     103    }
     104
     105    /**
     106     * Get the z coordinate
     107     *
     108     * @return The z coordinate
     109     */
     110    public double getZ() {
     111        return z;
     112    }
     113
     114    /**
     115     * Get the radius
     116     *
     117     * @return The radius
     118     */
     119    public double getRadialDistance() {
     120        if (Double.isNaN(this.radialDistance)) {
     121            this.radialDistance = Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2) + Math.pow(this.z, 2));
     122        }
     123        return this.radialDistance;
     124    }
     125
     126    /**
     127     * Get the polar angle (inclination)
     128     *
     129     * @return The polar angle
     130     */
     131    public double getPolarAngle() {
     132        if (Double.isNaN(this.polarAngle)) {
     133            // This was Math.atan(x, z) in the Mapillary plugin
     134            // This should be Math.atan(y, z)
     135            this.polarAngle = Math.atan2(this.x, this.z);
     136        }
     137        return this.polarAngle;
     138    }
     139
     140    /**
     141     * Get the polar angle cossine (inclination)
     142     *
     143     * @return The polar angle cosine
     144     */
     145    public double getPolarAngleCos() {
     146        if (Double.isNaN(this.polarAngleCos)) {
     147            this.polarAngleCos = Math.cos(this.getPolarAngle());
     148        }
     149        return this.polarAngleCos;
     150    }
     151
     152    /**
     153     * Get the polar angle sine (inclination)
     154     *
     155     * @return The polar angle sine
     156     */
     157    public double getPolarAngleSin() {
     158        if (Double.isNaN(this.polarAngleSin)) {
     159            this.polarAngleSin = Math.sin(this.getPolarAngle());
     160        }
     161        return this.polarAngleSin;
     162    }
     163
     164    /**
     165     * Get the azimuthal angle
     166     *
     167     * @return The azimuthal angle
     168     */
     169    public double getAzimuthalAngle() {
     170        if (Double.isNaN(this.azimuthalAngle)) {
     171            if (Double.isNaN(this.radialDistance)) {
     172                // Force calculation
     173                this.getRadialDistance();
     174            }
     175            // Avoid issues where x, y, and z are 0
     176            if (this.radialDistance == 0) {
     177                this.azimuthalAngle = 0;
     178            } else {
     179                // This was Math.acos(y / radialDistance) in the Mapillary plugin
     180                // This should be Math.acos(z / radialDistance)
     181                this.azimuthalAngle = Math.acos(this.y / this.radialDistance);
     182            }
     183        }
     184        return this.azimuthalAngle;
     185    }
     186
     187    /**
     188     * Get the azimuthal angle cosine
     189     *
     190     * @return The azimuthal angle cosine
     191     */
     192    public double getAzimuthalAngleCos() {
     193        if (Double.isNaN(this.azimuthalAngleCos)) {
     194            this.azimuthalAngleCos = Math.cos(this.getAzimuthalAngle());
     195        }
     196        return this.azimuthalAngleCos;
     197    }
     198
     199    /**
     200     * Get the azimuthal angle sine
     201     *
     202     * @return The azimuthal angle sine
     203     */
     204    public double getAzimuthalAngleSin() {
     205        if (Double.isNaN(this.azimuthalAngleSin)) {
     206            this.azimuthalAngleSin = Math.sin(this.getAzimuthalAngle());
     207        }
     208        return this.azimuthalAngleSin;
     209    }
     210
     211    /**
     212     * Normalize the vector
     213     *
     214     * @return A normalized vector
     215     */
     216    public Vector3D normalize() {
     217        final double length = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2) + Math.pow(z, 2));
     218        final double newX;
     219        final double newY;
     220        final double newZ;
     221        if (length == 0 || Double.isNaN(length)) {
     222            newX = 0;
     223            newY = 0;
     224            newZ = 0;
     225        } else {
     226            newX = x / length;
     227            newY = y / length;
     228            newZ = z / length;
     229        }
     230        return new Vector3D(newX, newY, newZ);
     231    }
     232
     233    @Override
     234    public int hashCode() {
     235        return Double.hashCode(this.x) + 31 * Double.hashCode(this.y) + 31 * 31 * Double.hashCode(this.z);
     236    }
     237
     238    @Override
     239    public boolean equals(Object o) {
     240        if (o instanceof Vector3D) {
     241            Vector3D other = (Vector3D) o;
     242            return this.x == other.x && this.y == other.y && this.z == other.z;
     243        }
     244        return false;
     245    }
     246
     247    @Override
     248    public String toString() {
     249        return "[x=" + this.x + ", y=" + this.y + ", z=" + this.z + ", r=" + this.radialDistance + ", inclination="
     250            + this.polarAngle + ", azimuthal=" + this.azimuthalAngle + "]";
     251    }
     252}
  • new file test/unit/org/openstreetmap/josm/gui/util/imagery/CameraPlaneTest.java

    diff --git a/test/unit/org/openstreetmap/josm/gui/util/imagery/CameraPlaneTest.java b/test/unit/org/openstreetmap/josm/gui/util/imagery/CameraPlaneTest.java
    new file mode 100644
    index 0000000000..9ba3970471
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.util.imagery;
     3
     4import static org.junit.jupiter.api.Assertions.assertAll;
     5import static org.junit.jupiter.api.Assertions.assertEquals;
     6
     7import java.awt.Point;
     8import java.awt.geom.Point2D;
     9import java.util.stream.Stream;
     10
     11import org.junit.jupiter.api.BeforeEach;
     12import org.junit.jupiter.api.Test;
     13import org.junit.jupiter.params.ParameterizedTest;
     14import org.junit.jupiter.params.provider.Arguments;
     15import org.junit.jupiter.params.provider.MethodSource;
     16
     17class CameraPlaneTest {
     18
     19    private static final int CAMERA_PLANE_WIDTH = 800;
     20    private static final int CAMERA_PLANE_HEIGHT = 600;
     21
     22    private CameraPlane cameraPlane;
     23
     24    @BeforeEach
     25    void setUp() {
     26        this.cameraPlane = new CameraPlane(CAMERA_PLANE_WIDTH, CAMERA_PLANE_HEIGHT);
     27    }
     28
     29    @Test
     30    void testSetRotation() {
     31        Vector3D vec = new Vector3D(0, 0, 1);
     32        cameraPlane.setRotation(vec);
     33        Vector3D out = cameraPlane.getRotation();
     34        assertAll(() -> assertEquals(280.0830152838839, out.getRadialDistance(), 0.001),
     35            () -> assertEquals(0, out.getPolarAngle(), 0.001), () -> assertEquals(0, out.getAzimuthalAngle(), 0.001));
     36    }
     37
     38    @Test
     39    void testGetVector3D() {
     40        Vector3D vec = new Vector3D(0, 0, 1);
     41        cameraPlane.setRotation(vec);
     42        Vector3D out = cameraPlane.getVector3D(new Point(CAMERA_PLANE_WIDTH / 2, CAMERA_PLANE_HEIGHT / 2));
     43        assertAll(() -> assertEquals(0.0, out.getX(), 1.0E-04), () -> assertEquals(0.0, out.getY(), 1.0E-04),
     44            () -> assertEquals(1.0, out.getZ(), 1.0E-04));
     45    }
     46
     47    static Stream<Arguments> testGetVector3DFloat() {
     48        return Stream
     49            .of(Arguments.of(new Vector3D(0, 0, 1), new Point(CAMERA_PLANE_WIDTH / 2, CAMERA_PLANE_HEIGHT / 2)));
     50    }
     51
     52    /**
     53     * This tests a method which does not cache, and more importantly, is what is used to create the sphere.
     54     * The vector is normalized.
     55     * (0, 0) is the center of the image
     56     *
     57     * @param expected The expected vector
     58     * @param toCheck The point to check
     59     */
     60    @ParameterizedTest
     61    @MethodSource
     62    void testGetVector3DFloat(final Vector3D expected, final Point toCheck) {
     63        Vector3D out = cameraPlane.getVector3D(toCheck.getX(), toCheck.getY());
     64        assertAll(() -> assertEquals(expected.getX(), out.getX(), 1.0E-04),
     65            () -> assertEquals(expected.getY(), out.getY(), 1.0E-04),
     66            () -> assertEquals(expected.getZ(), out.getZ(), 1.0E-04), () -> assertEquals(1,
     67                Math.sqrt(Math.pow(out.getX(), 2) + Math.pow(out.getY(), 2) + Math.pow(out.getZ(), 2)), 1.0E-04));
     68    }
     69
     70    @Test
     71    void testMapping() {
     72        Vector3D vec = new Vector3D(0, 0, 1);
     73        cameraPlane.setRotation(vec);
     74        Vector3D out = cameraPlane.getVector3D(new Point(300, 200));
     75        Point2D map = UVMapping.getTextureCoordinate(out);
     76        assertAll(() -> assertEquals(0.44542099, map.getX(), 1e-8), () -> assertEquals(0.39674936, map.getY(), 1e-8));
     77    }
     78}
  • new file test/unit/org/openstreetmap/josm/gui/util/imagery/UVMappingTest.java

    diff --git a/test/unit/org/openstreetmap/josm/gui/util/imagery/UVMappingTest.java b/test/unit/org/openstreetmap/josm/gui/util/imagery/UVMappingTest.java
    new file mode 100644
    index 0000000000..5cefd64834
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.util.imagery;
     3import static org.junit.jupiter.api.Assertions.assertAll;
     4import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
     5import static org.junit.jupiter.api.Assertions.assertEquals;
     6import static org.junit.jupiter.api.Assertions.assertThrows;
     7
     8import java.awt.geom.Point2D;
     9import java.util.stream.Stream;
     10
     11import org.junit.jupiter.params.ParameterizedTest;
     12import org.junit.jupiter.params.provider.Arguments;
     13import org.junit.jupiter.params.provider.MethodSource;
     14import org.junit.jupiter.params.provider.ValueSource;
     15
     16/**
     17 * A test class for {@link UVMapping}
     18 */
     19class UVMappingTest {
     20    private static final double DEFAULT_DELTA = 1e-5;
     21
     22    static Stream<Arguments> testMapping() {
     23        return Stream.of(Arguments.of(0.5, 1, 0, 1, 0),
     24                Arguments.of(0.5, 0, 0, -1, 0),
     25                Arguments.of(0.25, 0.5, -1, 0, 0),
     26                Arguments.of(0.5, 0.5, 0, 0, 1),
     27                Arguments.of(0.75, 0.5, 1, 0, 0),
     28                Arguments.of(1, 0.5, 0, 0, -1),
     29                Arguments.of(0.125, 0.25, -0.5, -1 / Math.sqrt(2), -0.5),
     30                Arguments.of(0.625, 0.75, 0.5, 1 / Math.sqrt(2), 0.5)
     31                );
     32    }
     33
     34    /**
     35     * Test that UV mapping is reversible for the sphere
     36     * @param px The x for the point
     37     * @param py The y for the point
     38     * @param x The x portion of the vector
     39     * @param y The y portion of the vector
     40     * @param z The z portion of the vector
     41     */
     42    @ParameterizedTest
     43    @MethodSource
     44    void testMapping(final double px, final double py, final double x, final double y, final double z) {
     45        // The mapping must be reversible
     46        assertAll(() -> assertPointEquals(new Point2D.Double(px, py), UVMapping.getTextureCoordinate(new Vector3D(x, y, z))),
     47                () -> assertVectorEquals(new Vector3D(x, y, z), UVMapping.getVector(px, py)));
     48    }
     49
     50    @ParameterizedTest
     51    @ValueSource(floats = {0, 1, 1.1f, 0.9f})
     52    void testGetVectorEdgeCases(final float location) {
     53        if (location < 0 || location > 1) {
     54            assertAll(() -> assertThrows(IllegalArgumentException.class, () -> UVMapping.getVector(location, 0.5)),
     55                    () -> assertThrows(IllegalArgumentException.class, () -> UVMapping.getVector(0.5, location)));
     56        } else {
     57            assertAll(() -> assertDoesNotThrow(() -> UVMapping.getVector(location, 0.5)),
     58                    () -> assertDoesNotThrow(() -> UVMapping.getVector(0.5, location)));
     59        }
     60    }
     61
     62    private static void assertVectorEquals(final Vector3D expected, final Vector3D actual) {
     63        final String message = String.format("Expected (%f %f %f), but was (%f %f %f)", expected.getX(),
     64                expected.getY(), expected.getZ(), actual.getX(), actual.getY(), actual.getZ());
     65        assertEquals(expected.getX(), actual.getX(), DEFAULT_DELTA, message);
     66        assertEquals(expected.getY(), actual.getY(), DEFAULT_DELTA, message);
     67        assertEquals(expected.getZ(), actual.getZ(), DEFAULT_DELTA, message);
     68    }
     69
     70    private static void assertPointEquals(final Point2D expected, final Point2D actual) {
     71        final String message = String.format("Expected (%f, %f), but was (%f, %f)", expected.getX(), expected.getY(),
     72                actual.getX(), actual.getY());
     73        assertEquals(expected.getX(), actual.getX(), DEFAULT_DELTA, message);
     74        assertEquals(expected.getY(), actual.getY(), DEFAULT_DELTA, message);
     75    }
     76}
  • new file test/unit/org/openstreetmap/josm/gui/util/imagery/Vector3DTest.java

    diff --git a/test/unit/org/openstreetmap/josm/gui/util/imagery/Vector3DTest.java b/test/unit/org/openstreetmap/josm/gui/util/imagery/Vector3DTest.java
    new file mode 100644
    index 0000000000..5f360956fc
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.util.imagery;
     3
     4import static org.junit.jupiter.api.Assertions.assertAll;
     5import static org.junit.jupiter.api.Assertions.assertEquals;
     6import static org.junit.jupiter.api.Assertions.fail;
     7
     8import java.util.stream.Stream;
     9
     10import org.junit.jupiter.api.Disabled;
     11import org.junit.jupiter.params.ParameterizedTest;
     12import org.junit.jupiter.params.provider.Arguments;
     13import org.junit.jupiter.params.provider.MethodSource;
     14
     15/**
     16 * Test class for {@link Vector3D}
     17 * @author Taylor Smock
     18 */
     19class Vector3DTest {
     20
     21    static Stream<Arguments> vectorInformation() {
     22        return Stream.of(
     23            Arguments.of(0, 0, 0, 0),
     24            Arguments.of(1, 1, 1, Math.sqrt(3)),
     25            Arguments.of(-1, -1, -1, Math.sqrt(3)),
     26            Arguments.of(-2, 2, -2, Math.sqrt(12))
     27        );
     28    }
     29
     30    @ParameterizedTest
     31    @MethodSource("vectorInformation")
     32    void getX(final double x, final double y, final double z) {
     33        final Vector3D vector3D = new Vector3D(x, y, z);
     34        assertEquals(x, vector3D.getX());
     35    }
     36
     37    @ParameterizedTest
     38    @MethodSource("vectorInformation")
     39    void getY(final double x, final double y, final double z) {
     40        final Vector3D vector3D = new Vector3D(x, y, z);
     41        assertEquals(y, vector3D.getY());
     42    }
     43
     44    @ParameterizedTest
     45    @MethodSource("vectorInformation")
     46    void getZ(final double x, final double y, final double z) {
     47        final Vector3D vector3D = new Vector3D(x, y, z);
     48        assertEquals(z, vector3D.getZ());
     49    }
     50
     51    @ParameterizedTest
     52    @MethodSource("vectorInformation")
     53    void getRadialDistance(final double x, final double y, final double z, final double radialDistance) {
     54        final Vector3D vector3D = new Vector3D(x, y, z);
     55        assertEquals(radialDistance, vector3D.getRadialDistance());
     56    }
     57
     58    @ParameterizedTest
     59    @MethodSource("vectorInformation")
     60    @Disabled("Angle calculations may be corrected")
     61    void getPolarAngle() {
     62        fail("Not yet implemented");
     63    }
     64
     65    @ParameterizedTest
     66    @MethodSource("vectorInformation")
     67    @Disabled("Angle calculations may be corrected")
     68    void getAzimuthalAngle() {
     69        fail("Not yet implemented");
     70    }
     71
     72    @ParameterizedTest
     73    @MethodSource("vectorInformation")
     74    void normalize(final double x, final double y, final double z) {
     75        final Vector3D vector3D = new Vector3D(x, y, z);
     76        final Vector3D normalizedVector = vector3D.normalize();
     77        assertAll(() -> assertEquals(vector3D.getRadialDistance() == 0 ? 0 : 1, normalizedVector.getRadialDistance()),
     78                () -> assertEquals(vector3D.getPolarAngle(), normalizedVector.getPolarAngle()),
     79                () -> assertEquals(vector3D.getAzimuthalAngle(), normalizedVector.getAzimuthalAngle()));
     80    }
     81
     82    @ParameterizedTest
     83    @MethodSource("vectorInformation")
     84    @Disabled("Angle calculations may be corrected")
     85    void testToString() {
     86        fail("Not yet implemented");
     87    }
     88}