diff --git a/src/org/openstreetmap/josm/data/ImageData.java b/src/org/openstreetmap/josm/data/ImageData.java
index 7fba8372a5..da289dca82 100644
--- a/src/org/openstreetmap/josm/data/ImageData.java
+++ b/src/org/openstreetmap/josm/data/ImageData.java
@@ -131,18 +131,46 @@ public class ImageData implements Data {
         return selectedImagesIndex.stream().filter(i -> i > -1 && i < data.size()).map(data::get).collect(Collectors.toList());
     }
 
+    /**
+     * Get the first image on the layer
+     * @return The first image
+     * @since xxx
+     */
+    public ImageEntry getFirstImage() {
+        if (!this.data.isEmpty()) {
+            return this.data.get(0);
+        }
+        return null;
+    }
+
     /**
      * Select the first image of the sequence
+     * @deprecated Use {@link #getFirstImage()} in conjunction with {@link #setSelectedImage}
      */
+    @Deprecated
     public void selectFirstImage() {
         if (!data.isEmpty()) {
             setSelectedImageIndex(0);
         }
     }
 
+    /**
+     * Get the last image in the layer
+     * @return The last image
+     * @since xxx
+     */
+    public ImageEntry getLastImage() {
+        if (!this.data.isEmpty()) {
+            return this.data.get(this.data.size() - 1);
+        }
+        return null;
+    }
+
     /**
      * Select the last image of the sequence
+     * @deprecated Use {@link #getLastImage()} with {@link #setSelectedImage}
      */
+    @Deprecated
     public void selectLastImage() {
         setSelectedImageIndex(data.size() - 1);
     }
@@ -165,15 +193,41 @@ public class ImageData implements Data {
         return this.geoImages.search(bounds.toBBox());
     }
 
+    /**
+     * Get the image next to the current image
+     * @return The next image
+     * @since xxx
+     */
+    public ImageEntry getNextImage() {
+        if (this.hasNextImage()) {
+            return this.data.get(this.selectedImagesIndex.get(0) + 1);
+        }
+        return null;
+    }
+
     /**
      * Select the next image of the sequence
+     * @deprecated Use {@link #getNextImage()} in conjunction with {@link #setSelectedImage}
      */
+    @Deprecated
     public void selectNextImage() {
         if (hasNextImage()) {
             setSelectedImageIndex(selectedImagesIndex.get(0) + 1);
         }
     }
 
+    /**
+     * Get the image previous to the current image
+     * @return The previous image
+     * @since xxx
+     */
+    public ImageEntry getPreviousImage() {
+        if (this.hasPreviousImage()) {
+            return this.data.get(Integer.max(0, selectedImagesIndex.get(0) - 1));
+        }
+        return null;
+    }
+
     /**
      *  Check if there is a previous image in the sequence
      * @return {@code true} is there is a previous image, {@code false} otherwise
@@ -184,7 +238,9 @@ public class ImageData implements Data {
 
     /**
      * Select the previous image of the sequence
+     * @deprecated Use {@link #getPreviousImage()} with {@link #setSelectedImage}
      */
+    @Deprecated
     public void selectPreviousImage() {
         if (data.isEmpty()) {
             return;
diff --git a/src/org/openstreetmap/josm/data/gpx/GpxImageEntry.java b/src/org/openstreetmap/josm/data/gpx/GpxImageEntry.java
index 030e2db117..d06b607693 100644
--- a/src/org/openstreetmap/josm/data/gpx/GpxImageEntry.java
+++ b/src/org/openstreetmap/josm/data/gpx/GpxImageEntry.java
@@ -3,22 +3,19 @@ package org.openstreetmap.josm.data.gpx;
 
 import static org.openstreetmap.josm.tools.I18n.tr;
 
+import java.awt.Dimension;
+import java.awt.image.BufferedImage;
 import java.io.File;
 import java.io.IOException;
 import java.time.Instant;
 import java.util.Date;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Objects;
 import java.util.function.Consumer;
-
-import org.openstreetmap.josm.data.IQuadBucketType;
-import org.openstreetmap.josm.data.coor.CachedLatLon;
-import org.openstreetmap.josm.data.coor.LatLon;
-import org.openstreetmap.josm.data.osm.BBox;
-import org.openstreetmap.josm.tools.ExifReader;
-import org.openstreetmap.josm.tools.JosmRuntimeException;
-import org.openstreetmap.josm.tools.Logging;
+import java.util.stream.Stream;
+import javax.imageio.IIOParam;
 
 import com.drew.imaging.jpeg.JpegMetadataReader;
 import com.drew.imaging.jpeg.JpegProcessingException;
@@ -33,6 +30,15 @@ import com.drew.metadata.exif.ExifIFD0Directory;
 import com.drew.metadata.exif.GpsDirectory;
 import com.drew.metadata.iptc.IptcDirectory;
 import com.drew.metadata.jpeg.JpegDirectory;
+import com.drew.metadata.xmp.XmpDirectory;
+import org.openstreetmap.josm.data.IQuadBucketType;
+import org.openstreetmap.josm.data.coor.CachedLatLon;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.imagery.street_level.Projections;
+import org.openstreetmap.josm.data.osm.BBox;
+import org.openstreetmap.josm.tools.ExifReader;
+import org.openstreetmap.josm.tools.JosmRuntimeException;
+import org.openstreetmap.josm.tools.Logging;
 
 /**
  * Stores info about each image
@@ -44,6 +50,7 @@ public class GpxImageEntry implements Comparable<GpxImageEntry>, IQuadBucketType
     private LatLon exifCoor;
     private Double exifImgDir;
     private Instant exifTime;
+    private Projections cameraProjection = Projections.UNKNOWN;
     /**
      * Flag isNewGpsData indicates that the GPS data of the image is new or has changed.
      * GPS data includes the position, speed, elevation, time (e.g. as extracted from the GPS track).
@@ -753,6 +760,26 @@ public class GpxImageEntry implements Comparable<GpxImageEntry>, IQuadBucketType
             ifNotNull(ExifReader.readKeywords(dirIptc), this::setIptcKeywords);
             ifNotNull(ExifReader.readObjectName(dirIptc), this::setIptcObjectName);
         }
+
+        for (XmpDirectory xmpDirectory : metadata.getDirectoriesOfType(XmpDirectory.class)) {
+            Map<String, String> properties = xmpDirectory.getXmpProperties();
+            final String projectionType = "GPano:ProjectionType";
+            if (properties.containsKey(projectionType)) {
+                Stream.of(Projections.values()).filter(p -> p.name().equalsIgnoreCase(properties.get(projectionType)))
+                        .findFirst().ifPresent(projection -> this.cameraProjection = projection);
+                break;
+            }
+        }
+    }
+
+    /**
+     * Reads the image represented by this entry in the given target dimension.
+     * @param target the desired dimension used for {@linkplain IIOParam#setSourceSubsampling subsampling} or {@code null}
+     * @return the read image, or {@code null}
+     * @throws IOException if any I/O error occurs
+     */
+    public BufferedImage read(Dimension target) throws IOException {
+        throw new UnsupportedOperationException("read not implemented for " + this.getClass().getSimpleName());
     }
 
     private static class NoMetadataReaderWarning extends Exception {
@@ -767,6 +794,14 @@ public class GpxImageEntry implements Comparable<GpxImageEntry>, IQuadBucketType
         }
     }
 
+    /**
+     * Get the projection type for this entry
+     * @return The projection type
+     */
+    public Projections getProjectionType() {
+        return this.cameraProjection;
+    }
+
     /**
      * Returns a {@link WayPoint} representation of this GPX image entry.
      * @return a {@code WayPoint} representation of this GPX image entry (containing position, instant and elevation)
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
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/street_level/IImageEntry.java
@@ -0,0 +1,212 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.street_level;
+
+import java.awt.Dimension;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.List;
+import javax.imageio.IIOParam;
+
+import org.openstreetmap.josm.data.coor.ILatLon;
+import org.openstreetmap.josm.gui.layer.geoimage.ImageViewerDialog;
+
+/**
+ * An interface for image entries that will be shown in {@link org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay}
+ * @author Taylor Smock
+ * @since xxx
+ */
+public interface IImageEntry<I extends IImageEntry<I>> {
+    /**
+     * Select the next image
+     * @param imageViewerDialog The image viewer to update
+     */
+    default void selectNextImage(final ImageViewerDialog imageViewerDialog) {
+        imageViewerDialog.displayImage(this.getNextImage());
+    }
+
+    /**
+     * Get what would be the next image
+     * @return The next image
+     */
+    I getNextImage();
+
+    /**
+     * Select the previous image
+     * @param imageViewerDialog The image viewer to update
+     */
+    default void selectPreviousImage(final ImageViewerDialog imageViewerDialog) {
+        imageViewerDialog.displayImage(this.getPreviousImage());
+    }
+
+    /**
+     * Get the previous image
+     * @return The previous image
+     */
+    I getPreviousImage();
+
+    /**
+     * Select the first image for the data or sequence
+     * @param imageViewerDialog The image viewer to update
+     */
+    default void selectFirstImage(final ImageViewerDialog imageViewerDialog) {
+        imageViewerDialog.displayImage(this.getFirstImage());
+    }
+
+    /**
+     * Get the first image for the data or sequence
+     * @return The first image
+     */
+    I getFirstImage();
+
+    /**
+     * Select the last image for the data or sequence
+     * @param imageViewerDialog The image viewer to update
+     */
+    default void selectLastImage(final ImageViewerDialog imageViewerDialog) {
+        imageViewerDialog.displayImage(this.getLastImage());
+    }
+
+    /**
+     * Get the last image for the data or sequence
+     * @return The last image
+     */
+    I getLastImage();
+
+    /**
+     * Remove the image
+     * @return {@code true} if removal was successful
+     * @throws UnsupportedOperationException If the implementation does not support removal.
+     * Use {@link #isRemoveSupported()}} to check for support.
+     */
+    default boolean remove() {
+        throw new UnsupportedOperationException("remove is not supported for " + this.getClass().getSimpleName());
+    }
+
+    /**
+     * Check if image removal is supported
+     * @return {@code true} if removal is supported
+     */
+    default boolean isRemoveSupported() {
+        return false;
+    }
+
+    /**
+     * Returns a display name for this entry (shown in image viewer title bar)
+     * @return a display name for this entry
+     */
+    String getDisplayName();
+
+    /**
+     * Reads the image represented by this entry in the given target dimension.
+     * @param target the desired dimension used for {@linkplain IIOParam#setSourceSubsampling subsampling} or {@code null}
+     * @return the read image, or {@code null}
+     * @throws IOException if any I/O error occurs
+     */
+    BufferedImage read(Dimension target) throws IOException;
+
+    /**
+     * Sets the width of this ImageEntry.
+     * @param width set the width of this ImageEntry
+     */
+    void setWidth(int width);
+
+    /**
+     * Sets the height of this ImageEntry.
+     * @param height set the height of this ImageEntry
+     */
+    void setHeight(int height);
+
+    /**
+     * Returns associated file.
+     * @return associated file
+     */
+    File getFile();
+
+    /**
+     * Returns the position value. The position value from the temporary copy
+     * is returned if that copy exists.
+     * @return the position value
+     */
+    ILatLon getPos();
+
+    /**
+     * Returns the speed value. The speed value from the temporary copy is
+     * returned if that copy exists.
+     * @return the speed value
+     */
+    Double getSpeed();
+
+    /**
+     * Returns the elevation value. The elevation value from the temporary
+     * copy is returned if that copy exists.
+     * @return the elevation value
+     */
+    Double getElevation();
+
+    /**
+     * Returns the image direction. The image direction from the temporary
+     * copy is returned if that copy exists.
+     * @return The image camera angle
+     */
+    Double getExifImgDir();
+
+    /**
+     * Convenient way to determine if this entry has a EXIF time, without the cost of building a defensive copy.
+     * @return {@code true} if this entry has a EXIF time
+     * @since 6450
+     */
+    boolean hasExifTime();
+
+    /**
+     * Returns EXIF time
+     * @return EXIF time
+     */
+    Instant getExifInstant();
+
+    /**
+     * Convenient way to determine if this entry has a GPS time, without the cost of building a defensive copy.
+     * @return {@code true} if this entry has a GPS time
+     */
+    boolean hasGpsTime();
+
+    /**
+     * Returns the GPS time value. The GPS time value from the temporary copy
+     * is returned if that copy exists.
+     * @return the GPS time value
+     */
+    Instant getGpsInstant();
+
+    /**
+     * Returns the IPTC caption.
+     * @return the IPTC caption
+     */
+    String getIptcCaption();
+
+    /**
+     * Returns the IPTC headline.
+     * @return the IPTC headline
+     */
+    String getIptcHeadline();
+
+    /**
+     * Returns the IPTC keywords.
+     * @return the IPTC keywords
+     */
+    List<String> getIptcKeywords();
+
+    /**
+     * Returns the IPTC object name.
+     * @return the IPTC object name
+     */
+    String getIptcObjectName();
+
+    /**
+     * Get the camera projection type
+     * @return the camera projection type
+     */
+    default Projections getProjectionType() {
+        return Projections.PERSPECTIVE;
+    }
+}
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..f14708ed59
--- /dev/null
+++ b/src/org/openstreetmap/josm/data/imagery/street_level/Projections.java
@@ -0,0 +1,18 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery.street_level;
+
+/**
+ * Projections for street level imagery
+ * @author Taylor Smock
+ * @since xxx
+ */
+public enum Projections {
+    /** This is the image type from most cameras */
+    PERSPECTIVE,
+    /** This will probably not be seen often in JOSM, but someone might have a synchronized pair of fisheye camers */
+    FISHEYE,
+    /** 360 imagery using the equirectangular method (single image) */
+    EQUIRECTANGULAR,
+    /** In the event that we have no clue what the projection should be. Defaults to perspective viewing. */
+    UNKNOWN;
+}
diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java
index cece730ab4..f8fab1e1c6 100644
--- a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java
@@ -12,24 +12,26 @@ import java.awt.Image;
 import java.awt.Point;
 import java.awt.Rectangle;
 import java.awt.RenderingHints;
+import java.awt.event.ComponentEvent;
+import java.awt.event.MouseAdapter;
 import java.awt.event.MouseEvent;
-import java.awt.event.MouseListener;
-import java.awt.event.MouseMotionListener;
 import java.awt.event.MouseWheelEvent;
-import java.awt.event.MouseWheelListener;
 import java.awt.geom.Rectangle2D;
 import java.awt.image.BufferedImage;
 import java.io.IOException;
 import java.util.Objects;
 import java.util.concurrent.Future;
-
 import javax.swing.JComponent;
 import javax.swing.SwingUtilities;
 
+import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
+import org.openstreetmap.josm.data.imagery.street_level.Projections;
 import org.openstreetmap.josm.data.preferences.BooleanProperty;
 import org.openstreetmap.josm.data.preferences.DoubleProperty;
 import org.openstreetmap.josm.data.preferences.IntegerProperty;
 import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.layer.geoimage.viewers.projections.IImageViewer;
+import org.openstreetmap.josm.gui.layer.geoimage.viewers.projections.ImageProjectionRegistry;
 import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings;
 import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings.FilterChangeListener;
 import org.openstreetmap.josm.gui.util.GuiHelper;
@@ -38,6 +40,7 @@ import org.openstreetmap.josm.spi.preferences.PreferenceChangeEvent;
 import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener;
 import org.openstreetmap.josm.tools.Destroyable;
 import org.openstreetmap.josm.tools.ImageProcessor;
+import org.openstreetmap.josm.tools.JosmRuntimeException;
 import org.openstreetmap.josm.tools.Logging;
 
 /**
@@ -48,11 +51,14 @@ import org.openstreetmap.josm.tools.Logging;
  */
 public class ImageDisplay extends JComponent implements Destroyable, PreferenceChangedListener, FilterChangeListener {
 
+    /** The current image viewer */
+    private IImageViewer iImageViewer;
+
     /** The file that is currently displayed */
-    private ImageEntry entry;
+    private IImageEntry<?> entry;
 
     /** The previous file that is currently displayed. Cleared on paint. Only used to help improve UI error information. */
-    private ImageEntry oldEntry;
+    private IImageEntry<?> oldEntry;
 
     /** The image currently displayed */
     private transient BufferedImage image;
@@ -245,9 +251,9 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
     /** The thread that reads the images. */
     protected class LoadImageRunnable implements Runnable {
 
-        private final ImageEntry entry;
+        private final IImageEntry<?> entry;
 
-        LoadImageRunnable(ImageEntry entry) {
+        LoadImageRunnable(IImageEntry<?> entry) {
             this.entry = entry;
         }
 
@@ -279,7 +285,7 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
                     updateProcessedImage();
                     // This will clear the loading info box
                     ImageDisplay.this.oldEntry = ImageDisplay.this.entry;
-                    visibleRect = new VisRect(0, 0, width, height);
+                    visibleRect = getIImageViewer(entry).getDefaultVisibleRectangle(ImageDisplay.this, image);
 
                     selectedRect = null;
                     errorLoading = false;
@@ -291,7 +297,7 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
         }
     }
 
-    private class ImgDisplayMouseListener implements MouseListener, MouseWheelListener, MouseMotionListener {
+    private class ImgDisplayMouseListener extends MouseAdapter {
 
         private MouseEvent lastMouseEvent;
         private Point mousePointInImg;
@@ -314,65 +320,57 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
         }
 
         private void mouseWheelMovedImpl(int x, int y, int rotation, boolean refreshMousePointInImg) {
-            ImageEntry entry;
-            Image image;
-            VisRect visibleRect;
+            IImageEntry<?> currentEntry;
+            IImageViewer imageViewer;
+            Image currentImage;
+            VisRect currentVisibleRect;
 
             synchronized (ImageDisplay.this) {
-                entry = ImageDisplay.this.entry;
-                image = ImageDisplay.this.image;
-                visibleRect = ImageDisplay.this.visibleRect;
+                currentEntry = ImageDisplay.this.entry;
+                currentImage = ImageDisplay.this.image;
+                currentVisibleRect = ImageDisplay.this.visibleRect;
+                imageViewer = ImageDisplay.this.iImageViewer;
             }
 
             selectedRect = null;
 
-            if (image == null)
+            if (currentImage == null)
                 return;
 
             // Calculate the mouse cursor position in image coordinates to center the zoom.
             if (refreshMousePointInImg)
-                mousePointInImg = comp2imgCoord(visibleRect, x, y, getSize());
+                mousePointInImg = comp2imgCoord(currentVisibleRect, x, y, getSize());
 
             // Apply the zoom to the visible rectangle in image coordinates
             if (rotation > 0) {
-                visibleRect.width = (int) (visibleRect.width * ZOOM_STEP.get());
-                visibleRect.height = (int) (visibleRect.height * ZOOM_STEP.get());
+                currentVisibleRect.width = (int) (currentVisibleRect.width * ZOOM_STEP.get());
+                currentVisibleRect.height = (int) (currentVisibleRect.height * ZOOM_STEP.get());
             } else {
-                visibleRect.width = (int) (visibleRect.width / ZOOM_STEP.get());
-                visibleRect.height = (int) (visibleRect.height / ZOOM_STEP.get());
+                currentVisibleRect.width = (int) (currentVisibleRect.width / ZOOM_STEP.get());
+                currentVisibleRect.height = (int) (currentVisibleRect.height / ZOOM_STEP.get());
             }
 
             // Check that the zoom doesn't exceed MAX_ZOOM:1
-            if (visibleRect.width < getSize().width / MAX_ZOOM.get()) {
-                visibleRect.width = (int) (getSize().width / MAX_ZOOM.get());
-            }
-            if (visibleRect.height < getSize().height / MAX_ZOOM.get()) {
-                visibleRect.height = (int) (getSize().height / MAX_ZOOM.get());
-            }
+            ensureMaxZoom(currentVisibleRect);
 
-            // Set the same ratio for the visible rectangle and the display area
-            int hFact = visibleRect.height * getSize().width;
-            int wFact = visibleRect.width * getSize().height;
-            if (hFact > wFact) {
-                visibleRect.width = hFact / getSize().height;
+            // The size of the visible rectangle is limited by the image size or the viewer implementation.
+            if (imageViewer != null) {
+                imageViewer.checkAndModifyVisibleRectSize(currentImage, currentVisibleRect);
             } else {
-                visibleRect.height = wFact / getSize().width;
+                currentVisibleRect.checkRectSize();
             }
 
-            // The size of the visible rectangle is limited by the image size.
-            visibleRect.checkRectSize();
-
             // Set the position of the visible rectangle, so that the mouse cursor doesn't move on the image.
-            Rectangle drawRect = calculateDrawImageRectangle(visibleRect, getSize());
-            visibleRect.x = mousePointInImg.x + ((drawRect.x - x) * visibleRect.width) / drawRect.width;
-            visibleRect.y = mousePointInImg.y + ((drawRect.y - y) * visibleRect.height) / drawRect.height;
+            Rectangle drawRect = calculateDrawImageRectangle(currentVisibleRect, getSize());
+            currentVisibleRect.x = mousePointInImg.x + ((drawRect.x - x) * currentVisibleRect.width) / drawRect.width;
+            currentVisibleRect.y = mousePointInImg.y + ((drawRect.y - y) * currentVisibleRect.height) / drawRect.height;
 
             // The position is also limited by the image size
-            visibleRect.checkRectPos();
+            currentVisibleRect.checkRectPos();
 
             synchronized (ImageDisplay.this) {
-                if (ImageDisplay.this.entry == entry) {
-                    ImageDisplay.this.visibleRect = visibleRect;
+                if (ImageDisplay.this.entry == currentEntry) {
+                    ImageDisplay.this.visibleRect = currentVisibleRect;
                 }
             }
             ImageDisplay.this.repaint();
@@ -400,24 +398,24 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
         @Override
         public void mouseClicked(MouseEvent e) {
             // Move the center to the clicked point.
-            ImageEntry entry;
-            Image image;
-            VisRect visibleRect;
+            IImageEntry<?> currentEntry;
+            Image currentImage;
+            VisRect currentVisibleRect;
 
             synchronized (ImageDisplay.this) {
-                entry = ImageDisplay.this.entry;
-                image = ImageDisplay.this.image;
-                visibleRect = ImageDisplay.this.visibleRect;
+                currentEntry = ImageDisplay.this.entry;
+                currentImage = ImageDisplay.this.image;
+                currentVisibleRect = ImageDisplay.this.visibleRect;
             }
 
-            if (image == null)
+            if (currentImage == null)
                 return;
 
             if (ZOOM_ON_CLICK.get()) {
                 // click notions are less coherent than wheel, refresh mousePointInImg on each click
                 lastMouseEvent = null;
 
-                if (mouseIsZoomSelecting(e) && !isAtMaxZoom(visibleRect)) {
+                if (mouseIsZoomSelecting(e) && !isAtMaxZoom(currentVisibleRect)) {
                     // zoom in if clicked with the zoom button
                     mouseWheelMovedImpl(e.getX(), e.getY(), -1, true);
                     return;
@@ -430,17 +428,17 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
             }
 
             // Calculate the translation to set the clicked point the center of the view.
-            Point click = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
-            Point center = getCenterImgCoord(visibleRect);
+            Point click = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
+            Point center = getCenterImgCoord(currentVisibleRect);
 
-            visibleRect.x += click.x - center.x;
-            visibleRect.y += click.y - center.y;
+            currentVisibleRect.x += click.x - center.x;
+            currentVisibleRect.y += click.y - center.y;
 
-            visibleRect.checkRectPos();
+            currentVisibleRect.checkRectPos();
 
             synchronized (ImageDisplay.this) {
-                if (ImageDisplay.this.entry == entry) {
-                    ImageDisplay.this.visibleRect = visibleRect;
+                if (ImageDisplay.this.entry == currentEntry) {
+                    ImageDisplay.this.visibleRect = currentVisibleRect;
                 }
             }
             ImageDisplay.this.repaint();
@@ -450,21 +448,21 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
          * a picture part) */
         @Override
         public void mousePressed(MouseEvent e) {
-            Image image;
-            VisRect visibleRect;
+            Image currentImage;
+            VisRect currentVisibleRect;
 
             synchronized (ImageDisplay.this) {
-                image = ImageDisplay.this.image;
-                visibleRect = ImageDisplay.this.visibleRect;
+                currentImage = ImageDisplay.this.image;
+                currentVisibleRect = ImageDisplay.this.visibleRect;
             }
 
-            if (image == null)
+            if (currentImage == null)
                 return;
 
             selectedRect = null;
 
             if (mouseIsDragging(e) || mouseIsZoomSelecting(e))
-                mousePointInImg = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
+                mousePointInImg = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
         }
 
         @Override
@@ -472,67 +470,73 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
             if (!mouseIsDragging(e) && !mouseIsZoomSelecting(e))
                 return;
 
-            ImageEntry entry;
-            Image image;
-            VisRect visibleRect;
+            IImageEntry<?> imageEntry;
+            Image currentImage;
+            VisRect currentVisibleRect;
 
             synchronized (ImageDisplay.this) {
-                entry = ImageDisplay.this.entry;
-                image = ImageDisplay.this.image;
-                visibleRect = ImageDisplay.this.visibleRect;
+                imageEntry = ImageDisplay.this.entry;
+                currentImage = ImageDisplay.this.image;
+                currentVisibleRect = ImageDisplay.this.visibleRect;
             }
 
-            if (image == null)
+            if (currentImage == null)
                 return;
 
             if (mouseIsDragging(e) && mousePointInImg != null) {
-                Point p = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
-                visibleRect.isDragUpdate = true;
-                visibleRect.x += mousePointInImg.x - p.x;
-                visibleRect.y += mousePointInImg.y - p.y;
-                visibleRect.checkRectPos();
+                Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
+                currentVisibleRect.isDragUpdate = true;
+                currentVisibleRect.x += mousePointInImg.x - p.x;
+                currentVisibleRect.y += mousePointInImg.y - p.y;
+                currentVisibleRect.checkRectPos();
+                getIImageViewer(entry).mouseDragged(this.mousePointInImg, p);
                 synchronized (ImageDisplay.this) {
-                    if (ImageDisplay.this.entry == entry) {
-                        ImageDisplay.this.visibleRect = visibleRect;
+                    if (ImageDisplay.this.entry == imageEntry) {
+                        ImageDisplay.this.visibleRect = currentVisibleRect;
                     }
                 }
+                // We have to update the mousePointInImg for 360 image panning, as otherwise the panning
+                // never stops.
+                // This does not work well with the perspective viewer at this time (2021-08-26).
+                if (entry != null && Projections.EQUIRECTANGULAR == entry.getProjectionType()) {
+                    this.mousePointInImg = p;
+                }
                 ImageDisplay.this.repaint();
             }
 
             if (mouseIsZoomSelecting(e) && mousePointInImg != null) {
-                Point p = comp2imgCoord(visibleRect, e.getX(), e.getY(), getSize());
-                visibleRect.checkPointInside(p);
-                VisRect selectedRect = new VisRect(
-                        p.x < mousePointInImg.x ? p.x : mousePointInImg.x,
-                        p.y < mousePointInImg.y ? p.y : mousePointInImg.y,
+                Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
+                currentVisibleRect.checkPointInside(p);
+                VisRect selectedRectTemp = new VisRect(
+                        Math.min(p.x, mousePointInImg.x),
+                        Math.min(p.y, mousePointInImg.y),
                         p.x < mousePointInImg.x ? mousePointInImg.x - p.x : p.x - mousePointInImg.x,
                         p.y < mousePointInImg.y ? mousePointInImg.y - p.y : p.y - mousePointInImg.y,
-                        visibleRect);
-                selectedRect.checkRectSize();
-                selectedRect.checkRectPos();
-                ImageDisplay.this.selectedRect = selectedRect;
+                        currentVisibleRect);
+                selectedRectTemp.checkRectSize();
+                selectedRectTemp.checkRectPos();
+                ImageDisplay.this.selectedRect = selectedRectTemp;
                 ImageDisplay.this.repaint();
             }
-
         }
 
         @Override
         public void mouseReleased(MouseEvent e) {
-            ImageEntry entry;
-            Image image;
-            VisRect visibleRect;
+            IImageEntry<?> currentEntry;
+            Image currentImage;
+            VisRect currentVisibleRect;
 
             synchronized (ImageDisplay.this) {
-                entry = ImageDisplay.this.entry;
-                image = ImageDisplay.this.image;
-                visibleRect = ImageDisplay.this.visibleRect;
+                currentEntry = ImageDisplay.this.entry;
+                currentImage = ImageDisplay.this.image;
+                currentVisibleRect = ImageDisplay.this.visibleRect;
             }
 
-            if (image == null)
+            if (currentImage == null)
                 return;
 
             if (mouseIsDragging(e)) {
-                visibleRect.isDragUpdate = false;
+                currentVisibleRect.isDragUpdate = false;
             }
 
             if (mouseIsZoomSelecting(e) && selectedRect != null) {
@@ -540,21 +544,7 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
                 int oldHeight = selectedRect.height;
 
                 // Check that the zoom doesn't exceed MAX_ZOOM:1
-                if (selectedRect.width < getSize().width / MAX_ZOOM.get()) {
-                    selectedRect.width = (int) (getSize().width / MAX_ZOOM.get());
-                }
-                if (selectedRect.height < getSize().height / MAX_ZOOM.get()) {
-                    selectedRect.height = (int) (getSize().height / MAX_ZOOM.get());
-                }
-
-                // Set the same ratio for the visible rectangle and the display area
-                int hFact = selectedRect.height * getSize().width;
-                int wFact = selectedRect.width * getSize().height;
-                if (hFact > wFact) {
-                    selectedRect.width = hFact / getSize().height;
-                } else {
-                    selectedRect.height = wFact / getSize().width;
-                }
+                ensureMaxZoom(selectedRect);
 
                 // Keep the center of the selection
                 if (selectedRect.width != oldWidth) {
@@ -569,9 +559,9 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
             }
 
             synchronized (ImageDisplay.this) {
-                if (entry == ImageDisplay.this.entry) {
+                if (currentEntry == ImageDisplay.this.entry) {
                     if (selectedRect == null) {
-                        ImageDisplay.this.visibleRect = visibleRect;
+                        ImageDisplay.this.visibleRect = currentVisibleRect;
                     } else {
                         ImageDisplay.this.visibleRect.setBounds(selectedRect);
                         selectedRect = null;
@@ -580,28 +570,13 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
             }
             ImageDisplay.this.repaint();
         }
-
-        @Override
-        public void mouseEntered(MouseEvent e) {
-            // Do nothing
-        }
-
-        @Override
-        public void mouseExited(MouseEvent e) {
-            // Do nothing
-        }
-
-        @Override
-        public void mouseMoved(MouseEvent e) {
-            // Do nothing
-        }
     }
 
     /**
      * Constructs a new {@code ImageDisplay} with no image processor.
      */
     public ImageDisplay() {
-        this(image -> image);
+        this(imageObject -> imageObject);
     }
 
     /**
@@ -636,14 +611,14 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
      * Sets a new source image to be displayed by this {@code ImageDisplay}.
      * @param entry new source image
      * @return a {@link Future} representing pending completion of the image loading task
-     * @since 18150
+     * @since 18150 (xxx for IImageEntry)
      */
-    public Future<?> setImage(ImageEntry entry) {
+    public Future<?> setImage(IImageEntry<?> entry) {
         LoadImageRunnable runnable = setImage0(entry);
         return runnable != null ? MainApplication.worker.submit(runnable) : null;
     }
 
-    protected LoadImageRunnable setImage0(ImageEntry entry) {
+    protected LoadImageRunnable setImage0(IImageEntry<?> entry) {
         synchronized (this) {
             this.oldEntry = this.entry;
             this.entry = entry;
@@ -691,23 +666,24 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
 
     private void updateProcessedImage() {
         processedImage = image == null ? null : imageProcessor.process(image);
-        GuiHelper.runInEDT(() -> repaint());
+        GuiHelper.runInEDT(this::repaint);
     }
 
     @Override
     public void paintComponent(Graphics g) {
-        ImageEntry entry;
-        ImageEntry oldEntry;
-        BufferedImage image;
-        VisRect visibleRect;
-        boolean errorLoading;
+        IImageEntry<?> currentEntry;
+        IImageEntry<?> currentOldEntry;
+        IImageViewer currentImageViewer;
+        BufferedImage currentImage;
+        VisRect currentVisibleRect;
+        boolean currentErrorLoading;
 
         synchronized (this) {
-            image = this.processedImage;
-            entry = this.entry;
-            oldEntry = this.oldEntry;
-            visibleRect = this.visibleRect;
-            errorLoading = this.errorLoading;
+            currentImage = this.processedImage;
+            currentEntry = this.entry;
+            currentOldEntry = this.oldEntry;
+            currentVisibleRect = this.visibleRect;
+            currentErrorLoading = this.errorLoading;
         }
 
         if (g instanceof Graphics2D) {
@@ -716,77 +692,49 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
 
         Dimension size = getSize();
         // Draw the image first, then draw error information
-        if (image != null && (entry != null || oldEntry != null)) {
-            Rectangle r = new Rectangle(visibleRect);
-            Rectangle target = calculateDrawImageRectangle(visibleRect, size);
-
-            g.drawImage(image,
-                    target.x, target.y, target.x + target.width, target.y + target.height,
-                    r.x, r.y, r.x + r.width, r.y + r.height, null);
-
-            if (selectedRect != null) {
-                Point topLeft = img2compCoord(visibleRect, selectedRect.x, selectedRect.y, size);
-                Point bottomRight = img2compCoord(visibleRect,
-                        selectedRect.x + selectedRect.width,
-                        selectedRect.y + selectedRect.height, size);
-                g.setColor(new Color(128, 128, 128, 180));
-                g.fillRect(target.x, target.y, target.width, topLeft.y - target.y);
-                g.fillRect(target.x, target.y, topLeft.x - target.x, target.height);
-                g.fillRect(bottomRight.x, target.y, target.x + target.width - bottomRight.x, target.height);
-                g.fillRect(target.x, bottomRight.y, target.width, target.y + target.height - bottomRight.y);
-                g.setColor(Color.black);
-                g.drawRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
-            }
-            if (errorLoading && entry != null) {
-                String loadingStr = tr("Error on file {0}", entry.getDisplayName());
+        if (currentImage != null && (currentEntry != null || currentOldEntry != null)) {
+            currentImageViewer = this.getIImageViewer(currentEntry);
+            Rectangle r = new Rectangle(currentVisibleRect);
+            Rectangle target = calculateDrawImageRectangle(currentVisibleRect, size);
+
+            currentImageViewer.paintImage(g, currentImage, target, r);
+            paintSelectedRect(g, target, currentVisibleRect, size);
+            if (currentErrorLoading && currentEntry != null) {
+                String loadingStr = tr("Error on file {0}", currentEntry.getDisplayName());
                 Rectangle2D noImageSize = g.getFontMetrics(g.getFont()).getStringBounds(loadingStr, g);
-                g.drawString(loadingStr,
-                        (int) ((size.width - noImageSize.getWidth()) / 2),
+                g.drawString(loadingStr, (int) ((size.width - noImageSize.getWidth()) / 2),
                         (int) ((size.height - noImageSize.getHeight()) / 2));
             }
-            if (osdText != null) {
-                FontMetrics metrics = g.getFontMetrics(g.getFont());
-                int ascent = metrics.getAscent();
-                Color bkground = new Color(255, 255, 255, 128);
-                int lastPos = 0;
-                int pos = osdText.indexOf('\n');
-                int x = 3;
-                int y = 3;
-                String line;
-                while (pos > 0) {
-                    line = osdText.substring(lastPos, pos);
-                    Rectangle2D lineSize = metrics.getStringBounds(line, g);
-                    g.setColor(bkground);
-                    g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
-                    g.setColor(Color.black);
-                    g.drawString(line, x, y + ascent);
-                    y += (int) lineSize.getHeight();
-                    lastPos = pos + 1;
-                    pos = osdText.indexOf('\n', lastPos);
-                }
-
-                line = osdText.substring(lastPos);
-                Rectangle2D lineSize = g.getFontMetrics(g.getFont()).getStringBounds(line, g);
-                g.setColor(bkground);
-                g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
-                g.setColor(Color.black);
-                g.drawString(line, x, y + ascent);
-            }
+            paintOsdText(g);
         }
+        paintErrorMessage(g, currentEntry, currentOldEntry, currentImage, currentErrorLoading, size);
+    }
+
+    /**
+     * Paint an error message
+     * @param g The graphics to paint on
+     * @param imageEntry The current image entry
+     * @param oldImageEntry The old image entry
+     * @param bufferedImage The image being painted
+     * @param currentErrorLoading If there was an error loading the image
+     * @param size The size of the component
+     */
+    private void paintErrorMessage(Graphics g, IImageEntry<?> imageEntry, IImageEntry<?> oldImageEntry,
+            BufferedImage bufferedImage, boolean currentErrorLoading, Dimension size) {
         final String errorMessage;
         // If the new entry is null, then there is no image.
-        if (entry == null) {
+        if (imageEntry == null) {
             if (emptyText == null) {
                 emptyText = tr("No image");
             }
             errorMessage = emptyText;
-        } else if (image == null || !Objects.equals(entry, oldEntry)) {
+        } else if (bufferedImage == null || !Objects.equals(imageEntry, oldImageEntry)) {
             // The image is not necessarily null when loading anymore. If the oldEntry is not the same as the new entry,
             // we are probably still loading the image. (oldEntry gets set to entry when the image finishes loading).
-            if (!errorLoading) {
-                errorMessage = tr("Loading {0}", entry.getDisplayName());
+            if (!currentErrorLoading) {
+                errorMessage = tr("Loading {0}", imageEntry.getDisplayName());
             } else {
-                errorMessage = tr("Error on file {0}", entry.getDisplayName());
+                errorMessage = tr("Error on file {0}", imageEntry.getDisplayName());
             }
         } else {
             errorMessage = null;
@@ -812,6 +760,64 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
         }
     }
 
+    /**
+     * Paint OSD text
+     * @param g The graphics to paint on
+     */
+    private void paintOsdText(Graphics g) {
+        if (osdText != null) {
+            FontMetrics metrics = g.getFontMetrics(g.getFont());
+            int ascent = metrics.getAscent();
+            Color bkground = new Color(255, 255, 255, 128);
+            int lastPos = 0;
+            int pos = osdText.indexOf('\n');
+            int x = 3;
+            int y = 3;
+            String line;
+            while (pos > 0) {
+                line = osdText.substring(lastPos, pos);
+                Rectangle2D lineSize = metrics.getStringBounds(line, g);
+                g.setColor(bkground);
+                g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
+                g.setColor(Color.black);
+                g.drawString(line, x, y + ascent);
+                y += (int) lineSize.getHeight();
+                lastPos = pos + 1;
+                pos = osdText.indexOf('\n', lastPos);
+            }
+
+            line = osdText.substring(lastPos);
+            Rectangle2D lineSize = g.getFontMetrics(g.getFont()).getStringBounds(line, g);
+            g.setColor(bkground);
+            g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
+            g.setColor(Color.black);
+            g.drawString(line, x, y + ascent);
+        }
+    }
+
+    /**
+     * Paint the selected rectangle
+     * @param g The graphics to paint on
+     * @param target The target area (i.e., the selection)
+     * @param visibleRectTemp The current visible rect
+     * @param size The size of the component
+     */
+    private void paintSelectedRect(Graphics g, Rectangle target, VisRect visibleRectTemp, Dimension size) {
+        if (selectedRect != null) {
+            Point topLeft = img2compCoord(visibleRectTemp, selectedRect.x, selectedRect.y, size);
+            Point bottomRight = img2compCoord(visibleRectTemp,
+                    selectedRect.x + selectedRect.width,
+                    selectedRect.y + selectedRect.height, size);
+            g.setColor(new Color(128, 128, 128, 180));
+            g.fillRect(target.x, target.y, target.width, topLeft.y - target.y);
+            g.fillRect(target.x, target.y, topLeft.x - target.x, target.height);
+            g.fillRect(bottomRight.x, target.y, target.x + target.width - bottomRight.x, target.height);
+            g.fillRect(target.x, bottomRight.y, target.width, target.y + target.height - bottomRight.y);
+            g.setColor(Color.black);
+            g.drawRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
+        }
+    }
+
     static Point img2compCoord(VisRect visibleRect, int xImg, int yImg, Dimension compSize) {
         Rectangle drawRect = calculateDrawImageRectangle(visibleRect, compSize);
         return new Point(drawRect.x + ((xImg - visibleRect.x) * drawRect.width) / visibleRect.width,
@@ -835,6 +841,13 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
                          visibleRect.y + visibleRect.height / 2);
     }
 
+    /**
+     * calculateDrawImageRectangle
+     *
+     * @param visibleRect the part of the image that should be drawn (in image coordinates)
+     * @param compSize the part of the component where the image should be drawn (in component coordinates)
+     * @return the part of compRect with the same width/height ratio as the image
+     */
     static VisRect calculateDrawImageRectangle(VisRect visibleRect, Dimension compSize) {
         return calculateDrawImageRectangle(visibleRect, new Rectangle(0, 0, compSize.width, compSize.height));
     }
@@ -888,36 +901,89 @@ public class ImageDisplay extends JComponent implements Destroyable, PreferenceC
      * the component size.
      */
     public void zoomBestFitOrOne() {
-        ImageEntry entry;
-        Image image;
-        VisRect visibleRect;
+        IImageEntry<?> currentEntry;
+        Image currentImage;
+        VisRect currentVisibleRect;
 
         synchronized (this) {
-            entry = this.entry;
-            image = this.image;
-            visibleRect = this.visibleRect;
+            currentEntry = this.entry;
+            currentImage = this.image;
+            currentVisibleRect = this.visibleRect;
         }
 
-        if (image == null)
+        if (currentImage == null)
             return;
 
-        if (visibleRect.width != image.getWidth(null) || visibleRect.height != image.getHeight(null)) {
+        if (currentVisibleRect.width != currentImage.getWidth(null) || currentVisibleRect.height != currentImage.getHeight(null)) {
             // The display is not at best fit. => Zoom to best fit
-            visibleRect.reset();
+            currentVisibleRect.reset();
         } else {
             // The display is at best fit => zoom to 1:1
-            Point center = getCenterImgCoord(visibleRect);
-            visibleRect.setBounds(center.x - getWidth() / 2, center.y - getHeight() / 2,
+            Point center = getCenterImgCoord(currentVisibleRect);
+            currentVisibleRect.setBounds(center.x - getWidth() / 2, center.y - getHeight() / 2,
                     getWidth(), getHeight());
-            visibleRect.checkRectSize();
-            visibleRect.checkRectPos();
+            currentVisibleRect.checkRectSize();
+            currentVisibleRect.checkRectPos();
         }
 
         synchronized (this) {
-            if (this.entry == entry) {
-                this.visibleRect = visibleRect;
+            if (this.entry == currentEntry) {
+                this.visibleRect = currentVisibleRect;
             }
         }
         repaint();
     }
+
+    /**
+     * Get the image viewer for an entry
+     * @param entry The entry to get the viewer for. May be {@code null}.
+     * @return The new image viewer, may be {@code null}
+     */
+    private IImageViewer getIImageViewer(IImageEntry<?> entry) {
+        IImageViewer imageViewer;
+        IImageEntry<?> imageEntry;
+        synchronized (this) {
+            imageViewer = this.iImageViewer;
+            imageEntry = entry == null ? this.entry : entry;
+        }
+        if (imageEntry == null || imageViewer != null && imageViewer.getSupportedProjections().contains(imageEntry.getProjectionType())) {
+            return imageViewer;
+        }
+        try {
+            imageViewer = ImageProjectionRegistry.getViewer(imageEntry.getProjectionType()).getConstructor().newInstance();
+        } catch (ReflectiveOperationException e) {
+            throw new JosmRuntimeException(e);
+        }
+        synchronized (this) {
+            if (imageEntry.equals(this.entry)) {
+                this.removeComponentListener(this.iImageViewer);
+                this.iImageViewer = imageViewer;
+                imageViewer.componentResized(new ComponentEvent(this, ComponentEvent.COMPONENT_RESIZED));
+                this.addComponentListener(this.iImageViewer);
+            }
+        }
+        return imageViewer;
+    }
+
+    /**
+     * Ensure that a rectangle isn't zoomed in too much
+     * @param rectangle The rectangle to get (typically the visible area)
+     */
+    private void ensureMaxZoom(final Rectangle rectangle) {
+        if (rectangle.width < getSize().width / MAX_ZOOM.get()) {
+            rectangle.width = (int) (getSize().width / MAX_ZOOM.get());
+        }
+        if (rectangle.height < getSize().height / MAX_ZOOM.get()) {
+            rectangle.height = (int) (getSize().height / MAX_ZOOM.get());
+        }
+
+        // Set the same ratio for the visible rectangle and the display area
+        int hFact = rectangle.height * getSize().width;
+        int wFact = rectangle.width * getSize().height;
+        if (hFact > wFact) {
+            rectangle.width = hFact / getSize().height;
+        } else {
+            rectangle.height = wFact / getSize().width;
+        }
+    }
 }
diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java
index c0d3412f4e..8847011a33 100644
--- a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java
@@ -15,13 +15,13 @@ import java.net.MalformedURLException;
 import java.net.URL;
 import java.util.Collections;
 import java.util.Objects;
-
 import javax.imageio.IIOParam;
 import javax.imageio.ImageReadParam;
 import javax.imageio.ImageReader;
 
 import org.openstreetmap.josm.data.ImageData;
 import org.openstreetmap.josm.data.gpx.GpxImageEntry;
+import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
 import org.openstreetmap.josm.tools.ExifReader;
 import org.openstreetmap.josm.tools.ImageProvider;
 import org.openstreetmap.josm.tools.Logging;
@@ -30,7 +30,7 @@ import org.openstreetmap.josm.tools.Logging;
  * Stores info about each image, with an optional thumbnail
  * @since 2662
  */
-public class ImageEntry extends GpxImageEntry {
+public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry> {
 
     private Image thumbnail;
     private ImageData dataSet;
@@ -135,6 +135,61 @@ public class ImageEntry extends GpxImageEntry {
         return Objects.equals(thumbnail, other.thumbnail) && Objects.equals(dataSet, other.dataSet);
     }
 
+    @Override
+    public ImageEntry getNextImage() {
+        return this.dataSet.getNextImage();
+    }
+
+    @Override
+    public void selectNextImage(final ImageViewerDialog imageViewerDialog) {
+        IImageEntry.super.selectNextImage(imageViewerDialog);
+        this.dataSet.setSelectedImage(this.getNextImage());
+    }
+
+    @Override
+    public ImageEntry getPreviousImage() {
+        return this.dataSet.getPreviousImage();
+    }
+
+    @Override
+    public void selectPreviousImage(ImageViewerDialog imageViewerDialog) {
+        IImageEntry.super.selectPreviousImage(imageViewerDialog);
+        this.dataSet.setSelectedImage(this.getPreviousImage());
+    }
+
+    @Override
+    public ImageEntry getFirstImage() {
+        return this.dataSet.getFirstImage();
+    }
+
+    @Override
+    public void selectFirstImage(ImageViewerDialog imageViewerDialog) {
+        IImageEntry.super.selectFirstImage(imageViewerDialog);
+        this.dataSet.setSelectedImage(this.getFirstImage());
+    }
+
+    @Override
+    public ImageEntry getLastImage() {
+        return this.dataSet.getLastImage();
+    }
+
+    @Override
+    public void selectLastImage(ImageViewerDialog imageViewerDialog) {
+        IImageEntry.super.selectLastImage(imageViewerDialog);
+        this.dataSet.setSelectedImage(this.getLastImage());
+    }
+
+    @Override
+    public boolean isRemoveSupported() {
+        return true;
+    }
+
+    @Override
+    public boolean remove() {
+        this.dataSet.removeImage(this, false);
+        return true;
+    }
+
     /**
      * Reads the image represented by this entry in the given target dimension.
      * @param target the desired dimension used for {@linkplain IIOParam#setSourceSubsampling subsampling} or {@code null}
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/src/org/openstreetmap/josm/gui/layer/geoimage/ImageViewerDialog.java
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageViewerDialog.java
@@ -15,11 +15,12 @@ import java.awt.event.WindowEvent;
 import java.time.ZoneOffset;
 import java.time.format.DateTimeFormatter;
 import java.time.format.FormatStyle;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.Future;
-
+import java.util.stream.Collectors;
 import javax.swing.AbstractAction;
 import javax.swing.Box;
 import javax.swing.JButton;
@@ -32,10 +33,11 @@ import javax.swing.SwingConstants;
 import org.openstreetmap.josm.actions.JosmAction;
 import org.openstreetmap.josm.data.ImageData;
 import org.openstreetmap.josm.data.ImageData.ImageDataUpdateListener;
+import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
 import org.openstreetmap.josm.gui.ExtendedDialog;
 import org.openstreetmap.josm.gui.MainApplication;
 import org.openstreetmap.josm.gui.datatransfer.ClipboardUtils;
-import org.openstreetmap.josm.gui.dialogs.DialogsPanel.Action;
+import org.openstreetmap.josm.gui.dialogs.DialogsPanel;
 import org.openstreetmap.josm.gui.dialogs.ToggleDialog;
 import org.openstreetmap.josm.gui.dialogs.layer.LayerVisibilityAction;
 import org.openstreetmap.josm.gui.layer.Layer;
@@ -49,7 +51,6 @@ import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings;
 import org.openstreetmap.josm.tools.ImageProvider;
 import org.openstreetmap.josm.tools.Logging;
 import org.openstreetmap.josm.tools.Shortcut;
-import org.openstreetmap.josm.tools.Utils;
 import org.openstreetmap.josm.tools.date.DateUtils;
 
 /**
@@ -220,8 +221,8 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
 
         @Override
         public void actionPerformed(ActionEvent e) {
-            if (currentData != null) {
-                currentData.selectNextImage();
+            if (ImageViewerDialog.this.currentEntry != null) {
+                ImageViewerDialog.this.currentEntry.selectNextImage(ImageViewerDialog.this);
             }
         }
     }
@@ -235,8 +236,8 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
 
         @Override
         public void actionPerformed(ActionEvent e) {
-            if (currentData != null) {
-                currentData.selectPreviousImage();
+            if (ImageViewerDialog.this.currentEntry != null) {
+                ImageViewerDialog.this.currentEntry.selectPreviousImage(ImageViewerDialog.this);
             }
         }
     }
@@ -250,8 +251,8 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
 
         @Override
         public void actionPerformed(ActionEvent e) {
-            if (currentData != null) {
-                currentData.selectFirstImage();
+            if (ImageViewerDialog.this.currentEntry != null) {
+                ImageViewerDialog.this.currentEntry.selectFirstImage(ImageViewerDialog.this);
             }
         }
     }
@@ -265,8 +266,8 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
 
         @Override
         public void actionPerformed(ActionEvent e) {
-            if (currentData != null) {
-                currentData.selectLastImage();
+            if (ImageViewerDialog.this.currentEntry != null) {
+                ImageViewerDialog.this.currentEntry.selectLastImage(ImageViewerDialog.this);
             }
         }
     }
@@ -308,8 +309,11 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
 
         @Override
         public void actionPerformed(ActionEvent e) {
-            if (currentData != null) {
-                currentData.removeSelectedImages();
+            if (ImageViewerDialog.this.currentEntry != null) {
+                IImageEntry<?> imageEntry = ImageViewerDialog.this.currentEntry;
+                if (imageEntry.isRemoveSupported()) {
+                    imageEntry.remove();
+                }
             }
         }
     }
@@ -324,8 +328,10 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
 
         @Override
         public void actionPerformed(ActionEvent e) {
-            if (currentData != null && currentData.getSelectedImage() != null) {
-                List<ImageEntry> toDelete = currentData.getSelectedImages();
+            if (currentEntry != null) {
+                List<IImageEntry<?>> toDelete = currentEntry instanceof ImageEntry ?
+                        new ArrayList<>(((ImageEntry) currentEntry).getDataSet().getSelectedImages())
+                        : Collections.singletonList(currentEntry);
                 int size = toDelete.size();
 
                 int result = new ExtendedDialog(
@@ -346,9 +352,10 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
                         .getValue();
 
                 if (result == 2) {
-                    for (ImageEntry delete : toDelete) {
-                        if (Utils.deleteFile(delete.getFile())) {
-                            currentData.removeImage(delete, false);
+                    final List<ImageData> imageDataCollection = toDelete.stream().filter(ImageEntry.class::isInstance)
+                            .map(ImageEntry.class::cast).map(ImageEntry::getDataSet).distinct().collect(Collectors.toList());
+                    for (IImageEntry<?> delete : toDelete) {
+                        if (delete.isRemoveSupported() && delete.remove()) {
                             Logging.info("File {0} deleted.", delete.getFile());
                         } else {
                             JOptionPane.showMessageDialog(
@@ -359,8 +366,10 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
                                     );
                         }
                     }
-                    currentData.notifyImageUpdate();
-                    currentData.updateSelectedImage();
+                    imageDataCollection.forEach(data -> {
+                        data.notifyImageUpdate();
+                        data.updateSelectedImage();
+                    });
                 }
             }
         }
@@ -375,8 +384,8 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
 
         @Override
         public void actionPerformed(ActionEvent e) {
-            if (currentData != null) {
-                ClipboardUtils.copyString(String.valueOf(currentData.getSelectedImage().getFile()));
+            if (currentEntry != null) {
+                ClipboardUtils.copyString(String.valueOf(currentEntry.getFile()));
             }
         }
     }
@@ -425,28 +434,35 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
         return wasEnabled;
     }
 
-    private transient ImageData currentData;
-    private transient ImageEntry currentEntry;
+    private transient IImageEntry<?> currentEntry;
 
     /**
      * Displays a single image for the given layer.
-     * @param data the image data
+     * @param ignoredData the image data
      * @param entry image entry
      * @see #displayImages
      */
-    public void displayImage(ImageData data, ImageEntry entry) {
-        displayImages(data, Collections.singletonList(entry));
+    public void displayImage(ImageData ignoredData, ImageEntry entry) {
+        displayImages(Collections.singletonList(entry));
+    }
+
+    /**
+     * Displays a single image for the given layer.
+     * @param entry image entry
+     * @see #displayImages
+     */
+    public void displayImage(IImageEntry<?> entry) {
+        this.displayImages(Collections.singletonList(entry));
     }
 
     /**
      * Displays images for the given layer.
-     * @param data the image data
      * @param entries image entries
-     * @since 15333
+     * @since xxx
      */
-    public void displayImages(ImageData data, List<ImageEntry> entries) {
+    public void displayImages(List<IImageEntry<?>> entries) {
         boolean imageChanged;
-        ImageEntry entry = entries != null && entries.size() == 1 ? entries.get(0) : null;
+        IImageEntry<?> entry = entries != null && entries.size() == 1 ? entries.get(0) : null;
 
         synchronized (this) {
             // TODO: pop up image dialog but don't load image again
@@ -457,71 +473,13 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
                 MainApplication.getMap().mapView.zoomTo(entry.getPos());
             }
 
-            currentData = data;
             currentEntry = entry;
         }
 
         if (entry != null) {
-            setNextEnabled(data.hasNextImage());
-            setPreviousEnabled(data.hasPreviousImage());
-            btnDelete.setEnabled(true);
-            btnDeleteFromDisk.setEnabled(entry.getFile() != null);
-            btnCopyPath.setEnabled(true);
-
-            if (imageChanged) {
-                cancelLoadingImage();
-                // Set only if the image is new to preserve zoom and position if the same image is redisplayed
-                // (e.g. to update the OSD).
-                imgLoadingFuture = imgDisplay.setImage(entry);
-            }
-            setTitle(tr("Geotagged Images") + (!entry.getDisplayName().isEmpty() ? " - " + entry.getDisplayName() : ""));
-            StringBuilder osd = new StringBuilder(entry.getDisplayName());
-            if (entry.getElevation() != null) {
-                osd.append(tr("\nAltitude: {0} m", Math.round(entry.getElevation())));
-            }
-            if (entry.getSpeed() != null) {
-                osd.append(tr("\nSpeed: {0} km/h", Math.round(entry.getSpeed())));
-            }
-            if (entry.getExifImgDir() != null) {
-                osd.append(tr("\nDirection {0}\u00b0", Math.round(entry.getExifImgDir())));
-            }
-
-            DateTimeFormatter dtf = DateUtils.getDateTimeFormatter(FormatStyle.SHORT, FormatStyle.MEDIUM)
-                    // Set timezone to UTC since UTC is assumed when parsing the EXIF timestamp,
-                    // see see org.openstreetmap.josm.tools.ExifReader.readTime(com.drew.metadata.Metadata)
-                    .withZone(ZoneOffset.UTC);
-
-            if (entry.hasExifTime()) {
-                osd.append(tr("\nEXIF time: {0}", dtf.format(entry.getExifInstant())));
-            }
-            if (entry.hasGpsTime()) {
-                osd.append(tr("\nGPS time: {0}", dtf.format(entry.getGpsInstant())));
-            }
-            Optional.ofNullable(entry.getIptcCaption()).map(s -> tr("\nCaption: {0}", s)).ifPresent(osd::append);
-            Optional.ofNullable(entry.getIptcHeadline()).map(s -> tr("\nHeadline: {0}", s)).ifPresent(osd::append);
-            Optional.ofNullable(entry.getIptcKeywords()).map(s -> tr("\nKeywords: {0}", s)).ifPresent(osd::append);
-            Optional.ofNullable(entry.getIptcObjectName()).map(s -> tr("\nObject name: {0}", s)).ifPresent(osd::append);
-
-            imgDisplay.setOsdText(osd.toString());
+            this.updateButtonsNonNullEntry(entry, imageChanged);
         } else {
-            boolean hasMultipleImages = entries != null && entries.size() > 1;
-            // if this method is called to reinitialize dialog content with a blank image,
-            // do not actually show the dialog again with a blank image if currently hidden (fix #10672)
-            setTitle(tr("Geotagged Images"));
-            imgDisplay.setImage(null);
-            imgDisplay.setOsdText("");
-            setNextEnabled(false);
-            setPreviousEnabled(false);
-            btnDelete.setEnabled(hasMultipleImages);
-            btnDeleteFromDisk.setEnabled(hasMultipleImages);
-            btnCopyPath.setEnabled(false);
-            if (hasMultipleImages) {
-                imgDisplay.setEmptyText(tr("Multiple images selected"));
-                btnFirst.setEnabled(!isFirstImageSelected(data));
-                btnLast.setEnabled(!isLastImageSelected(data));
-            }
-            imgDisplay.setImage(null);
-            imgDisplay.setOsdText("");
+            this.updateButtonsNullEntry(entries);
             return;
         }
         if (!isDialogShowing()) {
@@ -530,17 +488,103 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
         } else {
             if (isDocked && isCollapsed) {
                 expand();
-                dialogsPanel.reconstruct(Action.COLLAPSED_TO_DEFAULT, this);
+                dialogsPanel.reconstruct(DialogsPanel.Action.COLLAPSED_TO_DEFAULT, this);
             }
         }
     }
 
-    private static boolean isLastImageSelected(ImageData data) {
-        return data.isImageSelected(data.getImages().get(data.getImages().size() - 1));
+    /**
+     * Update buttons for null entry
+     * @param entries {@code true} if multiple images are selected
+     */
+    private void updateButtonsNullEntry(List<IImageEntry<?>> entries) {
+        boolean hasMultipleImages = entries != null && entries.size() > 1;
+        // if this method is called to reinitialize dialog content with a blank image,
+        // do not actually show the dialog again with a blank image if currently hidden (fix #10672)
+        setTitle(tr("Geotagged Images"));
+        imgDisplay.setImage(null);
+        imgDisplay.setOsdText("");
+        setNextEnabled(false);
+        setPreviousEnabled(false);
+        btnDelete.setEnabled(hasMultipleImages);
+        btnDeleteFromDisk.setEnabled(hasMultipleImages);
+        btnCopyPath.setEnabled(false);
+        if (hasMultipleImages) {
+            imgDisplay.setEmptyText(tr("Multiple images selected"));
+            btnFirst.setEnabled(!isFirstImageSelected(entries));
+            btnLast.setEnabled(!isLastImageSelected(entries));
+        }
+        imgDisplay.setImage(null);
+        imgDisplay.setOsdText("");
+    }
+
+    /**
+     * Update the image viewer buttons for the new entry
+     * @param entry The new entry
+     * @param imageChanged {@code true} if it is not the same image as the previous image.
+     */
+    private void updateButtonsNonNullEntry(IImageEntry<?> entry, boolean imageChanged) {
+        setNextEnabled(entry.getNextImage() != null);
+        setPreviousEnabled(entry.getPreviousImage() != null);
+        btnDelete.setEnabled(true);
+        btnDeleteFromDisk.setEnabled(entry.getFile() != null);
+        btnCopyPath.setEnabled(true);
+
+        if (imageChanged) {
+            cancelLoadingImage();
+            // Set only if the image is new to preserve zoom and position if the same image is redisplayed
+            // (e.g. to update the OSD).
+            imgLoadingFuture = imgDisplay.setImage(entry);
+        }
+        setTitle(tr("Geotagged Images") + (!entry.getDisplayName().isEmpty() ? " - " + entry.getDisplayName() : ""));
+        StringBuilder osd = new StringBuilder(entry.getDisplayName());
+        if (entry.getElevation() != null) {
+            osd.append(tr("\nAltitude: {0} m", Math.round(entry.getElevation())));
+        }
+        if (entry.getSpeed() != null) {
+            osd.append(tr("\nSpeed: {0} km/h", Math.round(entry.getSpeed())));
+        }
+        if (entry.getExifImgDir() != null) {
+            osd.append(tr("\nDirection {0}\u00b0", Math.round(entry.getExifImgDir())));
+        }
+
+        DateTimeFormatter dtf = DateUtils.getDateTimeFormatter(FormatStyle.SHORT, FormatStyle.MEDIUM)
+                // Set timezone to UTC since UTC is assumed when parsing the EXIF timestamp,
+                // see see org.openstreetmap.josm.tools.ExifReader.readTime(com.drew.metadata.Metadata)
+                .withZone(ZoneOffset.UTC);
+
+        if (entry.hasExifTime()) {
+            osd.append(tr("\nEXIF time: {0}", dtf.format(entry.getExifInstant())));
+        }
+        if (entry.hasGpsTime()) {
+            osd.append(tr("\nGPS time: {0}", dtf.format(entry.getGpsInstant())));
+        }
+        Optional.ofNullable(entry.getIptcCaption()).map(s -> tr("\nCaption: {0}", s)).ifPresent(osd::append);
+        Optional.ofNullable(entry.getIptcHeadline()).map(s -> tr("\nHeadline: {0}", s)).ifPresent(osd::append);
+        Optional.ofNullable(entry.getIptcKeywords()).map(s -> tr("\nKeywords: {0}", s)).ifPresent(osd::append);
+        Optional.ofNullable(entry.getIptcObjectName()).map(s -> tr("\nObject name: {0}", s)).ifPresent(osd::append);
+
+        imgDisplay.setOsdText(osd.toString());
+    }
+
+    /**
+     * Displays images for the given layer.
+     * @param ignoredData the image data (unused, may be {@code null})
+     * @param entries image entries
+     * @since 15333 (xxx for IImageEntry<?>)
+     * @deprecated Use {@link #displayImages(List)} (The data param is no longer used)
+     */
+    @Deprecated
+    public void displayImages(ImageData ignoredData, List<IImageEntry<?>> entries) {
+        this.displayImages(entries);
+    }
+
+    private static boolean isLastImageSelected(List<IImageEntry<?>> data) {
+        return data.stream().anyMatch(image -> data.contains(image.getLastImage()));
     }
 
-    private static boolean isFirstImageSelected(ImageData data) {
-        return data.isImageSelected(data.getImages().get(0));
+    private static boolean isFirstImageSelected(List<IImageEntry<?>> data) {
+        return data.stream().anyMatch(image -> data.contains(image.getFirstImage()));
     }
 
     /**
@@ -575,9 +619,9 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
     /**
      * Returns the currently displayed image.
      * @return Currently displayed image or {@code null}
-     * @since 6392
+     * @since 6392 (xxx for IImageEntry<?>)
      */
-    public static ImageEntry getCurrentImage() {
+    public static IImageEntry<?> getCurrentImage() {
         return getInstance().currentEntry;
     }
 
@@ -598,10 +642,10 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
 
     @Override
     public void layerRemoving(LayerRemoveEvent e) {
-        if (e.getRemovedLayer() instanceof GeoImageLayer) {
+        if (e.getRemovedLayer() instanceof GeoImageLayer && this.currentEntry instanceof ImageEntry) {
             ImageData removedData = ((GeoImageLayer) e.getRemovedLayer()).getImageData();
-            if (removedData == currentData) {
-                displayImages(null, null);
+            if (removedData == ((ImageEntry) this.currentEntry).getDataSet()) {
+                displayImages(null);
             }
             removedData.removeImageDataUpdateListener(this);
         }
@@ -626,8 +670,9 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
     }
 
     private void showLayer(Layer newLayer) {
-        if (currentData == null && newLayer instanceof GeoImageLayer) {
-            ((GeoImageLayer) newLayer).getImageData().selectFirstImage();
+        if (this.currentEntry == null && newLayer instanceof GeoImageLayer) {
+            ImageData imageData = ((GeoImageLayer) newLayer).getImageData();
+            imageData.setSelectedImage(imageData.getFirstImage());
         }
     }
 
@@ -640,11 +685,11 @@ public final class ImageViewerDialog extends ToggleDialog implements LayerChange
 
     @Override
     public void selectedImageChanged(ImageData data) {
-        displayImages(data, data.getSelectedImages());
+        displayImages(new ArrayList<>(data.getSelectedImages()));
     }
 
     @Override
     public void imageDataUpdated(ImageData data) {
-        displayImages(data, data.getSelectedImages());
+        displayImages(new ArrayList<>(data.getSelectedImages()));
     }
 }
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..a85acbcc88
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Equirectangular.java
@@ -0,0 +1,90 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.geoimage.viewers.projections;
+
+import java.awt.Component;
+import java.awt.Graphics;
+import java.awt.Image;
+import java.awt.Point;
+import java.awt.Rectangle;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.image.BufferedImage;
+import java.util.Collections;
+import java.util.Set;
+
+import org.openstreetmap.josm.data.imagery.street_level.Projections;
+import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;
+import org.openstreetmap.josm.gui.util.GuiHelper;
+import org.openstreetmap.josm.gui.util.imagery.CameraPlane;
+import org.openstreetmap.josm.gui.util.imagery.Vector3D;
+
+/**
+ * A class for showing 360 images that use the equirectangular projection
+ * @author Taylor Smock
+ * @since xxx
+ */
+public class Equirectangular extends ComponentAdapter implements IImageViewer {
+    private volatile CameraPlane cameraPlane;
+    private volatile BufferedImage offscreenImage;
+
+    @Override
+    public Set<Projections> getSupportedProjections() {
+        return Collections.singleton(Projections.EQUIRECTANGULAR);
+    }
+
+    @Override
+    public void paintImage(Graphics g, BufferedImage image, Rectangle target, Rectangle visibleRect) {
+        this.cameraPlane.mapping(image, this.offscreenImage);
+        if (target == null) {
+            target = new Rectangle(0, 0, offscreenImage.getWidth(null), offscreenImage.getHeight(null));
+        }
+        g.drawImage(offscreenImage, target.x, target.y, target.x + target.width, target.y + target.height,
+                visibleRect.x, visibleRect.y, visibleRect.x + visibleRect.width, visibleRect.y + visibleRect.height,
+                null);
+    }
+
+    @Override
+    public ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image) {
+        return new ImageDisplay.VisRect(0, 0, component.getSize().width, component.getSize().height);
+    }
+
+    @Override
+    public double getRotation() {
+        return this.cameraPlane.getRotation().getAzimuthalAngle();
+    }
+
+    @Override
+    public void componentResized(ComponentEvent e) {
+        final Component component = e.getComponent();
+        if (component instanceof ImageDisplay && e.getComponent().getWidth() > 0
+                && e.getComponent().getHeight() > 0) {
+            final ImageDisplay imgDisplay = (ImageDisplay) component;
+            // FIXME: Do something so that the types of the images are the same between the offscreenImage and
+            // the image entry
+            this.offscreenImage = new BufferedImage(imgDisplay.getWidth(), imgDisplay.getHeight(),
+                    BufferedImage.TYPE_3BYTE_BGR);
+            Vector3D currentRotation = null;
+            if (this.cameraPlane != null) {
+                currentRotation = this.cameraPlane.getRotation();
+            }
+            this.cameraPlane = new CameraPlane(imgDisplay.getWidth(), imgDisplay.getHeight());
+            if (currentRotation != null) {
+                this.cameraPlane.setRotation(currentRotation);
+            }
+            GuiHelper.runInEDT(imgDisplay::invalidate);
+        }
+    }
+
+    @Override
+    public void mouseDragged(final Point from, final Point to) {
+        IImageViewer.super.mouseDragged(from, to);
+        if (from != null && to != null) {
+            this.cameraPlane.setRotationFromDelta(from, to);
+        }
+    }
+
+    @Override
+    public void checkAndModifyVisibleRectSize(Image image, ImageDisplay.VisRect visibleRect) {
+        IImageViewer.super.checkAndModifyVisibleRectSize(this.offscreenImage, visibleRect);
+    }
+}
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..a1d5983e6b
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java
@@ -0,0 +1,74 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.geoimage.viewers.projections;
+
+import java.awt.Component;
+import java.awt.Graphics;
+import java.awt.Image;
+import java.awt.Point;
+import java.awt.Rectangle;
+import java.awt.event.ComponentListener;
+import java.awt.image.BufferedImage;
+import java.util.Set;
+
+import org.openstreetmap.josm.data.imagery.street_level.Projections;
+import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;
+
+/**
+ * An interface for image viewers for specific projections
+ * @since xxx
+ */
+public interface IImageViewer extends ComponentListener {
+    /**
+     * Get the supported projections for the image viewer
+     * @return The projections supported. Typically, only one.
+     */
+    Set<Projections> getSupportedProjections();
+
+    /**
+     * Paint the image
+     * @param g The graphics to paint on
+     * @param image The image to paint
+     * @param target The target area
+     * @param visibleRect The visible rectangle
+     */
+    void paintImage(Graphics g, BufferedImage image, Rectangle target, Rectangle visibleRect);
+
+    /**
+     * Get the default visible rectangle for the projection
+     * @param component The component the image will be displayed in
+     * @param image The image that will be shown
+     * @return The default visible rectangle
+     */
+    ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image);
+
+    /**
+     * Get the current rotation in the image viewer
+     * @return The rotation
+     */
+    default double getRotation() {
+        return 0;
+    }
+
+    /**
+     * Indicate that the mouse has been dragged to a point
+     * @param to The point the mouse has been dragged to
+     * @param from The point the mouse was dragged from
+     */
+    default void mouseDragged(Point from, Point to) {
+        // no-op
+    }
+
+    /**
+     * Check and modify the visible rect size to appropriate dimensions
+     * @param visibleRect the visible rectangle to update
+     * @param image The image to use for checking
+     */
+    default void checkAndModifyVisibleRectSize(Image image, ImageDisplay.VisRect visibleRect) {
+        if (visibleRect.width > image.getWidth(null)) {
+            visibleRect.width = image.getWidth(null);
+        }
+        if (visibleRect.height > image.getHeight(null)) {
+            visibleRect.height = image.getHeight(null);
+        }
+    }
+}
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
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/ImageProjectionRegistry.java
@@ -0,0 +1,71 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.geoimage.viewers.projections;
+
+import java.util.EnumMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.openstreetmap.josm.data.imagery.street_level.Projections;
+import org.openstreetmap.josm.tools.JosmRuntimeException;
+
+/**
+ * A class that holds a registry of viewers for image projections
+ */
+public final class ImageProjectionRegistry {
+    private static final EnumMap<Projections, Class<? extends IImageViewer>> DEFAULT_VIEWERS = new EnumMap<>(Projections.class);
+
+    // Register the default viewers
+    static {
+        try {
+            registerViewer(Perspective.class);
+            registerViewer(Equirectangular.class);
+        } catch (ReflectiveOperationException e) {
+            throw new JosmRuntimeException(e);
+        }
+    }
+
+    private ImageProjectionRegistry() {
+        // Prevent instantiations
+    }
+
+    /**
+     * Register a new viewer
+     * @param clazz The class to register. The class <i>must</i> have a no args constructor
+     * @return {@code true} if something changed
+     * @throws ReflectiveOperationException if there is no no-args constructor, or it is not visible to us.
+     */
+    public static boolean registerViewer(Class<? extends IImageViewer> clazz) throws ReflectiveOperationException {
+        Objects.requireNonNull(clazz, "null classes are hard to instantiate");
+        final IImageViewer object = clazz.getConstructor().newInstance();
+        boolean changed = false;
+        for (Projections projections : object.getSupportedProjections()) {
+            changed = clazz.equals(DEFAULT_VIEWERS.put(projections, clazz)) || changed;
+        }
+        return changed;
+    }
+
+    /**
+     * Remove a viewer
+     * @param clazz The class to remove.
+     * @return {@code true} if something changed
+     */
+    public static boolean removeViewer(Class<? extends IImageViewer> clazz) {
+        boolean changed = false;
+        for (Projections projections : DEFAULT_VIEWERS.entrySet().stream()
+                .filter(entry -> entry.getValue().equals(clazz)).map(Map.Entry::getKey)
+                .collect(Collectors.toList())) {
+            changed = DEFAULT_VIEWERS.remove(projections, clazz) || changed;
+        }
+        return changed;
+    }
+
+    /**
+     * Get the viewer for a specific projection type
+     * @param projection The projection to view
+     * @return The class to use
+     */
+    public static Class<? extends IImageViewer> getViewer(Projections projection) {
+        return DEFAULT_VIEWERS.getOrDefault(projection, DEFAULT_VIEWERS.getOrDefault(Projections.UNKNOWN, Perspective.class));
+    }
+}
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
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java
@@ -0,0 +1,38 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.geoimage.viewers.projections;
+
+import java.awt.Component;
+import java.awt.Graphics;
+import java.awt.Image;
+import java.awt.Rectangle;
+import java.awt.event.ComponentAdapter;
+import java.awt.image.BufferedImage;
+import java.util.EnumSet;
+import java.util.Set;
+
+import org.openstreetmap.josm.data.imagery.street_level.Projections;
+import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;
+
+/**
+ * The default perspective image viewer class.
+ * This also handles (by default) unknown projections.
+ */
+public class Perspective extends ComponentAdapter implements IImageViewer {
+
+    @Override
+    public Set<Projections> getSupportedProjections() {
+        return EnumSet.of(Projections.PERSPECTIVE);
+    }
+
+    @Override
+    public void paintImage(Graphics g, BufferedImage image, Rectangle target, Rectangle r) {
+        g.drawImage(image,
+                target.x, target.y, target.x + target.width, target.y + target.height,
+                r.x, r.y, r.x + r.width, r.y + r.height, null);
+    }
+
+    @Override
+    public ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image) {
+        return new ImageDisplay.VisRect(0, 0, image.getWidth(null), image.getHeight(null));
+    }
+}
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..1a4b491a0d
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/util/imagery/CameraPlane.java
@@ -0,0 +1,273 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.util.imagery;
+
+import java.awt.Point;
+import java.awt.geom.Point2D;
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBuffer;
+import java.awt.image.DataBufferDouble;
+import java.awt.image.DataBufferInt;
+import java.util.stream.IntStream;
+import javax.annotation.Nullable;
+
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * The plane that the camera appears on and rotates around.
+ */
+public class CameraPlane {
+    /** The field of view for the panorama at 0 zoom */
+    static final double PANORAMA_FOV = Math.toRadians(110);
+    /** The width of the image */
+    private final int width;
+    /** The height of the image */
+    private final int height;
+
+    private final Vector3D[][] vectors;
+    private Vector3D rotation;
+
+    public static final double HALF_PI = Math.PI / 2;
+    public static final double TWO_PI = 2 * Math.PI;
+
+    /**
+     * Create a new CameraPlane with the default FOV (110 degrees).
+     *
+     * @param width The width of the image
+     * @param height The height of the image
+     */
+    public CameraPlane(int width, int height) {
+        this(width, height, (width / 2d) / Math.tan(PANORAMA_FOV / 2));
+    }
+
+    /**
+     * Create a new CameraPlane
+     *
+     * @param width The width of the image
+     * @param height The height of the image
+     * @param distance The radial distance of the photosphere
+     */
+    private CameraPlane(int width, int height, double distance) {
+        this.width = width;
+        this.height = height;
+        this.rotation = new Vector3D(Vector3D.VectorType.RPA, distance, 0, 0);
+        this.vectors = new Vector3D[width][height];
+        IntStream.range(0, this.height).parallel().forEach(y -> IntStream.range(0, this.width).parallel()
+            .forEach(x -> this.vectors[x][y] = this.getVector3D((double) x, y)));
+    }
+
+    /**
+     * Get the width of the image
+     * @return The width of the image
+     */
+    public int getWidth() {
+        return this.width;
+    }
+
+    /**
+     * Get the height of the image
+     * @return The height of the image
+     */
+    public int getHeight() {
+        return this.height;
+    }
+
+    /**
+     * Get the point for a vector
+     *
+     * @param vector the vector for which the corresponding point on the camera plane will be returned
+     * @return the point on the camera plane to which the given vector is mapped, nullable
+     */
+    @Nullable
+    public Point getPoint(final Vector3D vector) {
+        final Vector3D rotatedVector = rotate(vector, -1);
+        // Currently set to false due to change in painting
+        if (rotatedVector.getZ() < 0) {
+            // Ignores any points "behind the back", so they don't get painted a second time on the other
+            // side of the sphere
+            return null;
+        }
+        // This is a slightly faster than just doing the (brute force) method of Math.max(Math.min)). Reduces if
+        // statements by 1 per call.
+        final long x = Math
+            .round((rotatedVector.getX() / rotatedVector.getZ()) * this.rotation.getRadialDistance() + width / 2d);
+        final long y = Math
+            .round((rotatedVector.getY() / rotatedVector.getZ()) * this.rotation.getRadialDistance() + height / 2d);
+
+        try {
+            return new Point(Math.toIntExact(x), Math.toIntExact(y));
+        } catch (ArithmeticException e) {
+            return new Point((int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, x)),
+                (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, y)));
+        }
+    }
+
+    /**
+     * Convert a point to a 3D vector
+     *
+     * @param p The point to convert
+     * @return The vector
+     */
+    public Vector3D getVector3D(final Point p) {
+        return this.getVector3D(p.x, p.y);
+    }
+
+    /**
+     * Convert a point to a 3D vector (vectors are cached)
+     *
+     * @param x The x coordinate
+     * @param y The y coordinate
+     * @return The vector
+     */
+    public Vector3D getVector3D(final int x, final int y) {
+        Vector3D res;
+        try {
+            res = rotate(vectors[x][y]);
+        } catch (Exception e) {
+            res = Vector3D.DEFAULT_VECTOR_3D;
+        }
+        return res;
+    }
+
+    /**
+     * Convert a point to a 3D vector. Warning: This method does not cache.
+     *
+     * @param x The x coordinate
+     * @param y The y coordinate
+     * @return The vector (the middle of the image is 0, 0)
+     */
+    public Vector3D getVector3D(final double x, final double y) {
+        return new Vector3D(x - width / 2d, y - height / 2d, this.rotation.getRadialDistance()).normalize();
+    }
+
+    /**
+     * Set camera plane rotation by current plane position.
+     *
+     * @param p Point within current plane.
+     */
+    public void setRotation(final Point p) {
+        setRotation(getVector3D(p));
+    }
+
+    /**
+     * Set the rotation from the difference of two points
+     *
+     * @param from The originating point
+     * @param to The new point
+     */
+    public void setRotationFromDelta(final Point from, final Point to) {
+        try {
+            Vector3D f1 = vectors[from.x][from.y];
+            Vector3D t1 = vectors[to.x][to.y];
+            double deltaPolarAngle = f1.getPolarAngle() - t1.getPolarAngle();
+            double deltaAzimuthalAngle = t1.getAzimuthalAngle() - f1.getAzimuthalAngle();
+            double polarAngle = this.rotation.getPolarAngle() + deltaPolarAngle;
+            double azimuthalAngle = this.rotation.getAzimuthalAngle() + deltaAzimuthalAngle;
+            this.setRotation(azimuthalAngle, polarAngle);
+        } catch (ArrayIndexOutOfBoundsException e) {
+            Logging.error(e);
+        }
+    }
+
+    /**
+     * Set camera plane rotation by spherical vector.
+     *
+     * @param vec vector pointing new view position.
+     */
+    public void setRotation(Vector3D vec) {
+        setRotation(vec.getPolarAngle(), vec.getAzimuthalAngle());
+    }
+
+    public Vector3D getRotation() {
+        return this.rotation;
+    }
+
+    synchronized void setRotation(double azimuthalAngle, double polarAngle) {
+        // Note: Something, somewhere, is switching the two.
+        // So the bounds are flipped. FIXME sometime
+        // Prevent us from going much outside 2pi
+        if (polarAngle < 0) {
+            polarAngle = polarAngle + TWO_PI;
+        } else if (polarAngle > TWO_PI) {
+            polarAngle = polarAngle - TWO_PI;
+        }
+        // Avoid flipping the camera
+        if (azimuthalAngle > HALF_PI) {
+            azimuthalAngle = HALF_PI;
+        } else if (azimuthalAngle < -HALF_PI) {
+            azimuthalAngle = -HALF_PI;
+        }
+        this.rotation = new Vector3D(Vector3D.VectorType.RPA, this.rotation.getRadialDistance(), polarAngle, azimuthalAngle);
+    }
+
+    private Vector3D rotate(final Vector3D vec) {
+        return rotate(vec, 1);
+    }
+
+    private Vector3D rotate(final Vector3D vec, final int rotationFactor) {
+        double vecX, vecY, vecZ;
+        // Rotate around z axis first
+        vecZ = vec.getZ() * this.rotation.getAzimuthalAngleCos() - vec.getY() * this.rotation.getAzimuthalAngleSin();
+        vecY = vec.getZ() * this.rotation.getAzimuthalAngleSin() + vec.getY() * this.rotation.getAzimuthalAngleCos();
+        vecX = vecZ * this.rotation.getPolarAngleSin() * rotationFactor + vec.getX() * this.rotation.getPolarAngleCos();
+        vecZ = vecZ * this.rotation.getPolarAngleCos() - vec.getX() * this.rotation.getPolarAngleSin() * rotationFactor;
+        return new Vector3D(vecX, vecY, vecZ);
+    }
+
+    public void mapping(BufferedImage sourceImage, BufferedImage targetImage) {
+        DataBuffer sourceBuffer = sourceImage.getRaster().getDataBuffer();
+        DataBuffer targetBuffer = targetImage.getRaster().getDataBuffer();
+        // Faster mapping
+        if (sourceBuffer.getDataType() == DataBuffer.TYPE_INT && targetBuffer.getDataType() == DataBuffer.TYPE_INT) {
+            int[] sourceImageBuffer = ((DataBufferInt) sourceImage.getRaster().getDataBuffer()).getData();
+            int[] targetImageBuffer = ((DataBufferInt) targetImage.getRaster().getDataBuffer()).getData();
+            IntStream.range(0, targetImage.getHeight()).parallel()
+                    .forEach(y -> IntStream.range(0, targetImage.getWidth()).forEach(x -> {
+                        final Point2D.Double p = mapPoint(x, y);
+                        int tx = (int) (p.x * (sourceImage.getWidth() - 1));
+                        int ty = (int) (p.y * (sourceImage.getHeight() - 1));
+                        int color = sourceImageBuffer[ty * sourceImage.getWidth() + tx];
+                        targetImageBuffer[y * targetImage.getWidth() + x] = color;
+                    }));
+        } else if (sourceBuffer.getDataType() == DataBuffer.TYPE_DOUBLE && targetBuffer.getDataType() == DataBuffer.TYPE_DOUBLE) {
+            double[] sourceImageBuffer = ((DataBufferDouble) sourceImage.getRaster().getDataBuffer()).getData();
+            double[] targetImageBuffer = ((DataBufferDouble) targetImage.getRaster().getDataBuffer()).getData();
+            IntStream.range(0, targetImage.getHeight()).parallel()
+                    .forEach(y -> IntStream.range(0, targetImage.getWidth()).forEach(x -> {
+                        final Point2D.Double p = mapPoint(x, y);
+                        int tx = (int) (p.x * (sourceImage.getWidth() - 1));
+                        int ty = (int) (p.y * (sourceImage.getHeight() - 1));
+                        double color = sourceImageBuffer[ty * sourceImage.getWidth() + tx];
+                        targetImageBuffer[y * targetImage.getWidth() + x] = color;
+                    }));
+        } else {
+            IntStream.range(0, targetImage.getHeight()).parallel()
+                .forEach(y -> IntStream.range(0, targetImage.getWidth()).parallel().forEach(x -> {
+                    final Point2D.Double p = mapPoint(x, y);
+                    targetImage.setRGB(x, y, sourceImage.getRGB((int) (p.x * (sourceImage.getWidth() - 1)),
+                        (int) (p.y * (sourceImage.getHeight() - 1))));
+                }));
+        }
+    }
+
+    /**
+     * Map a real point to the displayed point. This method uses cached vectors.
+     * @param x The original x coordinate
+     * @param y The original y coordinate
+     * @return The scaled (0-1) point in the image. Use {@code p.x * (image.getWidth() - 1)} or {@code p.y * image.getHeight() - 1}.
+     */
+    public final Point2D.Double mapPoint(final int x, final int y) {
+        final Vector3D vec = getVector3D(x, y);
+        return UVMapping.getTextureCoordinate(vec);
+    }
+
+    /**
+     * Map a real point to the displayed point. This function does not use cached vectors.
+     * @param x The original x coordinate
+     * @param y The original y coordinate
+     * @return The scaled (0-1) point in the image. Use {@code p.x * (image.getWidth() - 1)} or {@code p.y * image.getHeight() - 1}.
+     */
+    public final Point2D.Double mapPoint(final double x, final double y) {
+        final Vector3D vec = getVector3D(x, y);
+        return UVMapping.getTextureCoordinate(vec);
+    }
+}
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
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/util/imagery/UVMapping.java
@@ -0,0 +1,46 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.util.imagery;
+
+import java.awt.geom.Point2D;
+
+/**
+ * A utility class for mapping a point onto a spherical coordinate system and vice versa
+ * @since xxx
+ */
+public final class UVMapping {
+    private static final double TWO_PI = 2 * Math.PI;
+    private UVMapping() {
+        // Private constructor to avoid instantiation
+    }
+
+    /**
+     * Returns the point of the texture image that is mapped to the given point in 3D space (given as {@link Vector3D})
+     * See <a href="https://en.wikipedia.org/wiki/UV_mapping">the Wikipedia article on UV mapping</a>.
+     *
+     * @param vector the vector to which the texture point is mapped
+     * @return a point on the texture image somewhere in the rectangle between (0, 0) and (1, 1)
+     */
+    public static Point2D.Double getTextureCoordinate(final Vector3D vector) {
+        final double u = 0.5 + (Math.atan2(vector.getX(), vector.getZ()) / TWO_PI);
+        final double v = 0.5 + (Math.asin(vector.getY()) / Math.PI);
+        return new Point2D.Double(u, v);
+    }
+
+    /**
+     * For a given point of the texture (i.e. the image), return the point in 3D space where the point
+     * of the texture is mapped to (as {@link Vector3D}).
+     *
+     * @param u x-coordinate of the point on the texture (in the range between 0 and 1, from left to right)
+     * @param v y-coordinate of the point on the texture (in the range between 0 and 1, from top to bottom)
+     * @return the vector from the origin to where the point of the texture is mapped on the sphere
+     */
+    public static Vector3D getVector(final double u, final double v) {
+        if (u > 1 || u < 0 || v > 1 || v < 0) {
+            throw new IllegalArgumentException("u and v must be between or equal to 0 and 1");
+        }
+        final double vectorY = Math.cos(v * Math.PI);
+        final double vectorYSquared = Math.pow(vectorY, 2);
+        return new Vector3D(-Math.sin(TWO_PI * u) * Math.sqrt(1 - vectorYSquared), -vectorY,
+            -Math.cos(TWO_PI * u) * Math.sqrt(1 - vectorYSquared));
+    }
+}
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
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/util/imagery/Vector3D.java
@@ -0,0 +1,252 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.util.imagery;
+
+import javax.annotation.concurrent.Immutable;
+
+/**
+ * A basic 3D vector class
+ * @author Taylor Smock (documentation, spherical conversions)
+ * @since xxx
+ */
+@Immutable
+public final class Vector3D {
+    /**
+     * This determines how arguments are used in {@link Vector3D#Vector3D(VectorType, double, double, double)}.
+     */
+    public enum VectorType {
+        /** Standard cartesian coordinates (x, y, z) */
+        XYZ,
+        /** Physics (radial distance, polar angle, azimuthal angle) */
+        RPA,
+        /** Mathematics (radial distance, azimuthal angle, polar angle) */
+        RAP
+    }
+
+    /** A non-null default vector */
+    public static final Vector3D DEFAULT_VECTOR_3D = new Vector3D(0, 0, 1);
+
+    private final double x;
+    private final double y;
+    private final double z;
+    /* The following are all lazily calculated, but should always be the same */
+    /** The radius r */
+    private volatile double radialDistance = Double.NaN;
+    /** The polar angle theta (inclination) */
+    private volatile double polarAngle = Double.NaN;
+    /** Cosine of polar angle (angle from Z axis, AKA straight up) */
+    private volatile double polarAngleCos = Double.NaN;
+    /** Sine of polar angle (angle from Z axis, AKA straight up) */
+    private volatile double polarAngleSin = Double.NaN;
+    /** The azimuthal angle phi */
+    private volatile double azimuthalAngle = Double.NaN;
+    /** Cosine of azimuthal angle (angle from X axis) */
+    private volatile double azimuthalAngleCos = Double.NaN;
+    /** Sine of azimuthal angle (angle from X axis) */
+    private volatile double azimuthalAngleSin = Double.NaN;
+
+    /**
+     * Create a new Vector3D object using the XYZ coordinate system
+     *
+     * @param x The x coordinate
+     * @param y The y coordinate
+     * @param z The z coordinate
+     */
+    public Vector3D(double x, double y, double z) {
+        this(VectorType.XYZ, x, y, z);
+    }
+
+    /**
+     * Create a new Vector3D object. See ordering in {@link VectorType}.
+     *
+     * @param first The first coordinate
+     * @param second The second coordinate
+     * @param third The third coordinate
+     * @param vectorType The coordinate type (determines how the other variables are treated)
+     */
+    public Vector3D(VectorType vectorType, double first, double second, double third) {
+        if (vectorType == VectorType.XYZ) {
+            this.x = first;
+            this.y = second;
+            this.z = third;
+        } else {
+            this.radialDistance = first;
+            if (vectorType == VectorType.RPA) {
+                this.azimuthalAngle = third;
+                this.polarAngle = second;
+            } else {
+                this.azimuthalAngle = second;
+                this.polarAngle = third;
+            }
+            // Since we have to run the calculations anyway, ensure they are cached.
+            this.x = this.radialDistance * this.getAzimuthalAngleCos() * this.getPolarAngleSin();
+            this.y = this.radialDistance * this.getAzimuthalAngleSin() * this.getPolarAngleSin();
+            this.z = this.radialDistance * this.getPolarAngleCos();
+        }
+    }
+
+    /**
+     * Get the x coordinate
+     *
+     * @return The x coordinate
+     */
+    public double getX() {
+        return x;
+    }
+
+    /**
+     * Get the y coordinate
+     *
+     * @return The y coordinate
+     */
+    public double getY() {
+        return y;
+    }
+
+    /**
+     * Get the z coordinate
+     *
+     * @return The z coordinate
+     */
+    public double getZ() {
+        return z;
+    }
+
+    /**
+     * Get the radius
+     *
+     * @return The radius
+     */
+    public double getRadialDistance() {
+        if (Double.isNaN(this.radialDistance)) {
+            this.radialDistance = Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2) + Math.pow(this.z, 2));
+        }
+        return this.radialDistance;
+    }
+
+    /**
+     * Get the polar angle (inclination)
+     *
+     * @return The polar angle
+     */
+    public double getPolarAngle() {
+        if (Double.isNaN(this.polarAngle)) {
+            // This was Math.atan(x, z) in the Mapillary plugin
+            // This should be Math.atan(y, z)
+            this.polarAngle = Math.atan2(this.x, this.z);
+        }
+        return this.polarAngle;
+    }
+
+    /**
+     * Get the polar angle cossine (inclination)
+     *
+     * @return The polar angle cosine
+     */
+    public double getPolarAngleCos() {
+        if (Double.isNaN(this.polarAngleCos)) {
+            this.polarAngleCos = Math.cos(this.getPolarAngle());
+        }
+        return this.polarAngleCos;
+    }
+
+    /**
+     * Get the polar angle sine (inclination)
+     *
+     * @return The polar angle sine
+     */
+    public double getPolarAngleSin() {
+        if (Double.isNaN(this.polarAngleSin)) {
+            this.polarAngleSin = Math.sin(this.getPolarAngle());
+        }
+        return this.polarAngleSin;
+    }
+
+    /**
+     * Get the azimuthal angle
+     *
+     * @return The azimuthal angle
+     */
+    public double getAzimuthalAngle() {
+        if (Double.isNaN(this.azimuthalAngle)) {
+            if (Double.isNaN(this.radialDistance)) {
+                // Force calculation
+                this.getRadialDistance();
+            }
+            // Avoid issues where x, y, and z are 0
+            if (this.radialDistance == 0) {
+                this.azimuthalAngle = 0;
+            } else {
+                // This was Math.acos(y / radialDistance) in the Mapillary plugin
+                // This should be Math.acos(z / radialDistance)
+                this.azimuthalAngle = Math.acos(this.y / this.radialDistance);
+            }
+        }
+        return this.azimuthalAngle;
+    }
+
+    /**
+     * Get the azimuthal angle cosine
+     *
+     * @return The azimuthal angle cosine
+     */
+    public double getAzimuthalAngleCos() {
+        if (Double.isNaN(this.azimuthalAngleCos)) {
+            this.azimuthalAngleCos = Math.cos(this.getAzimuthalAngle());
+        }
+        return this.azimuthalAngleCos;
+    }
+
+    /**
+     * Get the azimuthal angle sine
+     *
+     * @return The azimuthal angle sine
+     */
+    public double getAzimuthalAngleSin() {
+        if (Double.isNaN(this.azimuthalAngleSin)) {
+            this.azimuthalAngleSin = Math.sin(this.getAzimuthalAngle());
+        }
+        return this.azimuthalAngleSin;
+    }
+
+    /**
+     * Normalize the vector
+     *
+     * @return A normalized vector
+     */
+    public Vector3D normalize() {
+        final double length = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2) + Math.pow(z, 2));
+        final double newX;
+        final double newY;
+        final double newZ;
+        if (length == 0 || Double.isNaN(length)) {
+            newX = 0;
+            newY = 0;
+            newZ = 0;
+        } else {
+            newX = x / length;
+            newY = y / length;
+            newZ = z / length;
+        }
+        return new Vector3D(newX, newY, newZ);
+    }
+
+    @Override
+    public int hashCode() {
+        return Double.hashCode(this.x) + 31 * Double.hashCode(this.y) + 31 * 31 * Double.hashCode(this.z);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (o instanceof Vector3D) {
+            Vector3D other = (Vector3D) o;
+            return this.x == other.x && this.y == other.y && this.z == other.z;
+        }
+        return false;
+    }
+
+    @Override
+    public String toString() {
+        return "[x=" + this.x + ", y=" + this.y + ", z=" + this.z + ", r=" + this.radialDistance + ", inclination="
+            + this.polarAngle + ", azimuthal=" + this.azimuthalAngle + "]";
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/gui/util/imagery/CameraPlaneTest.java b/test/unit/org/openstreetmap/josm/gui/util/imagery/CameraPlaneTest.java
new file mode 100644
index 0000000000..9ba3970471
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/gui/util/imagery/CameraPlaneTest.java
@@ -0,0 +1,78 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.util.imagery;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.awt.Point;
+import java.awt.geom.Point2D;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class CameraPlaneTest {
+
+    private static final int CAMERA_PLANE_WIDTH = 800;
+    private static final int CAMERA_PLANE_HEIGHT = 600;
+
+    private CameraPlane cameraPlane;
+
+    @BeforeEach
+    void setUp() {
+        this.cameraPlane = new CameraPlane(CAMERA_PLANE_WIDTH, CAMERA_PLANE_HEIGHT);
+    }
+
+    @Test
+    void testSetRotation() {
+        Vector3D vec = new Vector3D(0, 0, 1);
+        cameraPlane.setRotation(vec);
+        Vector3D out = cameraPlane.getRotation();
+        assertAll(() -> assertEquals(280.0830152838839, out.getRadialDistance(), 0.001),
+            () -> assertEquals(0, out.getPolarAngle(), 0.001), () -> assertEquals(0, out.getAzimuthalAngle(), 0.001));
+    }
+
+    @Test
+    void testGetVector3D() {
+        Vector3D vec = new Vector3D(0, 0, 1);
+        cameraPlane.setRotation(vec);
+        Vector3D out = cameraPlane.getVector3D(new Point(CAMERA_PLANE_WIDTH / 2, CAMERA_PLANE_HEIGHT / 2));
+        assertAll(() -> assertEquals(0.0, out.getX(), 1.0E-04), () -> assertEquals(0.0, out.getY(), 1.0E-04),
+            () -> assertEquals(1.0, out.getZ(), 1.0E-04));
+    }
+
+    static Stream<Arguments> testGetVector3DFloat() {
+        return Stream
+            .of(Arguments.of(new Vector3D(0, 0, 1), new Point(CAMERA_PLANE_WIDTH / 2, CAMERA_PLANE_HEIGHT / 2)));
+    }
+
+    /**
+     * This tests a method which does not cache, and more importantly, is what is used to create the sphere.
+     * The vector is normalized.
+     * (0, 0) is the center of the image
+     *
+     * @param expected The expected vector
+     * @param toCheck The point to check
+     */
+    @ParameterizedTest
+    @MethodSource
+    void testGetVector3DFloat(final Vector3D expected, final Point toCheck) {
+        Vector3D out = cameraPlane.getVector3D(toCheck.getX(), toCheck.getY());
+        assertAll(() -> assertEquals(expected.getX(), out.getX(), 1.0E-04),
+            () -> assertEquals(expected.getY(), out.getY(), 1.0E-04),
+            () -> assertEquals(expected.getZ(), out.getZ(), 1.0E-04), () -> assertEquals(1,
+                Math.sqrt(Math.pow(out.getX(), 2) + Math.pow(out.getY(), 2) + Math.pow(out.getZ(), 2)), 1.0E-04));
+    }
+
+    @Test
+    void testMapping() {
+        Vector3D vec = new Vector3D(0, 0, 1);
+        cameraPlane.setRotation(vec);
+        Vector3D out = cameraPlane.getVector3D(new Point(300, 200));
+        Point2D map = UVMapping.getTextureCoordinate(out);
+        assertAll(() -> assertEquals(0.44542099, map.getX(), 1e-8), () -> assertEquals(0.39674936, map.getY(), 1e-8));
+    }
+}
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
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/gui/util/imagery/UVMappingTest.java
@@ -0,0 +1,76 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.util.imagery;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.awt.geom.Point2D;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * A test class for {@link UVMapping}
+ */
+class UVMappingTest {
+    private static final double DEFAULT_DELTA = 1e-5;
+
+    static Stream<Arguments> testMapping() {
+        return Stream.of(Arguments.of(0.5, 1, 0, 1, 0),
+                Arguments.of(0.5, 0, 0, -1, 0),
+                Arguments.of(0.25, 0.5, -1, 0, 0),
+                Arguments.of(0.5, 0.5, 0, 0, 1),
+                Arguments.of(0.75, 0.5, 1, 0, 0),
+                Arguments.of(1, 0.5, 0, 0, -1),
+                Arguments.of(0.125, 0.25, -0.5, -1 / Math.sqrt(2), -0.5),
+                Arguments.of(0.625, 0.75, 0.5, 1 / Math.sqrt(2), 0.5)
+                );
+    }
+
+    /**
+     * Test that UV mapping is reversible for the sphere
+     * @param px The x for the point
+     * @param py The y for the point
+     * @param x The x portion of the vector
+     * @param y The y portion of the vector
+     * @param z The z portion of the vector
+     */
+    @ParameterizedTest
+    @MethodSource
+    void testMapping(final double px, final double py, final double x, final double y, final double z) {
+        // The mapping must be reversible
+        assertAll(() -> assertPointEquals(new Point2D.Double(px, py), UVMapping.getTextureCoordinate(new Vector3D(x, y, z))),
+                () -> assertVectorEquals(new Vector3D(x, y, z), UVMapping.getVector(px, py)));
+    }
+
+    @ParameterizedTest
+    @ValueSource(floats = {0, 1, 1.1f, 0.9f})
+    void testGetVectorEdgeCases(final float location) {
+        if (location < 0 || location > 1) {
+            assertAll(() -> assertThrows(IllegalArgumentException.class, () -> UVMapping.getVector(location, 0.5)),
+                    () -> assertThrows(IllegalArgumentException.class, () -> UVMapping.getVector(0.5, location)));
+        } else {
+            assertAll(() -> assertDoesNotThrow(() -> UVMapping.getVector(location, 0.5)),
+                    () -> assertDoesNotThrow(() -> UVMapping.getVector(0.5, location)));
+        }
+    }
+
+    private static void assertVectorEquals(final Vector3D expected, final Vector3D actual) {
+        final String message = String.format("Expected (%f %f %f), but was (%f %f %f)", expected.getX(),
+                expected.getY(), expected.getZ(), actual.getX(), actual.getY(), actual.getZ());
+        assertEquals(expected.getX(), actual.getX(), DEFAULT_DELTA, message);
+        assertEquals(expected.getY(), actual.getY(), DEFAULT_DELTA, message);
+        assertEquals(expected.getZ(), actual.getZ(), DEFAULT_DELTA, message);
+    }
+
+    private static void assertPointEquals(final Point2D expected, final Point2D actual) {
+        final String message = String.format("Expected (%f, %f), but was (%f, %f)", expected.getX(), expected.getY(),
+                actual.getX(), actual.getY());
+        assertEquals(expected.getX(), actual.getX(), DEFAULT_DELTA, message);
+        assertEquals(expected.getY(), actual.getY(), DEFAULT_DELTA, message);
+    }
+}
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
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/gui/util/imagery/Vector3DTest.java
@@ -0,0 +1,88 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.util.imagery;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Test class for {@link Vector3D}
+ * @author Taylor Smock
+ */
+class Vector3DTest {
+
+    static Stream<Arguments> vectorInformation() {
+        return Stream.of(
+            Arguments.of(0, 0, 0, 0),
+            Arguments.of(1, 1, 1, Math.sqrt(3)),
+            Arguments.of(-1, -1, -1, Math.sqrt(3)),
+            Arguments.of(-2, 2, -2, Math.sqrt(12))
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("vectorInformation")
+    void getX(final double x, final double y, final double z) {
+        final Vector3D vector3D = new Vector3D(x, y, z);
+        assertEquals(x, vector3D.getX());
+    }
+
+    @ParameterizedTest
+    @MethodSource("vectorInformation")
+    void getY(final double x, final double y, final double z) {
+        final Vector3D vector3D = new Vector3D(x, y, z);
+        assertEquals(y, vector3D.getY());
+    }
+
+    @ParameterizedTest
+    @MethodSource("vectorInformation")
+    void getZ(final double x, final double y, final double z) {
+        final Vector3D vector3D = new Vector3D(x, y, z);
+        assertEquals(z, vector3D.getZ());
+    }
+
+    @ParameterizedTest
+    @MethodSource("vectorInformation")
+    void getRadialDistance(final double x, final double y, final double z, final double radialDistance) {
+        final Vector3D vector3D = new Vector3D(x, y, z);
+        assertEquals(radialDistance, vector3D.getRadialDistance());
+    }
+
+    @ParameterizedTest
+    @MethodSource("vectorInformation")
+    @Disabled("Angle calculations may be corrected")
+    void getPolarAngle() {
+        fail("Not yet implemented");
+    }
+
+    @ParameterizedTest
+    @MethodSource("vectorInformation")
+    @Disabled("Angle calculations may be corrected")
+    void getAzimuthalAngle() {
+        fail("Not yet implemented");
+    }
+
+    @ParameterizedTest
+    @MethodSource("vectorInformation")
+    void normalize(final double x, final double y, final double z) {
+        final Vector3D vector3D = new Vector3D(x, y, z);
+        final Vector3D normalizedVector = vector3D.normalize();
+        assertAll(() -> assertEquals(vector3D.getRadialDistance() == 0 ? 0 : 1, normalizedVector.getRadialDistance()),
+                () -> assertEquals(vector3D.getPolarAngle(), normalizedVector.getPolarAngle()),
+                () -> assertEquals(vector3D.getAzimuthalAngle(), normalizedVector.getAzimuthalAngle()));
+    }
+
+    @ParameterizedTest
+    @MethodSource("vectorInformation")
+    @Disabled("Angle calculations may be corrected")
+    void testToString() {
+        fail("Not yet implemented");
+    }
+}
