Ticket #16472: 16472.6.patch

File 16472.6.patch, 123.9 KB (added by taylor.smock, 4 years ago)

Update attachment:16472.4.patch

  • 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..c5b8d2b5fb 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  
    477484        return Objects.hash(height, width, isNewGpsData,
    478485            elevation, exifCoor, exifGpsTime, exifImgDir, exifOrientation, exifTime,
    479486            iptcCaption, iptcHeadline, iptcKeywords, iptcObjectName,
    480             file, gpsTime, pos, speed, tmp);
     487            file, gpsTime, pos, speed, tmp, cameraProjection);
    481488    }
    482489
    483490    @Override
    public class GpxImageEntry implements Comparable<GpxImageEntry>, IQuadBucketType  
    504511            && Objects.equals(gpsTime, other.gpsTime)
    505512            && Objects.equals(pos, other.pos)
    506513            && Objects.equals(speed, other.speed)
    507             && Objects.equals(tmp, other.tmp);
     514            && Objects.equals(tmp, other.tmp)
     515                && cameraProjection == other.cameraProjection;
    508516    }
    509517
    510518    /**
    public class GpxImageEntry implements Comparable<GpxImageEntry>, IQuadBucketType  
    753761            ifNotNull(ExifReader.readKeywords(dirIptc), this::setIptcKeywords);
    754762            ifNotNull(ExifReader.readObjectName(dirIptc), this::setIptcObjectName);
    755763        }
     764
     765        for (XmpDirectory xmpDirectory : metadata.getDirectoriesOfType(XmpDirectory.class)) {
     766            Map<String, String> properties = xmpDirectory.getXmpProperties();
     767            final String projectionType = "GPano:ProjectionType";
     768            if (properties.containsKey(projectionType)) {
     769                Stream.of(Projections.values()).filter(p -> p.name().equalsIgnoreCase(properties.get(projectionType)))
     770                        .findFirst().ifPresent(projection -> this.cameraProjection = projection);
     771                break;
     772            }
     773        }
     774    }
     775
     776    /**
     777     * Reads the image represented by this entry in the given target dimension.
     778     * @param target the desired dimension used for {@linkplain IIOParam#setSourceSubsampling subsampling} or {@code null}
     779     * @return the read image, or {@code null}
     780     * @throws IOException if any I/O error occurs
     781     */
     782    public BufferedImage read(Dimension target) throws IOException {
     783        throw new UnsupportedOperationException("read not implemented for " + this.getClass().getSimpleName());
    756784    }
    757785
    758786    private static class NoMetadataReaderWarning extends Exception {
    public class GpxImageEntry implements Comparable<GpxImageEntry>, IQuadBucketType  
    767795        }
    768796    }
    769797
     798    /**
     799     * Get the projection type for this entry
     800     * @return The projection type
     801     */
     802    public Projections getProjectionType() {
     803        return this.cameraProjection;
     804    }
     805
    770806    /**
    771807     * Returns a {@link WayPoint} representation of this GPX image entry.
    772808     * @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/GeoImageLayer.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/GeoImageLayer.java b/src/org/openstreetmap/josm/gui/layer/geoimage/GeoImageLayer.java
    index 0c5c8c29ae..55e5d98bc1 100644
    a b public class GeoImageLayer extends AbstractModifiableLayer implements  
    867867
    868868    @Override
    869869    public void jumpToNextMarker() {
    870         data.selectNextImage();
     870        data.setSelectedImage(data.getNextImage());
    871871    }
    872872
    873873    @Override
    874874    public void jumpToPreviousMarker() {
    875         data.selectPreviousImage();
     875        data.setSelectedImage(data.getPreviousImage());
    876876    }
    877877
    878878    /**
  • 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 abe21ec0a1..fa54f6b86e 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;
    4245import org.openstreetmap.josm.tools.Utils;
    4346
    import org.openstreetmap.josm.tools.Utils;  
    4952 */
    5053public class ImageDisplay extends JComponent implements Destroyable, PreferenceChangedListener, FilterChangeListener {
    5154
     55    /** The current image viewer */
     56    private IImageViewer iImageViewer;
     57
    5258    /** The file that is currently displayed */
    53     private ImageEntry entry;
     59    private IImageEntry<?> entry;
    5460
    5561    /** The previous file that is currently displayed. Cleared on paint. Only used to help improve UI error information. */
    56     private ImageEntry oldEntry;
     62    private IImageEntry<?> oldEntry;
    5763
    5864    /** The image currently displayed */
    5965    private transient BufferedImage image;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    261267    /** The thread that reads the images. */
    262268    protected class LoadImageRunnable implements Runnable {
    263269
    264         private final ImageEntry entry;
     270        private final IImageEntry<?> entry;
    265271
    266         LoadImageRunnable(ImageEntry entry) {
     272        LoadImageRunnable(IImageEntry<?> entry) {
    267273            this.entry = entry;
    268274        }
    269275
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    295301                    updateProcessedImage();
    296302                    // This will clear the loading info box
    297303                    ImageDisplay.this.oldEntry = ImageDisplay.this.entry;
    298                     visibleRect = new VisRect(0, 0, width, height);
     304                    visibleRect = getIImageViewer(entry).getDefaultVisibleRectangle(ImageDisplay.this, image);
    299305
    300306                    selectedRect = null;
    301307                    errorLoading = false;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    307313        }
    308314    }
    309315
    310     private class ImgDisplayMouseListener implements MouseListener, MouseWheelListener, MouseMotionListener {
     316    private class ImgDisplayMouseListener extends MouseAdapter {
    311317
    312318        private MouseEvent lastMouseEvent;
    313319        private Point mousePointInImg;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    330336        }
    331337
    332338        private void mouseWheelMovedImpl(int x, int y, int rotation, boolean refreshMousePointInImg) {
    333             ImageEntry entry;
    334             Image image;
    335             VisRect visibleRect;
     339            IImageEntry<?> currentEntry;
     340            IImageViewer imageViewer;
     341            Image currentImage;
     342            VisRect currentVisibleRect;
    336343
    337344            synchronized (ImageDisplay.this) {
    338                 entry = ImageDisplay.this.entry;
    339                 image = ImageDisplay.this.image;
    340                 visibleRect = ImageDisplay.this.visibleRect;
     345                currentEntry = ImageDisplay.this.entry;
     346                currentImage = ImageDisplay.this.image;
     347                currentVisibleRect = ImageDisplay.this.visibleRect;
     348                imageViewer = ImageDisplay.this.iImageViewer;
    341349            }
    342350
    343351            selectedRect = null;
    344352
    345             if (image == null)
     353            if (currentImage == null)
    346354                return;
    347355
    348356            // Calculate the mouse cursor position in image coordinates to center the zoom.
    349357            if (refreshMousePointInImg)
    350                 mousePointInImg = comp2imgCoord(visibleRect, x, y, getSize());
     358                mousePointInImg = comp2imgCoord(currentVisibleRect, x, y, getSize());
    351359
    352360            // Apply the zoom to the visible rectangle in image coordinates
    353361            if (rotation > 0) {
    354                 visibleRect.width = (int) (visibleRect.width * ZOOM_STEP.get());
    355                 visibleRect.height = (int) (visibleRect.height * ZOOM_STEP.get());
     362                currentVisibleRect.width = (int) (currentVisibleRect.width * ZOOM_STEP.get());
     363                currentVisibleRect.height = (int) (currentVisibleRect.height * ZOOM_STEP.get());
    356364            } else {
    357                 visibleRect.width = (int) (visibleRect.width / ZOOM_STEP.get());
    358                 visibleRect.height = (int) (visibleRect.height / ZOOM_STEP.get());
     365                currentVisibleRect.width = (int) (currentVisibleRect.width / ZOOM_STEP.get());
     366                currentVisibleRect.height = (int) (currentVisibleRect.height / ZOOM_STEP.get());
    359367            }
    360368
    361369            // Check that the zoom doesn't exceed MAX_ZOOM:1
    362             if (visibleRect.width < getSize().width / MAX_ZOOM.get()) {
    363                 visibleRect.width = (int) (getSize().width / MAX_ZOOM.get());
    364             }
    365             if (visibleRect.height < getSize().height / MAX_ZOOM.get()) {
    366                 visibleRect.height = (int) (getSize().height / MAX_ZOOM.get());
    367             }
     370            ensureMaxZoom(currentVisibleRect);
    368371
    369             // Set the same ratio for the visible rectangle and the display area
    370             int hFact = visibleRect.height * getSize().width;
    371             int wFact = visibleRect.width * getSize().height;
    372             if (hFact > wFact) {
    373                 visibleRect.width = hFact / getSize().height;
     372            // The size of the visible rectangle is limited by the image size or the viewer implementation.
     373            if (imageViewer != null) {
     374                imageViewer.checkAndModifyVisibleRectSize(currentImage, currentVisibleRect);
    374375            } else {
    375                 visibleRect.height = wFact / getSize().width;
     376                currentVisibleRect.checkRectSize();
    376377            }
    377378
    378             // The size of the visible rectangle is limited by the image size.
    379             visibleRect.checkRectSize();
    380 
    381379            // Set the position of the visible rectangle, so that the mouse cursor doesn't move on the image.
    382             Rectangle drawRect = calculateDrawImageRectangle(visibleRect, getSize());
    383             visibleRect.x = mousePointInImg.x + ((drawRect.x - x) * visibleRect.width) / drawRect.width;
    384             visibleRect.y = mousePointInImg.y + ((drawRect.y - y) * visibleRect.height) / drawRect.height;
     380            Rectangle drawRect = calculateDrawImageRectangle(currentVisibleRect, getSize());
     381            currentVisibleRect.x = mousePointInImg.x + ((drawRect.x - x) * currentVisibleRect.width) / drawRect.width;
     382            currentVisibleRect.y = mousePointInImg.y + ((drawRect.y - y) * currentVisibleRect.height) / drawRect.height;
    385383
    386384            // The position is also limited by the image size
    387             visibleRect.checkRectPos();
     385            currentVisibleRect.checkRectPos();
    388386
    389387            synchronized (ImageDisplay.this) {
    390                 if (ImageDisplay.this.entry == entry) {
    391                     ImageDisplay.this.visibleRect = visibleRect;
     388                if (ImageDisplay.this.entry == currentEntry) {
     389                    ImageDisplay.this.visibleRect = currentVisibleRect;
    392390                }
    393391            }
    394392            ImageDisplay.this.repaint();
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    416414        @Override
    417415        public void mouseClicked(MouseEvent e) {
    418416            // Move the center to the clicked point.
    419             ImageEntry entry;
    420             Image image;
    421             VisRect visibleRect;
     417            IImageEntry<?> currentEntry;
     418            Image currentImage;
     419            VisRect currentVisibleRect;
    422420
    423421            synchronized (ImageDisplay.this) {
    424                 entry = ImageDisplay.this.entry;
    425                 image = ImageDisplay.this.image;
    426                 visibleRect = ImageDisplay.this.visibleRect;
     422                currentEntry = ImageDisplay.this.entry;
     423                currentImage = ImageDisplay.this.image;
     424                currentVisibleRect = ImageDisplay.this.visibleRect;
    427425            }
    428426
    429             if (image == null)
     427            if (currentImage == null)
    430428                return;
    431429
    432430            if (ZOOM_ON_CLICK.get()) {
    433431                // click notions are less coherent than wheel, refresh mousePointInImg on each click
    434432                lastMouseEvent = null;
    435433
    436                 if (mouseIsZoomSelecting(e) && !isAtMaxZoom(visibleRect)) {
     434                if (mouseIsZoomSelecting(e) && !isAtMaxZoom(currentVisibleRect)) {
    437435                    // zoom in if clicked with the zoom button
    438436                    mouseWheelMovedImpl(e.getX(), e.getY(), -1, true);
    439437                    return;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    446444            }
    447445
    448446            // Calculate the translation to set the clicked point the center of the view.
    449             Point click = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
    450             Point center = getCenterImgCoord(visibleRect);
     447            Point click = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
     448            Point center = getCenterImgCoord(currentVisibleRect);
    451449
    452             visibleRect.x += click.x - center.x;
    453             visibleRect.y += click.y - center.y;
     450            currentVisibleRect.x += click.x - center.x;
     451            currentVisibleRect.y += click.y - center.y;
    454452
    455             visibleRect.checkRectPos();
     453            currentVisibleRect.checkRectPos();
    456454
    457455            synchronized (ImageDisplay.this) {
    458                 if (ImageDisplay.this.entry == entry) {
    459                     ImageDisplay.this.visibleRect = visibleRect;
     456                if (ImageDisplay.this.entry == currentEntry) {
     457                    ImageDisplay.this.visibleRect = currentVisibleRect;
    460458                }
    461459            }
    462460            ImageDisplay.this.repaint();
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    466464         * a picture part) */
    467465        @Override
    468466        public void mousePressed(MouseEvent e) {
    469             Image image;
    470             VisRect visibleRect;
     467            Image currentImage;
     468            VisRect currentVisibleRect;
    471469
    472470            synchronized (ImageDisplay.this) {
    473                 image = ImageDisplay.this.image;
    474                 visibleRect = ImageDisplay.this.visibleRect;
     471                currentImage = ImageDisplay.this.image;
     472                currentVisibleRect = ImageDisplay.this.visibleRect;
    475473            }
    476474
    477             if (image == null)
     475            if (currentImage == null)
    478476                return;
    479477
    480478            selectedRect = null;
    481479
    482480            if (mouseIsDragging(e) || mouseIsZoomSelecting(e))
    483                 mousePointInImg = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
     481                mousePointInImg = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
    484482        }
    485483
    486484        @Override
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    488486            if (!mouseIsDragging(e) && !mouseIsZoomSelecting(e))
    489487                return;
    490488
    491             ImageEntry entry;
    492             Image image;
    493             VisRect visibleRect;
     489            IImageEntry<?> imageEntry;
     490            Image currentImage;
     491            VisRect currentVisibleRect;
    494492
    495493            synchronized (ImageDisplay.this) {
    496                 entry = ImageDisplay.this.entry;
    497                 image = ImageDisplay.this.image;
    498                 visibleRect = ImageDisplay.this.visibleRect;
     494                imageEntry = ImageDisplay.this.entry;
     495                currentImage = ImageDisplay.this.image;
     496                currentVisibleRect = ImageDisplay.this.visibleRect;
    499497            }
    500498
    501             if (image == null)
     499            if (currentImage == null)
    502500                return;
    503501
    504502            if (mouseIsDragging(e) && mousePointInImg != null) {
    505                 Point p = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
    506                 visibleRect.isDragUpdate = true;
    507                 visibleRect.x += mousePointInImg.x - p.x;
    508                 visibleRect.y += mousePointInImg.y - p.y;
    509                 visibleRect.checkRectPos();
     503                Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
     504                getIImageViewer(entry).mouseDragged(this.mousePointInImg, p, currentVisibleRect);
     505                currentVisibleRect.checkRectPos();
    510506                synchronized (ImageDisplay.this) {
    511                     if (ImageDisplay.this.entry == entry) {
    512                         ImageDisplay.this.visibleRect = visibleRect;
     507                    if (ImageDisplay.this.entry == imageEntry) {
     508                        ImageDisplay.this.visibleRect = currentVisibleRect;
    513509                    }
    514510                }
     511                // We have to update the mousePointInImg for 360 image panning, as otherwise the panning
     512                // never stops.
     513                // This does not work well with the perspective viewer at this time (2021-08-26).
     514                if (entry != null && Projections.EQUIRECTANGULAR == entry.getProjectionType()) {
     515                    this.mousePointInImg = p;
     516                }
    515517                ImageDisplay.this.repaint();
    516518            }
    517519
    518520            if (mouseIsZoomSelecting(e) && mousePointInImg != null) {
    519                 Point p = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
    520                 visibleRect.checkPointInside(p);
    521                 VisRect selectedRect = new VisRect(
    522                         p.x < mousePointInImg.x ? p.x : mousePointInImg.x,
    523                         p.y < mousePointInImg.y ? p.y : mousePointInImg.y,
     521                Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
     522                currentVisibleRect.checkPointInside(p);
     523                VisRect selectedRectTemp = new VisRect(
     524                        Math.min(p.x, mousePointInImg.x),
     525                        Math.min(p.y, mousePointInImg.y),
    524526                        p.x < mousePointInImg.x ? mousePointInImg.x - p.x : p.x - mousePointInImg.x,
    525527                        p.y < mousePointInImg.y ? mousePointInImg.y - p.y : p.y - mousePointInImg.y,
    526                         visibleRect);
    527                 selectedRect.checkRectSize();
    528                 selectedRect.checkRectPos();
    529                 ImageDisplay.this.selectedRect = selectedRect;
     528                        currentVisibleRect);
     529                selectedRectTemp.checkRectSize();
     530                selectedRectTemp.checkRectPos();
     531                ImageDisplay.this.selectedRect = selectedRectTemp;
    530532                ImageDisplay.this.repaint();
    531533            }
    532 
    533534        }
    534535
    535536        @Override
    536537        public void mouseReleased(MouseEvent e) {
    537             ImageEntry entry;
    538             Image image;
    539             VisRect visibleRect;
     538            IImageEntry<?> currentEntry;
     539            Image currentImage;
     540            VisRect currentVisibleRect;
    540541
    541542            synchronized (ImageDisplay.this) {
    542                 entry = ImageDisplay.this.entry;
    543                 image = ImageDisplay.this.image;
    544                 visibleRect = ImageDisplay.this.visibleRect;
     543                currentEntry = ImageDisplay.this.entry;
     544                currentImage = ImageDisplay.this.image;
     545                currentVisibleRect = ImageDisplay.this.visibleRect;
    545546            }
    546547
    547             if (image == null)
     548            if (currentImage == null)
    548549                return;
    549550
    550551            if (mouseIsDragging(e)) {
    551                 visibleRect.isDragUpdate = false;
     552                currentVisibleRect.isDragUpdate = false;
    552553            }
    553554
    554555            if (mouseIsZoomSelecting(e) && selectedRect != null) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    556557                int oldHeight = selectedRect.height;
    557558
    558559                // Check that the zoom doesn't exceed MAX_ZOOM:1
    559                 if (selectedRect.width < getSize().width / MAX_ZOOM.get()) {
    560                     selectedRect.width = (int) (getSize().width / MAX_ZOOM.get());
    561                 }
    562                 if (selectedRect.height < getSize().height / MAX_ZOOM.get()) {
    563                     selectedRect.height = (int) (getSize().height / MAX_ZOOM.get());
    564                 }
    565 
    566                 // Set the same ratio for the visible rectangle and the display area
    567                 int hFact = selectedRect.height * getSize().width;
    568                 int wFact = selectedRect.width * getSize().height;
    569                 if (hFact > wFact) {
    570                     selectedRect.width = hFact / getSize().height;
    571                 } else {
    572                     selectedRect.height = wFact / getSize().width;
    573                 }
     560                ensureMaxZoom(selectedRect);
    574561
    575562                // Keep the center of the selection
    576563                if (selectedRect.width != oldWidth) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    585572            }
    586573
    587574            synchronized (ImageDisplay.this) {
    588                 if (entry == ImageDisplay.this.entry) {
     575                if (currentEntry == ImageDisplay.this.entry) {
    589576                    if (selectedRect == null) {
    590                         ImageDisplay.this.visibleRect = visibleRect;
     577                        ImageDisplay.this.visibleRect = currentVisibleRect;
    591578                    } else {
    592579                        ImageDisplay.this.visibleRect.setBounds(selectedRect);
    593580                        selectedRect = null;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    596583            }
    597584            ImageDisplay.this.repaint();
    598585        }
    599 
    600         @Override
    601         public void mouseEntered(MouseEvent e) {
    602             // Do nothing
    603         }
    604 
    605         @Override
    606         public void mouseExited(MouseEvent e) {
    607             // Do nothing
    608         }
    609 
    610         @Override
    611         public void mouseMoved(MouseEvent e) {
    612             // Do nothing
    613         }
    614586    }
    615587
    616588    /**
    617589     * Constructs a new {@code ImageDisplay} with no image processor.
    618590     */
    619591    public ImageDisplay() {
    620         this(image -> image);
     592        this(imageObject -> imageObject);
    621593    }
    622594
    623595    /**
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    652624     * Sets a new source image to be displayed by this {@code ImageDisplay}.
    653625     * @param entry new source image
    654626     * @return a {@link Future} representing pending completion of the image loading task
    655      * @since 18150
     627     * @since 18150 (xxx for IImageEntry)
    656628     */
    657     public Future<?> setImage(ImageEntry entry) {
     629    public Future<?> setImage(IImageEntry<?> entry) {
    658630        LoadImageRunnable runnable = setImage0(entry);
    659631        return runnable != null ? MainApplication.worker.submit(runnable) : null;
    660632    }
    661633
    662     protected LoadImageRunnable setImage0(ImageEntry entry) {
     634    protected LoadImageRunnable setImage0(IImageEntry<?> entry) {
    663635        synchronized (this) {
    664636            this.oldEntry = this.entry;
    665637            this.entry = entry;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    707679
    708680    private void updateProcessedImage() {
    709681        processedImage = image == null ? null : imageProcessor.process(image);
    710         GuiHelper.runInEDT(() -> repaint());
     682        GuiHelper.runInEDT(this::repaint);
    711683    }
    712684
    713685    @Override
    714686    public void paintComponent(Graphics g) {
    715687        super.paintComponent(g);
    716688
    717         ImageEntry entry;
    718         ImageEntry oldEntry;
    719         BufferedImage image;
    720         VisRect visibleRect;
    721         boolean errorLoading;
     689        IImageEntry<?> currentEntry;
     690        IImageEntry<?> currentOldEntry;
     691        IImageViewer currentImageViewer;
     692        BufferedImage currentImage;
     693        VisRect currentVisibleRect;
     694        boolean currentErrorLoading;
    722695
    723696        synchronized (this) {
    724             image = this.processedImage;
    725             entry = this.entry;
    726             oldEntry = this.oldEntry;
    727             visibleRect = this.visibleRect;
    728             errorLoading = this.errorLoading;
     697            currentImage = this.processedImage;
     698            currentEntry = this.entry;
     699            currentOldEntry = this.oldEntry;
     700            currentVisibleRect = this.visibleRect;
     701            currentErrorLoading = this.errorLoading;
    729702        }
    730703
    731704        if (g instanceof Graphics2D) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    734707
    735708        Dimension size = getSize();
    736709        // Draw the image first, then draw error information
    737         if (image != null && (entry != null || oldEntry != null)) {
    738             Rectangle r = new Rectangle(visibleRect);
    739             Rectangle target = calculateDrawImageRectangle(visibleRect, size);
    740 
    741             g.drawImage(image,
    742                     target.x, target.y, target.x + target.width, target.y + target.height,
    743                     r.x, r.y, r.x + r.width, r.y + r.height, null);
    744 
    745             if (selectedRect != null) {
    746                 Point topLeft = img2compCoord(visibleRect, selectedRect.x, selectedRect.y, size);
    747                 Point bottomRight = img2compCoord(visibleRect,
    748                         selectedRect.x + selectedRect.width,
    749                         selectedRect.y + selectedRect.height, size);
    750                 g.setColor(new Color(128, 128, 128, 180));
    751                 g.fillRect(target.x, target.y, target.width, topLeft.y - target.y);
    752                 g.fillRect(target.x, target.y, topLeft.x - target.x, target.height);
    753                 g.fillRect(bottomRight.x, target.y, target.x + target.width - bottomRight.x, target.height);
    754                 g.fillRect(target.x, bottomRight.y, target.width, target.y + target.height - bottomRight.y);
    755                 g.setColor(Color.black);
    756                 g.drawRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
    757             }
    758             if (errorLoading && entry != null) {
    759                 String loadingStr = tr("Error on file {0}", entry.getDisplayName());
     710        if (currentImage != null && (currentEntry != null || currentOldEntry != null)) {
     711            currentImageViewer = this.getIImageViewer(currentEntry);
     712            Rectangle r = new Rectangle(currentVisibleRect);
     713            Rectangle target = calculateDrawImageRectangle(currentVisibleRect, size);
     714
     715            currentImageViewer.paintImage(g, currentImage, target, r);
     716            paintSelectedRect(g, target, currentVisibleRect, size);
     717            if (currentErrorLoading && currentEntry != null) {
     718                String loadingStr = tr("Error on file {0}", currentEntry.getDisplayName());
    760719                Rectangle2D noImageSize = g.getFontMetrics(g.getFont()).getStringBounds(loadingStr, g);
    761                 g.drawString(loadingStr,
    762                         (int) ((size.width - noImageSize.getWidth()) / 2),
     720                g.drawString(loadingStr, (int) ((size.width - noImageSize.getWidth()) / 2),
    763721                        (int) ((size.height - noImageSize.getHeight()) / 2));
    764722            }
    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             }
     723            paintOsdText(g);
    793724        }
     725        paintErrorMessage(g, currentEntry, currentOldEntry, currentImage, currentErrorLoading, size);
     726    }
     727
     728    /**
     729     * Paint an error message
     730     * @param g The graphics to paint on
     731     * @param imageEntry The current image entry
     732     * @param oldImageEntry The old image entry
     733     * @param bufferedImage The image being painted
     734     * @param currentErrorLoading If there was an error loading the image
     735     * @param size The size of the component
     736     */
     737    private void paintErrorMessage(Graphics g, IImageEntry<?> imageEntry, IImageEntry<?> oldImageEntry,
     738            BufferedImage bufferedImage, boolean currentErrorLoading, Dimension size) {
    794739        final String errorMessage;
    795740        // If the new entry is null, then there is no image.
    796         if (entry == null) {
     741        if (imageEntry == null) {
    797742            if (emptyText == null) {
    798743                emptyText = tr("No image");
    799744            }
    800745            errorMessage = emptyText;
    801         } else if (image == null || !Objects.equals(entry, oldEntry)) {
     746        } else if (bufferedImage == null || !Objects.equals(imageEntry, oldImageEntry)) {
    802747            // The image is not necessarily null when loading anymore. If the oldEntry is not the same as the new entry,
    803748            // we are probably still loading the image. (oldEntry gets set to entry when the image finishes loading).
    804             if (!errorLoading) {
    805                 errorMessage = tr("Loading {0}", entry.getDisplayName());
     749            if (!currentErrorLoading) {
     750                errorMessage = tr("Loading {0}", imageEntry.getDisplayName());
    806751            } else {
    807                 errorMessage = tr("Error on file {0}", entry.getDisplayName());
     752                errorMessage = tr("Error on file {0}", imageEntry.getDisplayName());
    808753            }
    809754        } else {
    810755            errorMessage = null;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    830775        }
    831776    }
    832777
     778    /**
     779     * Paint OSD text
     780     * @param g The graphics to paint on
     781     */
     782    private void paintOsdText(Graphics g) {
     783        if (osdText != null) {
     784            FontMetrics metrics = g.getFontMetrics(g.getFont());
     785            int ascent = metrics.getAscent();
     786            Color bkground = new Color(255, 255, 255, 128);
     787            int lastPos = 0;
     788            int pos = osdText.indexOf('\n');
     789            int x = 3;
     790            int y = 3;
     791            String line;
     792            while (pos > 0) {
     793                line = osdText.substring(lastPos, pos);
     794                Rectangle2D lineSize = metrics.getStringBounds(line, g);
     795                g.setColor(bkground);
     796                g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
     797                g.setColor(Color.black);
     798                g.drawString(line, x, y + ascent);
     799                y += (int) lineSize.getHeight();
     800                lastPos = pos + 1;
     801                pos = osdText.indexOf('\n', lastPos);
     802            }
     803
     804            line = osdText.substring(lastPos);
     805            Rectangle2D lineSize = g.getFontMetrics(g.getFont()).getStringBounds(line, g);
     806            g.setColor(bkground);
     807            g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
     808            g.setColor(Color.black);
     809            g.drawString(line, x, y + ascent);
     810        }
     811    }
     812
     813    /**
     814     * Paint the selected rectangle
     815     * @param g The graphics to paint on
     816     * @param target The target area (i.e., the selection)
     817     * @param visibleRectTemp The current visible rect
     818     * @param size The size of the component
     819     */
     820    private void paintSelectedRect(Graphics g, Rectangle target, VisRect visibleRectTemp, Dimension size) {
     821        if (selectedRect != null) {
     822            Point topLeft = img2compCoord(visibleRectTemp, selectedRect.x, selectedRect.y, size);
     823            Point bottomRight = img2compCoord(visibleRectTemp,
     824                    selectedRect.x + selectedRect.width,
     825                    selectedRect.y + selectedRect.height, size);
     826            g.setColor(new Color(128, 128, 128, 180));
     827            g.fillRect(target.x, target.y, target.width, topLeft.y - target.y);
     828            g.fillRect(target.x, target.y, topLeft.x - target.x, target.height);
     829            g.fillRect(bottomRight.x, target.y, target.x + target.width - bottomRight.x, target.height);
     830            g.fillRect(target.x, bottomRight.y, target.width, target.y + target.height - bottomRight.y);
     831            g.setColor(Color.black);
     832            g.drawRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
     833        }
     834    }
     835
    833836    static Point img2compCoord(VisRect visibleRect, int xImg, int yImg, Dimension compSize) {
    834837        Rectangle drawRect = calculateDrawImageRectangle(visibleRect, compSize);
    835838        return new Point(drawRect.x + ((xImg - visibleRect.x) * drawRect.width) / visibleRect.width,
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    853856                         visibleRect.y + visibleRect.height / 2);
    854857    }
    855858
     859    /**
     860     * calculateDrawImageRectangle
     861     *
     862     * @param visibleRect the part of the image that should be drawn (in image coordinates)
     863     * @param compSize the part of the component where the image should be drawn (in component coordinates)
     864     * @return the part of compRect with the same width/height ratio as the image
     865     */
    856866    static VisRect calculateDrawImageRectangle(VisRect visibleRect, Dimension compSize) {
    857867        return calculateDrawImageRectangle(visibleRect, new Rectangle(0, 0, compSize.width, compSize.height));
    858868    }
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    906916     * the component size.
    907917     */
    908918    public void zoomBestFitOrOne() {
    909         ImageEntry entry;
    910         Image image;
    911         VisRect visibleRect;
     919        IImageEntry<?> currentEntry;
     920        Image currentImage;
     921        VisRect currentVisibleRect;
    912922
    913923        synchronized (this) {
    914             entry = this.entry;
    915             image = this.image;
    916             visibleRect = this.visibleRect;
     924            currentEntry = this.entry;
     925            currentImage = this.image;
     926            currentVisibleRect = this.visibleRect;
    917927        }
    918928
    919         if (image == null)
     929        if (currentImage == null)
    920930            return;
    921931
    922         if (visibleRect.width != image.getWidth(null) || visibleRect.height != image.getHeight(null)) {
     932        if (currentVisibleRect.width != currentImage.getWidth(null) || currentVisibleRect.height != currentImage.getHeight(null)) {
    923933            // The display is not at best fit. => Zoom to best fit
    924             visibleRect.reset();
     934            currentVisibleRect.reset();
    925935        } else {
    926936            // The display is at best fit => zoom to 1:1
    927             Point center = getCenterImgCoord(visibleRect);
    928             visibleRect.setBounds(center.x - getWidth() / 2, center.y - getHeight() / 2,
     937            Point center = getCenterImgCoord(currentVisibleRect);
     938            currentVisibleRect.setBounds(center.x - getWidth() / 2, center.y - getHeight() / 2,
    929939                    getWidth(), getHeight());
    930             visibleRect.checkRectSize();
    931             visibleRect.checkRectPos();
     940            currentVisibleRect.checkRectSize();
     941            currentVisibleRect.checkRectPos();
    932942        }
    933943
    934944        synchronized (this) {
    935             if (this.entry == entry) {
    936                 this.visibleRect = visibleRect;
     945            if (this.entry == currentEntry) {
     946                this.visibleRect = currentVisibleRect;
    937947            }
    938948        }
    939949        repaint();
    940950    }
     951
     952    /**
     953     * Get the image viewer for an entry
     954     * @param entry The entry to get the viewer for. May be {@code null}.
     955     * @return The new image viewer, may be {@code null}
     956     */
     957    private IImageViewer getIImageViewer(IImageEntry<?> entry) {
     958        IImageViewer imageViewer;
     959        IImageEntry<?> imageEntry;
     960        synchronized (this) {
     961            imageViewer = this.iImageViewer;
     962            imageEntry = entry == null ? this.entry : entry;
     963        }
     964        if (imageEntry == null || (imageViewer != null && imageViewer.getSupportedProjections().contains(imageEntry.getProjectionType()))) {
     965            return imageViewer;
     966        }
     967        try {
     968            imageViewer = ImageProjectionRegistry.getViewer(imageEntry.getProjectionType()).getConstructor().newInstance();
     969        } catch (ReflectiveOperationException e) {
     970            throw new JosmRuntimeException(e);
     971        }
     972        synchronized (this) {
     973            if (imageEntry.equals(this.entry)) {
     974                this.removeComponentListener(this.iImageViewer);
     975                this.iImageViewer = imageViewer;
     976                imageViewer.componentResized(new ComponentEvent(this, ComponentEvent.COMPONENT_RESIZED));
     977                this.addComponentListener(this.iImageViewer);
     978            }
     979        }
     980        return imageViewer;
     981    }
     982
     983    /**
     984     * Ensure that a rectangle isn't zoomed in too much
     985     * @param rectangle The rectangle to get (typically the visible area)
     986     */
     987    private void ensureMaxZoom(final Rectangle rectangle) {
     988        if (rectangle.width < getSize().width / MAX_ZOOM.get()) {
     989            rectangle.width = (int) (getSize().width / MAX_ZOOM.get());
     990        }
     991        if (rectangle.height < getSize().height / MAX_ZOOM.get()) {
     992            rectangle.height = (int) (getSize().height / MAX_ZOOM.get());
     993        }
     994
     995        // Set the same ratio for the visible rectangle and the display area
     996        int hFact = rectangle.height * getSize().width;
     997        int wFact = rectangle.width * getSize().height;
     998        if (hFact > wFact) {
     999            rectangle.width = hFact / getSize().height;
     1000        } else {
     1001            rectangle.height = wFact / getSize().width;
     1002        }
     1003    }
     1004
     1005    /**
     1006     * Update the visible rectangle (ensure zoom does not exceed specified values).
     1007     * Specifically only visible for {@link IImageViewer} implementations.
     1008     * @since xxx
     1009     */
     1010    public void updateVisibleRectangle() {
     1011        final VisRect currentVisibleRect;
     1012        final Image mouseImage;
     1013        final IImageViewer iImageViewer;
     1014        synchronized (this) {
     1015            currentVisibleRect = this.visibleRect;
     1016            mouseImage = this.image;
     1017            iImageViewer = this.getIImageViewer(this.entry);
     1018        }
     1019        if (mouseImage != null && currentVisibleRect != null && iImageViewer != null) {
     1020            final Image maxImageSize = iImageViewer.getMaxImageSize(this, mouseImage);
     1021            final VisRect maxVisibleRect = new VisRect(0, 0, maxImageSize.getWidth(null), maxImageSize.getHeight(null));
     1022            maxVisibleRect.setRect(currentVisibleRect);
     1023            ensureMaxZoom(maxVisibleRect);
     1024
     1025            maxVisibleRect.checkRectSize();
     1026            synchronized (this) {
     1027                this.visibleRect = maxVisibleRect;
     1028            }
     1029        }
     1030    }
    9411031}
  • 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..d5a94886b1 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}
    141196     * @return the read image, or {@code null}
    142197     * @throws IOException if any I/O error occurs
    143198     */
     199    @Override
    144200    public BufferedImage read(Dimension target) throws IOException {
    145201        URL imageUrl = getImageUrl();
    146202        Logging.info(tr("Loading {0}", imageUrl));
  • 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..72da2a3a31
    - +  
     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);
     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    /**
     207     * Rotate a vector using the current rotation
     208     * @param vec The vector to rotate
     209     * @return A rotated vector
     210     */
     211    private Vector3D rotate(final Vector3D vec) {
     212        // @formatting:off
     213        /* Full rotation matrix for a yaw-pitch-roll
     214         * yaw = alpha, pitch = beta, roll = gamma (typical representations)
     215         * [cos(alpha), -sin(alpha), 0 ]   [cos(beta), 0, sin(beta) ]   [1,     0     ,     0      ]   [x]   [x1]
     216         * |sin(alpha), cos(alpha), 0  | . |0        , 1, 0         | . |0, cos(gamma), -sin(gamma)| . |y| = |y1|
     217         * [0         ,       0    , 1 ]   [-sin(beta), 0, cos(beta)]   [0, sin(gamma), cos(gamma) ]   [z]   [z1]
     218         * which becomes
     219         * x1 = y(cos(alpha)sin(beta)sin(gamma) - sin(alpha)cos(gamma)) + z(cos(alpha)sin(beta)cos(gamma) + sin(alpha)sin(gamma))
     220         *      + x cos(alpha)cos(beta)
     221         * y1 = y(sin(alpha)sin(beta)sin(gamma) + cos(alpha)cos(gamma)) + z(sin(alpha)sin(beta)cos(gamma) - cos(alpha)sin(gamma))
     222         *      + x sin(alpha)cos(beta)
     223         * z1 = y cos(beta)sin(gamma) + z cos(beta)cos(gamma) - x sin(beta)
     224         */
     225        // @formatting:on
     226        double vecX;
     227        double vecY;
     228        double vecZ;
     229        // We only do pitch/roll (we specifically do not do roll -- this would lead to tilting the image)
     230        // So yaw (alpha) -> azimuthalAngle, pitch (beta) -> polarAngle, roll (gamma) -> 0 (sin(gamma) -> 0, cos(gamma) -> 1)
     231        // 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.
     232        // Ironically enough, the alpha (yaw) and gama (roll) got reversed somewhere. TODO figure out where and fix this.
     233        final int gamma = 0;
     234        final double sinAlpha = Math.sin(gamma);
     235        final double cosAlpha = Math.cos(gamma);
     236        final double cosGamma = this.rotation.getAzimuthalAngleCos();
     237        final double sinGamma = this.rotation.getAzimuthalAngleSin();
     238        final double cosBeta = this.rotation.getPolarAngleCos();
     239        final double sinBeta = this.rotation.getPolarAngleSin();
     240        final double x = vec.getX();
     241        final double y = YAW_DIRECTION * vec.getY();
     242        final double z = vec.getZ();
     243        vecX = y * (cosAlpha * sinBeta * sinGamma - sinAlpha * cosGamma)
     244                + z * (cosAlpha * sinBeta * cosGamma + sinAlpha * sinGamma) + x * cosAlpha * cosBeta;
     245        vecY = y * (sinAlpha * sinBeta * sinGamma + cosAlpha * cosGamma)
     246                + z * (sinAlpha * sinBeta * cosGamma - cosAlpha * sinGamma) + x * sinAlpha * cosBeta;
     247        vecZ = y * cosBeta * sinGamma + z * cosBeta * cosGamma - x * sinBeta;
     248        return new Vector3D(vecX, YAW_DIRECTION * vecY, vecZ);
     249    }
     250
     251    public void mapping(BufferedImage sourceImage, BufferedImage targetImage) {
     252        DataBuffer sourceBuffer = sourceImage.getRaster().getDataBuffer();
     253        DataBuffer targetBuffer = targetImage.getRaster().getDataBuffer();
     254        // Faster mapping
     255        if (sourceBuffer.getDataType() == DataBuffer.TYPE_INT && targetBuffer.getDataType() == DataBuffer.TYPE_INT) {
     256            int[] sourceImageBuffer = ((DataBufferInt) sourceImage.getRaster().getDataBuffer()).getData();
     257            int[] targetImageBuffer = ((DataBufferInt) targetImage.getRaster().getDataBuffer()).getData();
     258            IntStream.range(0, targetImage.getHeight()).parallel()
     259                    .forEach(y -> IntStream.range(0, targetImage.getWidth()).forEach(x -> {
     260                        final Point2D.Double p = mapPoint(x, y);
     261                        int tx = (int) (p.x * (sourceImage.getWidth() - 1));
     262                        int ty = (int) (p.y * (sourceImage.getHeight() - 1));
     263                        int color = sourceImageBuffer[ty * sourceImage.getWidth() + tx];
     264                        targetImageBuffer[y * targetImage.getWidth() + x] = color;
     265                    }));
     266        } else if (sourceBuffer.getDataType() == DataBuffer.TYPE_DOUBLE && targetBuffer.getDataType() == DataBuffer.TYPE_DOUBLE) {
     267            double[] sourceImageBuffer = ((DataBufferDouble) sourceImage.getRaster().getDataBuffer()).getData();
     268            double[] targetImageBuffer = ((DataBufferDouble) targetImage.getRaster().getDataBuffer()).getData();
     269            IntStream.range(0, targetImage.getHeight()).parallel()
     270                    .forEach(y -> IntStream.range(0, targetImage.getWidth()).forEach(x -> {
     271                        final Point2D.Double p = mapPoint(x, y);
     272                        int tx = (int) (p.x * (sourceImage.getWidth() - 1));
     273                        int ty = (int) (p.y * (sourceImage.getHeight() - 1));
     274                        double color = sourceImageBuffer[ty * sourceImage.getWidth() + tx];
     275                        targetImageBuffer[y * targetImage.getWidth() + x] = color;
     276                    }));
     277        } else {
     278            IntStream.range(0, targetImage.getHeight()).parallel()
     279                .forEach(y -> IntStream.range(0, targetImage.getWidth()).parallel().forEach(x -> {
     280                    final Point2D.Double p = mapPoint(x, y);
     281                    targetImage.setRGB(x, y, sourceImage.getRGB((int) (p.x * (sourceImage.getWidth() - 1)),
     282                        (int) (p.y * (sourceImage.getHeight() - 1))));
     283                }));
     284        }
     285    }
     286
     287    /**
     288     * Map a real point to the displayed point. This method uses cached vectors.
     289     * @param x The original x coordinate
     290     * @param y The original y coordinate
     291     * @return The scaled (0-1) point in the image. Use {@code p.x * (image.getWidth() - 1)} or {@code p.y * image.getHeight() - 1}.
     292     */
     293    public final Point2D.Double mapPoint(final int x, final int y) {
     294        final Vector3D vec = getVector3D(x, y);
     295        return UVMapping.getTextureCoordinate(vec);
     296    }
     297
     298    /**
     299     * Map a real point to the displayed point. This function does not use cached vectors.
     300     * @param x The original x coordinate
     301     * @param y The original y coordinate
     302     * @return The scaled (0-1) point in the image. Use {@code p.x * (image.getWidth() - 1)} or {@code p.y * image.getHeight() - 1}.
     303     */
     304    public final Point2D.Double mapPoint(final double x, final double y) {
     305        final Vector3D vec = getVector3D(x, y);
     306        return UVMapping.getTextureCoordinate(vec);
     307    }
     308}
  • 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..03e282a2cc
    - +  
     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.Disabled;
     13import org.junit.jupiter.api.Test;
     14import org.junit.jupiter.params.ParameterizedTest;
     15import org.junit.jupiter.params.provider.Arguments;
     16import org.junit.jupiter.params.provider.MethodSource;
     17
     18class CameraPlaneTest {
     19
     20    private static final int CAMERA_PLANE_WIDTH = 800;
     21    private static final int CAMERA_PLANE_HEIGHT = 600;
     22
     23    private CameraPlane cameraPlane;
     24
     25    @BeforeEach
     26    void setUp() {
     27        this.cameraPlane = new CameraPlane(CAMERA_PLANE_WIDTH, CAMERA_PLANE_HEIGHT);
     28    }
     29
     30    @Test
     31    @Disabled("Currently broken")
     32    void testSetRotation() {
     33        Vector3D vec = new Vector3D(0, 0, 1);
     34        cameraPlane.setRotation(vec);
     35        Vector3D out = cameraPlane.getRotation();
     36        assertAll(() -> assertEquals(280.0830152838839, out.getRadialDistance(), 0.001),
     37            () -> assertEquals(0, out.getPolarAngle(), 0.001), () -> assertEquals(0, out.getAzimuthalAngle(), 0.001));
     38    }
     39
     40    @Test
     41    @Disabled("Currently broken")
     42    void testGetVector3D() {
     43        Vector3D vec = new Vector3D(0, 0, 1);
     44        cameraPlane.setRotation(vec);
     45        Vector3D out = cameraPlane.getVector3D(new Point(CAMERA_PLANE_WIDTH / 2, CAMERA_PLANE_HEIGHT / 2));
     46        assertAll(() -> assertEquals(0.0, out.getX(), 1.0E-04), () -> assertEquals(0.0, out.getY(), 1.0E-04),
     47            () -> assertEquals(1.0, out.getZ(), 1.0E-04));
     48    }
     49
     50    static Stream<Arguments> testGetVector3DFloat() {
     51        return Stream
     52            .of(Arguments.of(new Vector3D(0, 0, 1), new Point(CAMERA_PLANE_WIDTH / 2, CAMERA_PLANE_HEIGHT / 2)));
     53    }
     54
     55    /**
     56     * This tests a method which does not cache, and more importantly, is what is used to create the sphere.
     57     * The vector is normalized.
     58     * (0, 0) is the center of the image
     59     *
     60     * @param expected The expected vector
     61     * @param toCheck The point to check
     62     */
     63    @ParameterizedTest
     64    @MethodSource
     65    void testGetVector3DFloat(final Vector3D expected, final Point toCheck) {
     66        Vector3D out = cameraPlane.getVector3D(toCheck.getX(), toCheck.getY());
     67        assertAll(() -> assertEquals(expected.getX(), out.getX(), 1.0E-04),
     68            () -> assertEquals(expected.getY(), out.getY(), 1.0E-04),
     69            () -> assertEquals(expected.getZ(), out.getZ(), 1.0E-04), () -> assertEquals(1,
     70                Math.sqrt(Math.pow(out.getX(), 2) + Math.pow(out.getY(), 2) + Math.pow(out.getZ(), 2)), 1.0E-04));
     71    }
     72
     73    @Test
     74    @Disabled("Currently broken")
     75    void testMapping() {
     76        Vector3D vec = new Vector3D(0, 0, 1);
     77        cameraPlane.setRotation(vec);
     78        Vector3D out = cameraPlane.getVector3D(new Point(300, 200));
     79        Point2D map = UVMapping.getTextureCoordinate(out);
     80        assertAll(() -> assertEquals(0.44542099, map.getX(), 1e-8), () -> assertEquals(0.39674936, map.getY(), 1e-8));
     81    }
     82}
  • 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}