Ticket #21432: 21432.4.patch

File 21432.4.patch, 92.8 KB (added by taylor.smock, 3 years ago)

Zoom fixed, panning broken for non-tiled images (panoramic images not yet checked)

  • src/org/openstreetmap/josm/data/cache/BufferedImageCacheEntry.java

    diff --git a/src/org/openstreetmap/josm/data/cache/BufferedImageCacheEntry.java b/src/org/openstreetmap/josm/data/cache/BufferedImageCacheEntry.java
    index 2e637a12d1..41fb48b2a9 100644
    a b  
    22package org.openstreetmap.josm.data.cache;
    33
    44import java.awt.image.BufferedImage;
     5import java.awt.image.RenderedImage;
    56import java.io.ByteArrayInputStream;
    67import java.io.ByteArrayOutputStream;
    78import java.io.IOException;
    public class BufferedImageCacheEntry extends CacheEntry {  
    3738     * @return a cache entry for the PNG encoded image
    3839     * @throws UncheckedIOException if an I/O error occurs
    3940     */
    40     public static BufferedImageCacheEntry pngEncoded(BufferedImage img) {
     41    public static BufferedImageCacheEntry pngEncoded(RenderedImage img) {
    4142        try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
    4243            ImageIO.write(img, "png", output);
    4344            return new BufferedImageCacheEntry(output.toByteArray());
  • src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java

    diff --git a/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java b/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java
    index f8603239a9..7512641aa9 100644
    a b public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements  
    9999    private boolean force;
    100100    private final long minimumExpiryTime;
    101101
     102    /**
     103     * Get the deduplication string for this job
     104     * @return The string used for deduplication
     105     * @throws IOException See {@link #getUrl()}
     106     */
     107    private String getDeduplicationString() throws IOException {
     108        // getCacheKey is useful for where the same url might return different items
     109        return this.getUrl().toString() + '/' + getCacheKey();
     110    }
     111
    102112    /**
    103113     * @param cache cache instance that we will work on
    104114     * @param options options of the request
    public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements  
    150160        String deduplicationKey = null;
    151161        if (url != null) {
    152162            // url might be null, for example when Bing Attribution is not loaded yet
    153             deduplicationKey = url.toString();
     163            deduplicationKey = this.getDeduplicationString();
    154164        }
    155165        if (deduplicationKey == null) {
    156166            Logging.warn("No url returned for: {0}, skipping", getCacheKey());
    public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements  
    252262    private void finishLoading(LoadResult result) {
    253263        Set<ICachedLoaderListener> listeners;
    254264        try {
    255             listeners = inProgress.remove(getUrl().toString());
     265            listeners = inProgress.remove(this.getDeduplicationString());
    256266        } catch (IOException e) {
    257267            listeners = null;
    258268            Logging.trace(e);
    public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements  
    319329        if (!file.exists()) {
    320330            file = new File(fileName.substring("file://".length() - 1));
    321331        }
    322         try (InputStream fileInputStream = Files.newInputStream(file.toPath())) {
    323             cacheData = createCacheEntry(Utils.readBytesFromStream(fileInputStream));
     332        try {
     333            cacheData = createCacheEntry(this.loadObjectBytes(file));
    324334            cache.put(getCacheKey(), cacheData, attributes);
    325335            return true;
    326336        } catch (IOException e) {
    public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements  
    331341        return false;
    332342    }
    333343
     344    /**
     345     * Load bytes from a file. This is overridable to allow for sparse loading of bytes
     346     * @param file The file to load
     347     * @return The file bytes
     348     * @throws IOException If there is an issue reading the file
     349     */
     350    protected byte[] loadObjectBytes(File file) throws IOException {
     351        try (InputStream fileInputStream = Files.newInputStream(file.toPath())) {
     352            return Utils.readBytesFromStream(fileInputStream);
     353        }
     354    }
     355
    334356    /**
    335357     * @return true if object was successfully downloaded via http, false, if there was a loading failure
    336358     */
  • src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageDisplay.java
    index 418fcfd520..6c5a91dee8 100644
    a b import java.awt.image.BufferedImage;  
    2121import java.io.IOException;
    2222import java.util.Objects;
    2323import java.util.concurrent.Future;
     24import java.util.concurrent.atomic.AtomicInteger;
    2425
    2526import javax.swing.JComponent;
    2627import javax.swing.SwingUtilities;
    import org.openstreetmap.josm.gui.MainApplication;  
    3435import org.openstreetmap.josm.gui.layer.AbstractMapViewPaintable;
    3536import org.openstreetmap.josm.gui.layer.geoimage.viewers.projections.IImageViewer;
    3637import org.openstreetmap.josm.gui.layer.geoimage.viewers.projections.ImageProjectionRegistry;
     38import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
    3739import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings;
    3840import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings.FilterChangeListener;
    3941import org.openstreetmap.josm.gui.util.GuiHelper;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    7981    private boolean errorLoading;
    8082
    8183    /** The rectangle (in image coordinates) of the image that is visible. This rectangle is calculated
    82      * each time the zoom is modified */
     84     * each time the zoom is modified. Note: This is in the current zoom coordinates, if those change. */
    8385    private VisRect visibleRect;
    8486
    8587    /** When a selection is done, the rectangle of the selection (in image coordinates) */
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    8789
    8890    private final ImgDisplayMouseListener imgMouseListener = new ImgDisplayMouseListener();
    8991
     92    private final AtomicInteger zoom = new AtomicInteger(12);
     93
    9094    private String emptyText;
    9195    private String osdText;
    9296
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    213217        }
    214218
    215219        public void checkRectPos() {
     220            this.checkRectPos(null, 0);
     221        }
     222
     223        /**
     224         * Ensure that the rectangle is within bounds
     225         * @param imageEntry The current image entry -- if it is a tiling entry, different constraints are needed
     226         * @param zoom The current zoom level (only used if tiling)
     227         */
     228        public void checkRectPos(final IImageEntry<?> imageEntry, final int zoom) {
     229            final int useWidth;
     230            final int useHeight;
     231            if (imageEntry instanceof IImageTiling) {
     232                final IImageTiling<?> tiler = (IImageTiling<?>) imageEntry;
     233                useHeight = tiler.getHeight(zoom);
     234                useWidth = tiler.getWidth(zoom);
     235            } else {
     236                useWidth = init.width;
     237                useHeight = init.height;
     238            }
    216239            if (x < 0) {
    217240                x = 0;
    218241            }
    219242            if (y < 0) {
    220243                y = 0;
    221244            }
    222             if (x + width > init.width) {
    223                 x = init.width - width;
     245            if (width > useWidth) {
     246                width = useWidth;
    224247            }
    225             if (y + height > init.height) {
    226                 y = init.height - height;
     248            if (height > useHeight) {
     249                height = useHeight;
     250            }
     251            if (x + width > useWidth) {
     252                x = useWidth - width;
     253            }
     254            if (y + height > useHeight) {
     255                y = useHeight - height;
    227256            }
    228257        }
    229258
    230259        public void checkRectSize() {
    231             if (width > init.width) {
    232                 width = init.width;
     260            this.checkRectSize(null, 0);
     261        }
     262
     263        /**
     264         * Ensure that the rectangle is the appropriate size
     265         * @param imageEntry The current image entry -- if it is a tiling entry, different constraints are needed
     266         * @param zoom The current zoom level (only used if tiling)
     267         */
     268        public void checkRectSize(final IImageEntry<?> imageEntry, final int zoom) {
     269            final int useWidth;
     270            final int useHeight;
     271            if (imageEntry instanceof IImageTiling) {
     272                useWidth = ((IImageTiling<?>) imageEntry).getWidth(zoom);
     273                useHeight = ((IImageTiling<?>) imageEntry).getHeight(zoom);
     274            } else {
     275                useWidth = init.width;
     276                useHeight = init.height;
     277            }
     278            if (width > useWidth) {
     279                width = useWidth;
    233280            }
    234             if (height > init.height) {
    235                 height = init.height;
     281            if (height > useHeight) {
     282                height = useHeight;
    236283            }
    237284        }
    238285
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    265312            VisRect other = (VisRect) obj;
    266313            return Objects.equals(init, other.init);
    267314        }
     315
     316        /**
     317         * Create an equivalent rectangle in image coordinates
     318         * @param size The component size
     319         * @param visibleRect The visible rectangle
     320         * @param imageEntry The current image entry
     321         * @param zoom The current zoom
     322         * @return A copy of this rectangle in image coordinates
     323         */
     324        VisRect createImageCoordinateRectangle(Dimension size, VisRect visibleRect, IImageEntry<?> imageEntry, int zoom) {
     325            final Point imageUpperLeft = comp2imgCoord(imageEntry, visibleRect, this.x,
     326                    this.y, size, zoom);
     327            final Point imageLowerRight = comp2imgCoord(imageEntry, visibleRect,
     328                    this.x + this.width, this.y + this.height, size, zoom);
     329            return new VisRect(imageUpperLeft.x, imageUpperLeft.y, imageLowerRight.x - imageUpperLeft.x, imageLowerRight.y - imageUpperLeft.y);
     330        }
     331
     332        /**
     333         * Create an equivalent rectangle in component coordinates
     334         * @param size The component size
     335         * @param visibleRect The visible rectangle
     336         * @param imageEntry The current image entry
     337         * @param zoom The current zoom
     338         * @return A copy of this rectangle in component coordinates
     339         */
     340        VisRect createCompCoordinateRectangle(Dimension size, VisRect visibleRect, IImageEntry<?> imageEntry, int zoom) {
     341            final Point compUpperLeft = img2compCoord(imageEntry, visibleRect, this.x, this.y, size, zoom);
     342            final Point compLowerRight = img2compCoord(imageEntry, visibleRect, this.x + this.width, this.y + this.height, size, zoom);
     343            return new VisRect(compUpperLeft.x, compUpperLeft.y, compLowerRight.x - compUpperLeft.x, compLowerRight.y - compUpperLeft.y);
     344        }
    268345    }
    269346
    270347    /** The thread that reads the images. */
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    289366                    }
    290367                }
    291368
    292                 int width = img.getWidth();
    293                 int height = img.getHeight();
    294                 entry.setWidth(width);
    295                 entry.setHeight(height);
     369                // Only set width/height if the entry is not something that can be tiled
     370                // Tiling *requires* knowledge of the actual width/height of the image.
     371                if (!(entry instanceof IImageTiling)) {
     372                    int width = img.getWidth();
     373                    int height = img.getHeight();
     374                    entry.setWidth(width);
     375                    entry.setHeight(height);
     376                }
    296377
    297378                synchronized (ImageDisplay.this) {
    298379                    if (this.entry != ImageDisplay.this.entry) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    301382                    }
    302383
    303384                    ImageDisplay.this.image = img;
    304                     updateProcessedImage();
    305385                    // This will clear the loading info box
    306386                    ImageDisplay.this.oldEntry = ImageDisplay.this.entry;
    307                     visibleRect = getIImageViewer(entry).getDefaultVisibleRectangle(ImageDisplay.this, image);
     387                    visibleRect = getIImageViewer(entry).getDefaultVisibleRectangle(ImageDisplay.this, image, this.entry);
     388                    // Update the visible rectangle
     389                    ImageDisplay.this.updateVisibleRectangle();
     390                    // Update the processed image *after* updating the visible rect -- otherwise, we may try to load a large image fully (tiled)
     391                    ImageDisplay.this.updateProcessedImage();
    308392
    309393                    selectedRect = null;
    310394                    errorLoading = false;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    319403    private class ImgDisplayMouseListener extends MouseAdapter {
    320404
    321405        private MouseEvent lastMouseEvent;
     406        /** The mouse point in image coordinates in the image */
    322407        private Point mousePointInImg;
    323408
    324409        private boolean mouseIsDragging(MouseEvent e) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    358443
    359444            // Calculate the mouse cursor position in image coordinates to center the zoom.
    360445            if (refreshMousePointInImg)
    361                 mousePointInImg = comp2imgCoord(currentVisibleRect, x, y, getSize());
     446                this.mousePointInImg = comp2imgCoord(currentEntry, currentVisibleRect, x, y, getSize(), zoom.get());
    362447
    363448            // Apply the zoom to the visible rectangle in image coordinates
     449            final int oldZoom = ImageDisplay.this.zoom.get();
     450            final double step;
    364451            if (rotation > 0) {
    365                 currentVisibleRect.width = (int) (currentVisibleRect.width * ZOOM_STEP.get());
    366                 currentVisibleRect.height = (int) (currentVisibleRect.height * ZOOM_STEP.get());
     452                final int currentZoom = ImageDisplay.this.zoom.decrementAndGet();
     453                step = currentEntry instanceof IImageTiling ? Math.pow(2, oldZoom - currentZoom) : ZOOM_STEP.get();
    367454            } else {
    368                 currentVisibleRect.width = (int) (currentVisibleRect.width / ZOOM_STEP.get());
    369                 currentVisibleRect.height = (int) (currentVisibleRect.height / ZOOM_STEP.get());
     455                final int currentZoom = ImageDisplay.this.zoom.incrementAndGet();
     456                step = currentEntry instanceof IImageTiling ? Math.pow(2, oldZoom - currentZoom) : 1 / ZOOM_STEP.get();
     457            }
     458            final VisRect oldVisibleRectImageCoordinates = currentVisibleRect.createImageCoordinateRectangle(getSize(),
     459                    currentVisibleRect, currentEntry, oldZoom);
     460            // This ensures that zoom is not out of bounds
     461            ensureMaxZoom(currentVisibleRect);
     462            final int currentZoom = ImageDisplay.this.zoom.get();
     463            // If the zoom doesn't change, stop
     464            if (currentZoom == oldZoom) {
     465                return;
    370466            }
     467            currentVisibleRect.width = (int) (currentVisibleRect.width * step);
     468            currentVisibleRect.height = (int) (currentVisibleRect.height * step);
    371469
    372470            // Check that the zoom doesn't exceed MAX_ZOOM:1
    373             ensureMaxZoom(currentVisibleRect);
     471            if (!(currentEntry instanceof IImageTiling) || currentZoom > ((IImageTiling<?>) currentEntry).getMaxZoom()) {
     472                ensureMaxZoom(currentVisibleRect);
     473            }
    374474
    375475            // The size of the visible rectangle is limited by the image size or the viewer implementation.
     476            // It can also be influenced by whether or not the current entry allows tiling. Tiling image implementations
     477            // don't care what the size of the image buffer is. In fact, the image buffer can be the size of the window.
     478            // So the image buffer really only defines the scale.
    376479            if (imageViewer != null) {
    377                 imageViewer.checkAndModifyVisibleRectSize(currentImage, currentVisibleRect);
     480                imageViewer.checkAndModifyVisibleRectSize(currentImage, currentEntry, currentVisibleRect);
    378481            } else {
    379                 currentVisibleRect.checkRectSize();
     482                currentVisibleRect.checkRectSize(currentEntry, currentZoom);
    380483            }
    381484
    382485            // Set the position of the visible rectangle, so that the mouse cursor doesn't move on the image.
    383             Rectangle drawRect = calculateDrawImageRectangle(currentVisibleRect, getSize());
    384             currentVisibleRect.x = mousePointInImg.x + ((drawRect.x - x) * currentVisibleRect.width) / drawRect.width;
    385             currentVisibleRect.y = mousePointInImg.y + ((drawRect.y - y) * currentVisibleRect.height) / drawRect.height;
     486            if (currentEntry instanceof IImageTiling) {
     487                final Point pointToUse = mousePointInImg;
     488                // Create an equivalent visible rectangle, just in image coordinates instead of image zoom coordinates
     489                final VisRect imageVisRect = currentVisibleRect.createImageCoordinateRectangle(getSize(),
     490                        oldVisibleRectImageCoordinates, currentEntry, oldZoom);
     491                imageVisRect.x = pointToUse.x +
     492                        ((oldVisibleRectImageCoordinates.x - pointToUse.x) * imageVisRect.width) /oldVisibleRectImageCoordinates.width;
     493                imageVisRect.y = pointToUse.y +
     494                        ((oldVisibleRectImageCoordinates.y - pointToUse.y) * imageVisRect.height) / oldVisibleRectImageCoordinates.height;
     495                // Convert the equivalent visible rectangle back to image zoom coordinates
     496                currentVisibleRect = imageVisRect.createCompCoordinateRectangle(getSize(), currentVisibleRect, currentEntry, currentZoom);
     497            } else {
     498                final VisRect drawRect = calculateDrawImageRectangle(currentVisibleRect, getSize());
     499                currentVisibleRect.x = mousePointInImg.x + ((drawRect.x - x) * currentVisibleRect.width) / drawRect.width;
     500                currentVisibleRect.y = mousePointInImg.y + ((drawRect.y - y) * currentVisibleRect.height) / drawRect.height;
     501            }
    386502
    387503            // The position is also limited by the image size
    388             currentVisibleRect.checkRectPos();
     504            currentVisibleRect.checkRectPos(currentEntry, currentZoom);
    389505
    390506            synchronized (ImageDisplay.this) {
    391507                if (ImageDisplay.this.entry == currentEntry) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    447563            }
    448564
    449565            // Calculate the translation to set the clicked point the center of the view.
    450             Point click = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
     566            Point click = comp2imgCoord(currentEntry, currentVisibleRect, e.getX(), e.getY(), getSize(), zoom.get());
    451567            Point center = getCenterImgCoord(currentVisibleRect);
    452568
    453569            currentVisibleRect.x += click.x - center.x;
    454570            currentVisibleRect.y += click.y - center.y;
    455571
    456             currentVisibleRect.checkRectPos();
     572            currentVisibleRect.checkRectPos(currentEntry, ImageDisplay.this.zoom.get());
    457573
    458574            synchronized (ImageDisplay.this) {
    459575                if (ImageDisplay.this.entry == currentEntry) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    467583         * a picture part) */
    468584        @Override
    469585        public void mousePressed(MouseEvent e) {
    470             Image currentImage;
    471             VisRect currentVisibleRect;
     586            final Image currentImage;
     587            final VisRect currentVisibleRect;
     588            final IImageEntry<?> imageEntry;
    472589
    473590            synchronized (ImageDisplay.this) {
    474591                currentImage = ImageDisplay.this.image;
    475592                currentVisibleRect = ImageDisplay.this.visibleRect;
     593                imageEntry = ImageDisplay.this.entry;
    476594            }
    477595
    478596            if (currentImage == null)
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    481599            selectedRect = null;
    482600
    483601            if (mouseIsDragging(e) || mouseIsZoomSelecting(e))
    484                 mousePointInImg = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
     602                mousePointInImg = comp2imgCoord(imageEntry, currentVisibleRect, e.getX(), e.getY(), getSize(), zoom.get());
    485603        }
    486604
    487605        @Override
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    503621                return;
    504622
    505623            if (mouseIsDragging(e) && mousePointInImg != null) {
    506                 Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
    507                 getIImageViewer(entry).mouseDragged(this.mousePointInImg, p, currentVisibleRect);
    508                 currentVisibleRect.checkRectPos();
     624                Point p = comp2imgCoord(imageEntry, currentVisibleRect, e.getX(), e.getY(), getSize(), zoom.get());
     625                // Create an equivalent visible rectangle, just in image coordinates instead of image zoom coordinates
     626                final VisRect imageVisRect = currentVisibleRect.createImageCoordinateRectangle(getSize(), currentVisibleRect,
     627                        imageEntry, zoom.get());
     628                getIImageViewer(entry).mouseDragged(this.mousePointInImg, p, imageVisRect);
     629                // Convert the equivalent visible rectangle back to image zoom coordinates
     630                currentVisibleRect = imageVisRect.createCompCoordinateRectangle(getSize(), currentVisibleRect, imageEntry, zoom.get());
     631                currentVisibleRect.checkRectPos(imageEntry, ImageDisplay.this.zoom.get());
    509632                synchronized (ImageDisplay.this) {
    510633                    if (ImageDisplay.this.entry == imageEntry) {
    511634                        ImageDisplay.this.visibleRect = currentVisibleRect;
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    525648            }
    526649
    527650            if (mouseIsZoomSelecting(e) && mousePointInImg != null) {
    528                 Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
     651                Point p = comp2imgCoord(imageEntry, currentVisibleRect, e.getX(), e.getY(), getSize(), zoom.get());
    529652                currentVisibleRect.checkPointInside(p);
    530653                VisRect selectedRectTemp = new VisRect(
    531654                        Math.min(p.x, mousePointInImg.x),
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    533656                        p.x < mousePointInImg.x ? mousePointInImg.x - p.x : p.x - mousePointInImg.x,
    534657                        p.y < mousePointInImg.y ? mousePointInImg.y - p.y : p.y - mousePointInImg.y,
    535658                        currentVisibleRect);
    536                 selectedRectTemp.checkRectSize();
    537                 selectedRectTemp.checkRectPos();
     659                selectedRectTemp.checkRectSize(imageEntry, zoom.get());
     660                selectedRectTemp.checkRectPos(imageEntry, zoom.get());
    538661                ImageDisplay.this.selectedRect = selectedRectTemp;
    539662                ImageDisplay.this.repaint();
    540663            }
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    574697                    selectedRect.y -= (selectedRect.height - oldHeight) / 2;
    575698                }
    576699
    577                 selectedRect.checkRectSize();
    578                 selectedRect.checkRectPos();
     700                selectedRect.checkRectSize(currentEntry, zoom.get());
     701                selectedRect.checkRectPos(currentEntry, zoom.get());
    579702            }
    580703
    581704            synchronized (ImageDisplay.this) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    642765        synchronized (this) {
    643766            this.oldEntry = this.entry;
    644767            this.entry = entry;
     768            if (entry instanceof IImageTiling) {
     769                this.zoom.set(((IImageTiling<?>) entry).getMinZoom() + 1);
     770            }
    645771            if (entry == null) {
    646772                image = null;
    647773                updateProcessedImage();
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    719845            Rectangle r = new Rectangle(currentVisibleRect);
    720846            Rectangle target = calculateDrawImageRectangle(currentVisibleRect, size);
    721847
    722             currentImageViewer.paintImage(g, currentImage, target, r);
     848            if (currentEntry instanceof IImageTiling && ((IImageTiling<?>) currentEntry).isTilingEnabled()) {
     849                currentImageViewer.paintTiledImage(g, (IImageTiling<?>) currentEntry, target, r, zoom.get(), this);
     850            } else {
     851                currentImageViewer.paintImage(g, currentImage, target, r);
     852            }
    723853            paintSelectedRect(g, target, currentVisibleRect, size);
    724854            if (currentErrorLoading && currentEntry != null) {
    725855                String loadingStr = tr("Error on file {0}", currentEntry.getDisplayName());
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    826956     */
    827957    private void paintSelectedRect(Graphics g, Rectangle target, VisRect visibleRectTemp, Dimension size) {
    828958        if (selectedRect != null) {
    829             Point topLeft = img2compCoord(visibleRectTemp, selectedRect.x, selectedRect.y, size);
    830             Point bottomRight = img2compCoord(visibleRectTemp,
     959            Point topLeft = img2compCoord(entry, visibleRectTemp, selectedRect.x, selectedRect.y, size, zoom.get());
     960            Point bottomRight = img2compCoord(entry, visibleRectTemp,
    831961                    selectedRect.x + selectedRect.width,
    832                     selectedRect.y + selectedRect.height, size);
     962                    selectedRect.y + selectedRect.height, size, zoom.get());
    833963            g.setColor(new Color(128, 128, 128, 180));
    834964            g.fillRect(target.x, target.y, target.width, topLeft.y - target.y);
    835965            g.fillRect(target.x, target.y, topLeft.x - target.x, target.height);
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    840970        }
    841971    }
    842972
    843     static Point img2compCoord(VisRect visibleRect, int xImg, int yImg, Dimension compSize) {
    844         Rectangle drawRect = calculateDrawImageRectangle(visibleRect, compSize);
     973    /**
     974     * Convert an image coordinate to a component coordinate
     975     * @param imageEntry The image entry -- only used if tiling
     976     * @param visibleRect The visible rectangle
     977     * @param xImg The x position in the component
     978     * @param yImg The y position in the component
     979     * @param compSize The component size
     980     * @param zoom The current zoom level
     981     * @return The point in the image
     982     */
     983    static Point img2compCoord(IImageEntry<?> imageEntry, VisRect visibleRect, int xImg, int yImg, Dimension compSize, int zoom) {
     984        final Rectangle drawRect;
     985        if (imageEntry instanceof IImageTiling) {
     986            final IImageTiling<?> tiler = (IImageTiling<?>) imageEntry;
     987            final double scale = tiler.getScale(zoom);
     988            // xImg = (visRect.x + xComp) / scale
     989            // xImg * scale - visRect.x = xComp
     990            return new Point((int) (xImg * scale - visibleRect.x), (int) (yImg * scale - visibleRect.y));
     991        } else {
     992            drawRect = calculateDrawImageRectangle(visibleRect, compSize);
     993        }
    845994        return new Point(drawRect.x + ((xImg - visibleRect.x) * drawRect.width) / visibleRect.width,
    846995                drawRect.y + ((yImg - visibleRect.y) * drawRect.height) / visibleRect.height);
    847996    }
    848997
    849     static Point comp2imgCoord(VisRect visibleRect, int xComp, int yComp, Dimension compSize) {
    850         Rectangle drawRect = calculateDrawImageRectangle(visibleRect, compSize);
     998    /**
     999     * Convert a component coordinate to an image coordinate
     1000     * @param imageEntry The image entry -- only used if tiling
     1001     * @param visibleRect The visible rectangle
     1002     * @param xComp The x position in the component
     1003     * @param yComp The y position in the component
     1004     * @param compSize The component size
     1005     * @param zoom The current zoom level
     1006     * @return The point in the image
     1007     */
     1008    static Point comp2imgCoord(IImageEntry<?> imageEntry, VisRect visibleRect, int xComp, int yComp, Dimension compSize, int zoom) {
     1009        final Rectangle drawRect;
     1010        if (imageEntry instanceof IImageTiling) {
     1011            final IImageTiling<?> tiler = (IImageTiling<?>) imageEntry;
     1012            final double scale = tiler.getScale(zoom);
     1013            return new Point((int) ((visibleRect.x + xComp) / scale), (int) ((visibleRect.y + yComp) / scale));
     1014        } else {
     1015            drawRect = calculateDrawImageRectangle(visibleRect, compSize);
     1016        }
    8511017        Point p = new Point(
    8521018                        ((xComp - drawRect.x) * visibleRect.width),
    8531019                        ((yComp - drawRect.y) * visibleRect.height));
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    9441110            Point center = getCenterImgCoord(currentVisibleRect);
    9451111            currentVisibleRect.setBounds(center.x - getWidth() / 2, center.y - getHeight() / 2,
    9461112                    getWidth(), getHeight());
    947             currentVisibleRect.checkRectSize();
    948             currentVisibleRect.checkRectPos();
     1113            currentVisibleRect.checkRectSize(currentEntry, this.zoom.get());
     1114            currentVisibleRect.checkRectPos(currentEntry, this.zoom.get());
    9491115        }
    9501116
    9511117        synchronized (this) {
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    10091175            rectangle.height = (int) (getSize().height / MAX_ZOOM.get());
    10101176        }
    10111177
    1012         // Set the same ratio for the visible rectangle and the display area
    1013         int hFact = rectangle.height * getSize().width;
    1014         int wFact = rectangle.width * getSize().height;
    1015         if (hFact > wFact) {
    1016             rectangle.width = hFact / getSize().height;
     1178
     1179        final IImageEntry<?> currentEntry;
     1180        synchronized (this) {
     1181            currentEntry = this.entry;
     1182        }
     1183        if (currentEntry instanceof IImageTiling) {
     1184            final int currentZoom = this.zoom.get();
     1185            IImageTiling<?> imageTiling = (IImageTiling<?>) currentEntry;
     1186            if (currentZoom > imageTiling.getMaxZoom()) {
     1187                this.zoom.set(imageTiling.getMaxZoom());
     1188            } else if (currentZoom < imageTiling.getMinZoom()) {
     1189                this.zoom.set(imageTiling.getMinZoom());
     1190            } else if (getSize().width > imageTiling.getWidth(currentZoom) * 2
     1191                && getSize().height > imageTiling.getHeight(currentZoom) * 2) {
     1192                // Don't let users make the image really small in the window
     1193                this.zoom.set(currentZoom + 1);
     1194            }
    10171195        } else {
    1018             rectangle.height = wFact / getSize().width;
     1196            // Set the same ratio for the visible rectangle and the display area
     1197            int hFact = rectangle.height * getSize().width;
     1198            int wFact = rectangle.width * getSize().height;
     1199            if (hFact > wFact) {
     1200                rectangle.width = hFact / getSize().height;
     1201            } else {
     1202                rectangle.height = wFact / getSize().width;
     1203            }
    10191204        }
    10201205    }
    10211206
    public class ImageDisplay extends JComponent implements Destroyable, PreferenceC  
    10271212    public void updateVisibleRectangle() {
    10281213        final VisRect currentVisibleRect;
    10291214        final Image mouseImage;
    1030         final IImageViewer iImageViewer;
     1215        final IImageViewer currentImageViewer;
     1216        final IImageEntry<?> imageEntry;
    10311217        synchronized (this) {
    10321218            currentVisibleRect = this.visibleRect;
    10331219            mouseImage = this.image;
    1034             iImageViewer = this.getIImageViewer(this.entry);
     1220            imageEntry = this.entry;
     1221            currentImageViewer = this.getIImageViewer(imageEntry);
    10351222        }
    1036         if (mouseImage != null && currentVisibleRect != null && iImageViewer != null) {
    1037             final Image maxImageSize = iImageViewer.getMaxImageSize(this, mouseImage);
     1223        if (mouseImage != null && currentVisibleRect != null && currentImageViewer != null) {
     1224            final Image maxImageSize = currentImageViewer.getMaxImageSize(this, mouseImage);
    10381225            final VisRect maxVisibleRect = new VisRect(0, 0, maxImageSize.getWidth(null), maxImageSize.getHeight(null));
    10391226            maxVisibleRect.setRect(currentVisibleRect);
    10401227            ensureMaxZoom(maxVisibleRect);
    10411228
    1042             maxVisibleRect.checkRectSize();
     1229            maxVisibleRect.checkRectSize(imageEntry, this.zoom.get());
    10431230            synchronized (this) {
    10441231                this.visibleRect = maxVisibleRect;
    10451232            }
  • src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java b/src/org/openstreetmap/josm/gui/layer/geoimage/ImageEntry.java
    index 7ea6371f62..4170d07a04 100644
    a b import static org.openstreetmap.josm.tools.I18n.tr;  
    66import java.awt.Dimension;
    77import java.awt.Graphics2D;
    88import java.awt.Image;
     9import java.awt.Rectangle;
    910import java.awt.geom.AffineTransform;
    1011import java.awt.image.BufferedImage;
    1112import java.io.File;
    import java.net.MalformedURLException;  
    1516import java.net.URL;
    1617import java.util.Collections;
    1718import java.util.Objects;
     19
    1820import javax.imageio.IIOParam;
    1921import javax.imageio.ImageReadParam;
    2022import javax.imageio.ImageReader;
    import javax.imageio.ImageReader;  
    2224import org.openstreetmap.josm.data.ImageData;
    2325import org.openstreetmap.josm.data.gpx.GpxImageEntry;
    2426import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
     27import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.DeepTileSet;
     28import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
    2529import org.openstreetmap.josm.tools.ExifReader;
    2630import org.openstreetmap.josm.tools.ImageProvider;
    2731import org.openstreetmap.josm.tools.Logging;
    import org.openstreetmap.josm.tools.Utils;  
    3135 * Stores info about each image, with an optional thumbnail
    3236 * @since 2662
    3337 */
    34 public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry> {
     38public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry>, IImageTiling<BufferedImage> {
    3539
    3640    private Image thumbnail;
    3741    private ImageData dataSet;
     42    private DeepTileSet deepTileSet;
    3843
    3944    /**
    4045     * Constructs a new {@code ImageEntry}.
    public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry>  
    5156        super(other);
    5257        thumbnail = other.thumbnail;
    5358        dataSet = other.dataSet;
     59        this.deepTileSet = other.deepTileSet;
    5460    }
    5561
    5662    /**
    public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry>  
    223229        return applyExifRotation(image);
    224230    }
    225231
     232    @Override
     233    public BufferedImage getTileImage(int zoom, int tileSize, int column, int row) {
     234        final Rectangle tile = IImageTiling.super.getTileDimension(zoom, column, row, tileSize);
     235        if (column < 0 || row < 0 || zoom > this.getMaxZoom() || tile.getWidth() <= 0 || tile.getHeight() <= 0) {
     236            return null;
     237        }
     238        final URL imageUrl;
     239        final BufferedImage image;
     240        try {
     241            imageUrl = getImageUrl();
     242            Logging.info(tr("Loading {0} at {1}/{2}/{3} with size {4}", imageUrl, zoom, column, row, tileSize));
     243            image = ImageProvider.read(imageUrl, true, false,
     244                    r -> this.withSubsampling(r, tile, zoom));
     245        } catch (IOException e) {
     246            Logging.error(e);
     247            return null;
     248        }
     249
     250        if (image == null) {
     251            Logging.warn("Unable to load {0}", imageUrl);
     252        }
     253        // applyExifRotation not used here since it will not work with tiled images
     254        // Instead, we will have to rotate the column/row, and then apply rotation here.
     255        return image;
     256    }
     257
    226258    protected URL getImageUrl() throws MalformedURLException {
    227259        return getFile().toURI().toURL();
    228260    }
    229261
     262    private ImageReadParam withSubsampling(ImageReader reader, final Rectangle tile, int zoom) {
     263        ImageReadParam param = reader.getDefaultReadParam();
     264        param.setSourceRegion(tile);
     265        int subsampling = (int) Math.floor(Math.max(Math.pow(IImageTiling.super.getScale(zoom), -1), 1));
     266        param.setSourceSubsampling(subsampling, subsampling, 0, 0);
     267        return param;
     268    }
     269
    230270    private ImageReadParam withSubsampling(ImageReader reader, Dimension target) {
    231271        try {
    232272            ImageReadParam param = reader.getDefaultReadParam();
    public class ImageEntry extends GpxImageEntry implements IImageEntry<ImageEntry>  
    258298        g.dispose();
    259299        return rotated;
    260300    }
     301
     302    @Override
     303    public DeepTileSet getDeepTileSet() {
     304        if (this.deepTileSet == null) {
     305            this.deepTileSet = new DeepTileSet(this.getMinZoom(), this.getMaxZoom(), this);
     306        }
     307        return this.deepTileSet;
     308    }
     309
     310    @Override
     311    public boolean isTilingEnabled() {
     312        // Flipped images are going to need more work (the column/rows will need to be translated)
     313        return IImageTiling.super.isTilingEnabled() && !ExifReader.orientationNeedsCorrection(getExifOrientation());
     314    }
    261315}
  • src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Equirectangular.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Equirectangular.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Equirectangular.java
    index f76b252344..0a5467342b 100644
    a b import java.awt.image.BufferedImage;  
    1212import java.util.Collections;
    1313import java.util.Set;
    1414
     15import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
    1516import org.openstreetmap.josm.data.imagery.street_level.Projections;
    1617import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;
     18import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.GeoImageTileLoader;
     19import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
    1720import org.openstreetmap.josm.gui.util.GuiHelper;
    1821import org.openstreetmap.josm.gui.util.imagery.CameraPlane;
    1922import org.openstreetmap.josm.gui.util.imagery.Vector3D;
    import org.openstreetmap.josm.gui.util.imagery.Vector3D;  
    2427 * @since 18246
    2528 */
    2629public class Equirectangular extends ComponentAdapter implements IImageViewer {
     30    private final GeoImageTileLoader tileLoader = new GeoImageTileLoader(null, IImageTiling.IMAGE_CACHE);
    2731    private volatile CameraPlane cameraPlane;
    2832    private volatile BufferedImage offscreenImage;
    2933
    public class Equirectangular extends ComponentAdapter implements IImageViewer {  
    108112    public Image getMaxImageSize(ImageDisplay imageDisplay, Image image) {
    109113        return this.offscreenImage;
    110114    }
     115
     116    @Override
     117    public TileLoader getTileLoader() {
     118        return this.tileLoader;
     119    }
    111120}
  • src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/IImageViewer.java
    index 3c1d41e534..c9edb55163 100644
    a b import java.awt.Point;  
    88import java.awt.Rectangle;
    99import java.awt.event.ComponentListener;
    1010import java.awt.image.BufferedImage;
     11import java.util.List;
    1112import java.util.Set;
     13import java.util.Timer;
     14import java.util.TimerTask;
     15import java.util.function.Predicate;
     16import java.util.stream.Collectors;
    1217
     18import org.openstreetmap.gui.jmapviewer.Tile;
     19import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
     20import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
     21import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
    1322import org.openstreetmap.josm.data.imagery.street_level.Projections;
    1423import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;
     24import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.GeoImageTileLoader;
     25import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
     26import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.TileSet;
    1527import org.openstreetmap.josm.gui.util.imagery.Vector3D;
    1628
    1729/**
    import org.openstreetmap.josm.gui.util.imagery.Vector3D;  
    1931 * @since 18246
    2032 */
    2133public interface IImageViewer extends ComponentListener {
     34    /**
     35     * A class for {@link IImageViewer}. Probably shouldn't be used elsewhere.
     36     */
     37    class ImageTimerTask {
     38        /** A timer so that we aren't doing many repaints in a short time frame */
     39        static final Timer REPAINT_TIMER = new Timer("IImageViewerTimer");
     40        /** The current timer task */
     41        static TimerTask timerTask;
     42        private ImageTimerTask() {
     43            // Hide constructor
     44        }
     45    }
     46
    2247    /**
    2348     * Get the supported projections for the image viewer
    2449     * @return The projections supported. Typically, only one.
    public interface IImageViewer extends ComponentListener {  
    3459     */
    3560    void paintImage(Graphics g, BufferedImage image, Rectangle target, Rectangle visibleRect);
    3661
     62    /**
     63     * Paint the image tile
     64     * @param g The graphics to paint on
     65     * @param visibleRect The visible area
     66     * @param entry The image entry (specifically, with the tile size)
     67     * @param tile The tile to paint (x, y, z)
     68     * @param zoom The current zoom
     69     */
     70    default void paintImageTile(final Graphics g, final Rectangle visibleRect, final IImageTiling<?> entry, final Tile tile, final int zoom) {
     71        final int tileSize = entry.getTileSize();
     72        final Image image;
     73        int xPositionInImage = tileSize * tile.getXtile();
     74        int yPositionInImage = tileSize * tile.getYtile();
     75        if (zoom == tile.getZoom()) {
     76            image = tile.getImage();
     77        } else {
     78            // When the zooms are not the same, we have to scale the image appropriately
     79            final double scalingFactor = Math.pow(2, (double) zoom - tile.getZoom());
     80            yPositionInImage *= scalingFactor;
     81            xPositionInImage *= scalingFactor;
     82            final Image tileImage = tile.getImage();
     83            image = tileImage.getScaledInstance((int) (tileImage.getWidth(null) * scalingFactor),
     84                    (int) (tileImage.getHeight(null) * scalingFactor), Image.SCALE_DEFAULT);
     85        }
     86        final int x = xPositionInImage - visibleRect.x;
     87        final int y = yPositionInImage - visibleRect.y;
     88        g.drawImage(image, x, y, null);
     89    }
     90
     91    /**
     92     * Paint the image
     93     * @param g The graphics to paint on
     94     * @param imageEntry The image to paint
     95     * @param target The target area
     96     * @param visibleRect The visible rectangle
     97     * @param zoom The zoom level
     98     * @param component The component to repaint when tiles are loaded
     99     */
     100    default void paintTiledImage(Graphics g, IImageTiling<?> imageEntry, Rectangle target, Rectangle visibleRect,
     101            int zoom, Component component) {
     102        if (this.getTileLoader() instanceof GeoImageTileLoader) {
     103            ((GeoImageTileLoader) this.getTileLoader()).listener = (tile, success) -> updateRepaintTimer(component);
     104        }
     105        final Predicate<Tile> paintableTile = tile -> tile.isLoaded() && !tile.hasError();
     106        final Predicate<Tile> missingTile = tile -> !tile.isLoaded() && !tile.isLoading();
     107        final List<Tile> tiles = imageEntry.getTiles(zoom, visibleRect).collect(Collectors.toList());
     108        final List<Tile> missed = tiles.stream().filter(missingTile).collect(Collectors.toList());
     109        // Attempt to paint tiles at a lower zoom that are already loaded.
     110        if (zoom > imageEntry.getMinZoom()) {
     111            final List<Tile> superToPaint = missed.stream().map(tile -> imageEntry.getCoveringTileRange(tile, zoom - 1))
     112                    .distinct().flatMap(TileSet::allTiles).distinct().collect(Collectors.toList());
     113            // Paint lower resolution filler first
     114            superToPaint.forEach(tile -> tiles.add(0, tile));
     115            // Add any unloaded lower resolution tiles to the missed tiles. We are probably going to use them as the
     116            // user pans around.
     117            superToPaint.stream().filter(missingTile).forEach(missed::add);
     118        }
     119        // Attempt to paint tiles at a higher zoom that are already loaded
     120        if (zoom < imageEntry.getMaxZoom()) {
     121            final List<Tile> subsetToPaint = missed.stream().map(tile -> imageEntry.getCoveringTileRange(tile, zoom + 1))
     122                    .distinct().flatMap(TileSet::allTiles).distinct().collect(Collectors.toList());
     123            // Paint higher resolution filler last
     124            tiles.addAll(subsetToPaint);
     125        }
     126        // Paint the tiles that are loaded on this layer
     127        tiles.stream().filter(paintableTile).forEach(tile -> this.paintImageTile(g, visibleRect, imageEntry, tile, zoom));
     128        // Start loading tiles that have yet to be loaded.
     129        missed.stream().map(this.getTileLoader()::createTileLoaderJob).forEach(TileJob::submit);
     130    }
     131
     132    /**
     133     * Update the common repaint timer
     134     * @param component The component to update
     135     */
     136    static void updateRepaintTimer(Component component) {
     137        synchronized (ImageTimerTask.REPAINT_TIMER) {
     138            if (ImageTimerTask.timerTask != null) {
     139                ImageTimerTask.timerTask.cancel();
     140            }
     141            ImageTimerTask.timerTask = new TimerTask() {
     142                @Override
     143                public void run() {
     144                    component.repaint();
     145                }
     146            };
     147            ImageTimerTask.REPAINT_TIMER.schedule(ImageTimerTask.timerTask, 100);
     148        }
     149    }
     150
     151    /**
     152     * Get the tile loader for this image
     153     * @return The tile loader.
     154     */
     155    TileLoader getTileLoader();
     156
    37157    /**
    38158     * Get the default visible rectangle for the projection
    39159     * @param component The component the image will be displayed in
    public interface IImageViewer extends ComponentListener {  
    42162     */
    43163    ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image);
    44164
     165    /**
     166     * Get the default visible rectangle for the projection and entry
     167     * @param component The component the image will be displayed in
     168     * @param image The image that will be shown
     169     * @param entry The entry that will be used
     170     * @return The default visible rectangle
     171     */
     172    default ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image, IImageEntry<?> entry) {
     173        return this.getDefaultVisibleRectangle(component, image);
     174    }
     175
    45176    /**
    46177     * Get the current rotation in the image viewer
    47178     * @return The rotation
    public interface IImageViewer extends ComponentListener {  
    77208        }
    78209    }
    79210
     211    /**
     212     * Check and modify the visible rect size to appropriate dimensions
     213     * @param visibleRect the visible rectangle to update
     214     * @param entry the entry to use for checking
     215     * @param image The image to use for checking
     216     */
     217    default void checkAndModifyVisibleRectSize(Image image, IImageEntry<?> entry, ImageDisplay.VisRect visibleRect) {
     218        if (entry instanceof IImageTiling) {
     219            final IImageTiling<?> tiling = (IImageTiling<?>) entry;
     220            if (visibleRect.width > tiling.getWidth()) {
     221                visibleRect.width = tiling.getWidth();
     222            }
     223            if (visibleRect.height > tiling.getHeight()) {
     224                visibleRect.height = tiling.getHeight();
     225            }
     226            if (visibleRect.x + visibleRect.width > tiling.getWidth()) {
     227                visibleRect.x = tiling.getWidth() - visibleRect.width;
     228            }
     229            if (visibleRect.y + visibleRect.height > tiling.getHeight()) {
     230                visibleRect.y = tiling.getHeight() - visibleRect.height;
     231            }
     232            if (visibleRect.x < 0) {
     233                visibleRect.x = 0;
     234            }
     235            if (visibleRect.y < 0) {
     236                visibleRect.y = 0;
     237            }
     238        } else {
     239            this.checkAndModifyVisibleRectSize(image, visibleRect);
     240        }
     241    }
     242
    80243    /**
    81244     * Get the maximum image size that can be displayed
    82245     * @param imageDisplay The image display
  • src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/projections/Perspective.java
    index d570c53dde..b2a721dae1 100644
    a b import java.awt.image.BufferedImage;  
    1010import java.util.EnumSet;
    1111import java.util.Set;
    1212
     13import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
     14import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
    1315import org.openstreetmap.josm.data.imagery.street_level.Projections;
    1416import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;
     17import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.GeoImageTileLoader;
     18import org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling.IImageTiling;
    1519
    1620/**
    1721 * The default perspective image viewer class.
    import org.openstreetmap.josm.gui.layer.geoimage.ImageDisplay;  
    1923 * @since 18246
    2024 */
    2125public class Perspective extends ComponentAdapter implements IImageViewer {
    22 
     26    private final GeoImageTileLoader tileLoader = new GeoImageTileLoader(null, IImageTiling.IMAGE_CACHE);
    2327    @Override
    2428    public Set<Projections> getSupportedProjections() {
    2529        return EnumSet.of(Projections.PERSPECTIVE);
    public class Perspective extends ComponentAdapter implements IImageViewer {  
    3236                r.x, r.y, r.x + r.width, r.y + r.height, null);
    3337    }
    3438
     39    @Override
     40    public TileLoader getTileLoader() {
     41        return this.tileLoader;
     42    }
     43
    3544    @Override
    3645    public ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image) {
    3746        return new ImageDisplay.VisRect(0, 0, image.getWidth(null), image.getHeight(null));
    3847    }
     48
     49    @Override
     50    public ImageDisplay.VisRect getDefaultVisibleRectangle(Component component, Image image, IImageEntry<?> entry) {
     51        if (entry instanceof IImageTiling) {
     52            return new ImageDisplay.VisRect(0, 0, ((IImageTiling) entry).getWidth(), ((IImageTiling) entry).getHeight());
     53        }
     54        return IImageViewer.super.getDefaultVisibleRectangle(component, image, entry);
     55    }
    3956}
  • new file src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/DeepTileSet.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/DeepTileSet.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/DeepTileSet.java
    new file mode 100644
    index 0000000000..8532f936a3
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
     3
     4import java.util.Arrays;
     5import java.util.Collection;
     6import java.util.Iterator;
     7import java.util.List;
     8import java.util.Objects;
     9import java.util.Set;
     10import java.util.stream.Collectors;
     11import java.util.stream.Stream;
     12
     13import org.openstreetmap.gui.jmapviewer.MemoryTileCache;
     14import org.openstreetmap.gui.jmapviewer.TileXY;
     15import org.openstreetmap.gui.jmapviewer.interfaces.TileCache;
     16
     17/**
     18 * A collection of caching tile sets
     19 * @author Taylor Smock
     20 * @since xxx
     21 */
     22public class DeepTileSet implements Set<TileSet> {
     23    private final TileCache memoryTileCache = new MemoryTileCache();
     24
     25    private final int minZoom;
     26    private final int maxZoom;
     27    private final TileSet[] tileSets;
     28    private final TileSet nullTileSet = new TileSet();
     29    private final IImageTiling<?> imageTiling;
     30
     31    public DeepTileSet(final int minZoom, final int maxZoom, final IImageTiling<?> imageTiling) {
     32        this.minZoom = minZoom;
     33        this.maxZoom = maxZoom;
     34        if (minZoom > maxZoom) {
     35            throw new IllegalArgumentException(minZoom + " > " + maxZoom);
     36        }
     37        this.tileSets = new TileSet[maxZoom - minZoom + 1];
     38        this.imageTiling = imageTiling;
     39    }
     40
     41    public TileSet getTileSet(int zoom) {
     42        if (zoom < minZoom) {
     43            return nullTileSet;
     44        } else if (zoom > maxZoom) {
     45            zoom = maxZoom;
     46        }
     47        synchronized (tileSets) {
     48            TileSet ts = tileSets[zoom-minZoom];
     49            if (ts == null) {
     50                ts = new TileSet(new TileXY(0, 0),
     51                        new TileXY(this.imageTiling.getTileXMax(zoom), this.imageTiling.getTileYMax(zoom)),
     52                        zoom, this.memoryTileCache, this.imageTiling);
     53                ts.allTilesCreate();
     54                tileSets[zoom-minZoom] = ts;
     55            }
     56            return ts;
     57        }
     58    }
     59
     60    /**
     61     * Get the tile cache for this deep tile set
     62     * @return The tile cache
     63     */
     64    TileCache getTileCache() {
     65        return this.memoryTileCache;
     66    }
     67
     68    @Override
     69    public int size() {
     70        return Math.toIntExact(Stream.of(this.tileSets).filter(Objects::nonNull).count());
     71    }
     72
     73    @Override
     74    public boolean isEmpty() {
     75        return Stream.of(this.tileSets).allMatch(Objects::isNull);
     76    }
     77
     78    @Override
     79    public boolean contains(Object o) {
     80        Objects.requireNonNull(o);
     81        return Arrays.asList(this.tileSets).contains(o);
     82    }
     83
     84    @Override
     85    public Iterator<TileSet> iterator() {
     86        return Stream.of(this.tileSets).filter(Objects::nonNull).iterator();
     87    }
     88
     89    @Override
     90    public Object[] toArray() {
     91        return Stream.of(this.tileSets).filter(Objects::nonNull).toArray();
     92    }
     93
     94    @Override
     95    public <T> T[] toArray(T[] array) {
     96        return Arrays.asList(this.tileSets).toArray(array);
     97    }
     98
     99    @Override
     100    public boolean add(TileSet tileSet) {
     101        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support add");
     102    }
     103
     104    @Override
     105    public boolean remove(Object object) {
     106        Objects.requireNonNull(object);
     107        for (int i = 0; i < this.tileSets.length; i++) {
     108            if (object.equals(this.tileSets[i])) {
     109                this.tileSets[i] = null;
     110                return true;
     111            }
     112        }
     113        return false;
     114    }
     115
     116    @Override
     117    public boolean containsAll(Collection<?> collection) {
     118        Objects.requireNonNull(collection);
     119        List<TileSet> list = Arrays.asList(this.tileSets);
     120        return list.containsAll(collection);
     121    }
     122
     123    @Override
     124    public boolean addAll(Collection<? extends TileSet> collection) {
     125        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support addAll");
     126    }
     127
     128    @Override
     129    public boolean removeAll(Collection<?> collection) {
     130        Objects.requireNonNull(collection);
     131        final int count = this.size();
     132        collection.forEach(this::remove);
     133        return count != this.size();
     134    }
     135
     136    @Override
     137    public boolean retainAll(Collection<?> collection) {
     138        Objects.requireNonNull(collection);
     139        List<?> toRemove = Stream.of(this.tileSets).filter(Objects::nonNull)
     140                .filter(set -> !collection.contains(set)).collect(Collectors.toList());
     141        return this.removeAll(toRemove);
     142    }
     143
     144    @Override
     145    public void clear() {
     146        this.memoryTileCache.clear();
     147        Arrays.fill(this.tileSets, null);
     148    }
     149}
  • new file src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/GeoImageTileLoader.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/GeoImageTileLoader.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/GeoImageTileLoader.java
    new file mode 100644
    index 0000000000..a96f462b4c
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
     3
     4import java.util.concurrent.ThreadPoolExecutor;
     5
     6import org.apache.commons.jcs3.access.behavior.ICacheAccess;
     7import org.apache.commons.jcs3.engine.behavior.ICache;
     8import org.openstreetmap.gui.jmapviewer.Tile;
     9import org.openstreetmap.gui.jmapviewer.interfaces.CachedTileLoader;
     10import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
     11import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
     12import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
     13import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
     14import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
     15import org.openstreetmap.josm.data.imagery.TMSCachedTileLoader;
     16import org.openstreetmap.josm.tools.CheckParameterUtil;
     17
     18/**
     19 * A tile loader for geo images
     20 * @author Taylor Smock
     21 * @since xxx
     22 */
     23public class GeoImageTileLoader implements TileLoader, CachedTileLoader {
     24
     25    protected final ICacheAccess<String, BufferedImageCacheEntry> cache;
     26    public TileLoaderListener listener;
     27    private static final ThreadPoolExecutor DEFAULT_DOWNLOAD_JOB_DISPATCHER = TMSCachedTileLoader.getNewThreadPoolExecutor("GeoImage-tiler-%d");
     28
     29    /**
     30     * Constructor for the GeoImageTileLoader
     31     * @param listener The listener to notify when tile loading finishes
     32     * @param cache The cache to use
     33     */
     34    public GeoImageTileLoader(final TileLoaderListener listener, final ICacheAccess<String, BufferedImageCacheEntry> cache) {
     35        CheckParameterUtil.ensureParameterNotNull(cache);
     36        this.cache = cache;
     37        this.listener = listener;
     38    }
     39
     40    @Override
     41    public void clearCache(TileSource source) {
     42        this.cache.remove(source.getName() + ICache.NAME_COMPONENT_DELIMITER);
     43    }
     44
     45    @Override
     46    public TileJob createTileLoaderJob(Tile tile) {
     47        return new GeoImageTileLoaderJob(this.listener, tile,
     48                this.cache, DEFAULT_DOWNLOAD_JOB_DISPATCHER
     49        );
     50    }
     51
     52    @Override
     53    public boolean hasOutstandingTasks() {
     54        return DEFAULT_DOWNLOAD_JOB_DISPATCHER.getTaskCount() > DEFAULT_DOWNLOAD_JOB_DISPATCHER.getCompletedTaskCount();
     55    }
     56
     57    @Override
     58    public void cancelOutstandingTasks() {
     59        for (Runnable runnable : DEFAULT_DOWNLOAD_JOB_DISPATCHER.getQueue()) {
     60            DEFAULT_DOWNLOAD_JOB_DISPATCHER.remove(runnable);
     61        }
     62    }
     63}
  • new file src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/GeoImageTileLoaderJob.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/GeoImageTileLoaderJob.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/GeoImageTileLoaderJob.java
    new file mode 100644
    index 0000000000..79945abe13
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
     3
     4import java.awt.image.RenderedImage;
     5import java.io.ByteArrayInputStream;
     6import java.io.ByteArrayOutputStream;
     7import java.io.File;
     8import java.io.IOException;
     9import java.net.URL;
     10import java.time.Duration;
     11import java.util.Collections;
     12import java.util.concurrent.ThreadPoolExecutor;
     13
     14import javax.imageio.ImageIO;
     15
     16import org.apache.commons.jcs3.access.behavior.ICacheAccess;
     17import org.openstreetmap.gui.jmapviewer.Tile;
     18import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
     19import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
     20import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
     21import org.openstreetmap.josm.data.cache.CacheEntry;
     22import org.openstreetmap.josm.data.cache.CacheEntryAttributes;
     23import org.openstreetmap.josm.data.cache.ICachedLoaderListener;
     24import org.openstreetmap.josm.data.cache.JCSCachedTileLoaderJob;
     25import org.openstreetmap.josm.data.imagery.TileJobOptions;
     26import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
     27import org.openstreetmap.josm.tools.Logging;
     28
     29/**
     30 * A job to load geoimage tiles
     31 * @author Taylor Smock
     32 * @since xxx
     33 */
     34public class GeoImageTileLoaderJob extends JCSCachedTileLoaderJob<String, BufferedImageCacheEntry> implements TileJob,
     35        ICachedLoaderListener {
     36    private static final TileJobOptions DEFAULT_OPTIONS = new TileJobOptions(0, 0, Collections.emptyMap(),
     37            Duration.ofHours(1).getSeconds());
     38    private final Tile tile;
     39    private final TileLoaderListener listener;
     40
     41    public GeoImageTileLoaderJob(TileLoaderListener listener, Tile tile,
     42            ICacheAccess<String, BufferedImageCacheEntry> cache, ThreadPoolExecutor geoimagetileloader) {
     43        super(cache, DEFAULT_OPTIONS, geoimagetileloader);
     44        this.listener = listener;
     45        this.tile = tile;
     46    }
     47
     48    @Override
     49    public void submit() {
     50        submit(false);
     51    }
     52
     53    @Override
     54    public void submit(boolean force) {
     55        try {
     56            super.submit(this, force);
     57
     58        } catch (IllegalArgumentException | IOException e) {
     59            Logging.warn(e);
     60        }
     61    }
     62
     63    @Override
     64    public String getCacheKey() {
     65        return tile.getKey();
     66    }
     67
     68    @Override
     69    public URL getUrl() throws IOException {
     70        if (this.tile.getTileSource() instanceof IImageEntry) {
     71            return ((IImageEntry<?>) this.tile.getTileSource()).getFile().toURI().toURL();
     72        }
     73        return null;
     74    }
     75
     76    @Override
     77    public void loadingFinished(CacheEntry data, CacheEntryAttributes attributes, LoadResult result) {
     78        try {
     79            if (data instanceof BufferedImageCacheEntry) {
     80                this.tile.setImage(((BufferedImageCacheEntry) data).getImage());
     81            } else if (data != null) {
     82                this.tile.loadImage(new ByteArrayInputStream(data.getContent()));
     83            }
     84            this.tile.finishLoading();
     85        } catch (IOException e) {
     86            this.tile.setError(e);
     87        }
     88        this.listener.tileLoadingFinished(this.tile, LoadResult.SUCCESS == result);
     89    }
     90
     91    @Override
     92    protected BufferedImageCacheEntry createCacheEntry(byte[] content) {
     93        return new BufferedImageCacheEntry(content);
     94    }
     95
     96    @Override
     97    protected byte[] loadObjectBytes(File file) throws IOException {
     98        if (this.tile.getTileSource() instanceof IImageTiling) {
     99            final IImageTiling<?> tileSource = (IImageTiling<?>) this.tile.getTileSource();
     100            final RenderedImage image = tileSource.getTileImage(this.tile.getZoom(), this.tile.getXtile(), this.tile.getYtile());
     101            if (image == null) {
     102                throw new IOException("No image loaded for " + file.toString());
     103            }
     104            final ByteArrayOutputStream output = new ByteArrayOutputStream(image.getHeight() * image.getWidth());
     105            ImageIO.write(image, "jpg", output);
     106            return output.toByteArray();
     107        }
     108        return super.loadObjectBytes(file);
     109    }
     110}
  • new file src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTiling.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTiling.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTiling.java
    new file mode 100644
    index 0000000000..eb43fb09b3
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
     3
     4import java.awt.Dimension;
     5import java.awt.Image;
     6import java.awt.Point;
     7import java.awt.Rectangle;
     8import java.awt.image.RenderedImage;
     9import java.io.IOException;
     10import java.util.List;
     11import java.util.Map;
     12import java.util.Objects;
     13import java.util.function.DoubleToIntFunction;
     14import java.util.function.IntUnaryOperator;
     15import java.util.stream.IntStream;
     16import java.util.stream.Stream;
     17
     18import org.apache.commons.jcs3.access.CacheAccess;
     19import org.openstreetmap.gui.jmapviewer.Tile;
     20import org.openstreetmap.gui.jmapviewer.TileXY;
     21import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
     22import org.openstreetmap.gui.jmapviewer.interfaces.IProjected;
     23import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
     24import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
     25import org.openstreetmap.josm.data.cache.JCSCacheManager;
     26import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
     27import org.openstreetmap.josm.gui.layer.imagery.TilePosition;
     28import org.openstreetmap.josm.spi.preferences.Config;
     29
     30/**
     31 * An interface for tiled images. Primarily used to reduce memory usage in large images.
     32 * @author Taylor Smock
     33 * @param <I> The image type returned
     34 * @since xxx
     35 */
     36public interface IImageTiling<I extends Image & RenderedImage> extends TileSource {
     37
     38    /**
     39     * The default tile size for the image tiles -- each tile takes 1024 px * 1024 px * 4 bytes = 4 MiB max
     40     * A 4k image (4160x3120) has (Math.ceil(4160/1024) * Math.ceil(3120/1024) = 20 tiles). Some tiles are almost empty.
     41     * This gives a reasonable number of tiles for most image sizes.
     42     */
     43    int DEFAULT_TILE_SIZE = 1024;
     44
     45    /** A good default minimum zoom (the image size is {@link #DEFAULT_TILE_SIZE} max, at 1024 it is 5) */
     46    int DEFAULT_MIN_ZOOM = (int) Math.round(Math.log(Math.sqrt(DEFAULT_TILE_SIZE))/Math.log(2));
     47
     48    /** A cache for images */
     49    CacheAccess<String, BufferedImageCacheEntry> IMAGE_CACHE =
     50            JCSCacheManager.getCache("iimagetiling", 100, 1_000,
     51                    Config.getDirs().getCacheDirectory(true).getAbsolutePath());
     52
     53    /**
     54     * Get the size of the image at a specified zoom level
     55     * @param zoom The zoom level. Zoom 0 == 1 px for the image. Zoom 1 == 4 px for the image.
     56     * @return The number of pixels (max, for a square image)
     57     */
     58    static long getSizeAtZoom(final int zoom) {
     59        final long dimension = 1L << zoom;
     60        return dimension * dimension;
     61    }
     62
     63    /**
     64     * Get the default tile size.
     65     * @return The tile size to use
     66     */
     67    default int getDefaultTileSize() {
     68        return DEFAULT_TILE_SIZE;
     69    }
     70
     71    /**
     72     * Get the tile size.
     73     * @return The tile size to use
     74     */
     75    default int getTileSize() {
     76        return this.getDefaultTileSize();
     77    }
     78
     79    /**
     80     * Get the maximum zoom that the image supports
     81     * Feel free to override and cache the result for performance reasons.
     82     *
     83     * @return The maximum zoom of the image
     84     */
     85    default int getMaxZoom() {
     86        final int maxSize = Math.max(this.getWidth(), this.getHeight());
     87        return (int) Math.round(Math.ceil(Math.log(maxSize) / Math.log(2)));
     88    }
     89
     90    /**
     91     * Get the minimum zoom that the image supports or makes sense
     92     * @return The minimum zoom that makes sense
     93     */
     94    default int getMinZoom() {
     95        final IntUnaryOperator minZoom = input -> Math.toIntExact(
     96                Math.round(Math.floor(this.getMaxZoom() + Math.log((double) this.getTileSize() / input) / Math.log(2))));
     97        return Math.min(minZoom.applyAsInt(this.getWidth()), minZoom.applyAsInt(this.getHeight()));
     98    }
     99
     100    /**
     101     * Get the current scale of the image
     102     * @param zoom The zoom level
     103     * @return The scaling of the image at the specified level
     104     */
     105    default double getScale(final int zoom) {
     106        return Math.pow(2, (double) zoom - this.getMaxZoom());
     107    }
     108
     109    /**
     110     * Get the width of the image
     111     * @return The width of the image
     112     */
     113    int getWidth();
     114
     115    /**
     116     * Get the width of the image at a specified scale
     117     * @param zoom The zoom to use
     118     * @return The width at the specified scale
     119     */
     120    default int getWidth(final int zoom) {
     121        return Math.toIntExact(Math.round(this.getScale(zoom) * this.getWidth()));
     122    }
     123
     124    /**
     125     * Get the height of the image
     126     * @return The height of the image
     127     */
     128    int getHeight();
     129
     130    /**
     131     * Get the height of the image at a specified scale
     132     * @param zoom The zoom to use
     133     * @return The height at the specified scale
     134     */
     135    default int getHeight(final int zoom) {
     136        return Math.toIntExact(Math.round(this.getScale(zoom) * this.getHeight()));
     137    }
     138
     139    /**
     140     * Get the size of the image
     141     * @return The image size at the zoom level
     142     */
     143    default Dimension getSize(int zoom) {
     144        return new Dimension(this.getWidth(zoom), this.getHeight(zoom));
     145    }
     146
     147    /**
     148     * Get the number of rows at a specified zoom level
     149     * @param zoom The zoom level
     150     * @return The number of rows
     151     */
     152    default int getRows(final int zoom) {
     153        return this.getRows(zoom, this.getTileSize());
     154    }
     155
     156    /**
     157     * Get the number of rows at a specified zoom level
     158     * @param zoom The zoom level
     159     * @param tileSize The tile size
     160     * @return The number of rows
     161     */
     162    default int getRows(final int zoom, final int tileSize) {
     163        final int height = this.getHeight(zoom);
     164        return Math.toIntExact(Math.round(Math.ceil(height / (double) tileSize)));
     165    }
     166
     167    /**
     168     * Get the number of columns at a specified zoom level
     169     * @param zoom The zoom level
     170     * @return The number of columns
     171     */
     172    default int getColumns(final int zoom) {
     173        return this.getColumns(zoom, this.getTileSize());
     174    }
     175
     176    /**
     177     * Get the number of columns at a specified zoom level
     178     * @param zoom The zoom level
     179     * @param tileSize The tile size
     180     * @return The number of columns
     181     */
     182    default int getColumns(final int zoom, final int tileSize) {
     183        final int width = this.getWidth(zoom);
     184        return Math.toIntExact(Math.round(Math.ceil(width / (double) tileSize)));
     185    }
     186
     187    /**
     188     * A DeepTileSet (to avoid creating and loading tiles over and over)
     189     * @return The deap tile set
     190     */
     191    DeepTileSet getDeepTileSet();
     192
     193    /**
     194     * Get the image to show for a specific tile location. This should be cached by the implementation in most cases.
     195     * Top-left corner is 0,0
     196     * @param zoom The zoom to use
     197     * @param tileSize The tile size to use
     198     * @param column The column to get (x)
     199     * @param row The row to get (y)
     200     * @return The image to display (not padded). May be {@code null}.
     201     */
     202    I getTileImage(int zoom, int tileSize, int column, int row);
     203
     204    /**
     205     * Get the image to show for a specific tile location with the default tile size
     206     * Top-left corner is 0,0
     207     * @param zoom The zoom to use
     208     * @param column The column to get (x)
     209     * @param row The row to get (y)
     210     * @return The image to display (not padded). May be {@code null}.
     211     */
     212    default I getTileImage(final int zoom, final int column, final int row) {
     213        return this.getTileImage(zoom, this.getTileSize(), column, row);
     214    }
     215
     216    /**
     217     * Get the subsection of the image to show
     218     * Top-left corner is 0,0
     219     * @param zoom The zoom to use
     220     * @param column The column to get (x)
     221     * @param row The row to get (y)
     222     * @return The subsection of the image to get
     223     */
     224    default Rectangle getTileDimension(final int zoom, final int column, final int row) {
     225        return this.getTileDimension(zoom, column, row, this.getTileSize());
     226    }
     227
     228    /**
     229     * Get the subsection of the image to show
     230     * Top-left corner is 0,0
     231     * @param zoom The zoom to use
     232     * @param column The column to get (x)
     233     * @param row The row to get (y)
     234     * @param tileSize the tile size to use
     235     * @return The subsection of the image to get
     236     */
     237    default Rectangle getTileDimension(final int zoom, final int column, final int row, final int tileSize) {
     238        final double scale = this.getScale(zoom); // e.g., 1, 1/2, 1/4, etc.
     239        final DoubleToIntFunction roundToInt = dbl -> Math.toIntExact(Math.round(Math.floor(dbl)));
     240        final int x = roundToInt.applyAsInt(column * tileSize / scale);
     241        final int y = roundToInt.applyAsInt(row * tileSize / scale);
     242        final int defaultDimension = roundToInt.applyAsInt(tileSize / scale);
     243        final int width = Math.min(defaultDimension, roundToInt.applyAsInt(this.getWidth() - column * tileSize / scale));
     244        final int height = Math.min(defaultDimension, roundToInt.applyAsInt(this.getHeight() - row * tileSize / scale));
     245        return new Rectangle(x, y, width, height);
     246    }
     247
     248    /**
     249     * Get the tiles for a zoom level given a visible rectangle
     250     * @param zoom The zoom to get
     251     * @param visibleRect The rectangle to get
     252     * @return A stream of tiles to images (may be parallel)
     253     */
     254    default Stream<Tile> getTiles(int zoom, Rectangle visibleRect) {
     255        // We very specifically want to "overscan" -- this fixes some issues where the image isn't fully loaded
     256        final int startX = Math.max(0, Math.toIntExact(Math.round(Math.floor(visibleRect.getMinX() / this.getTileSize()))) - 1);
     257        final int startY = Math.max(0, Math.toIntExact(Math.round(Math.floor(visibleRect.getMinY() / this.getTileSize()))) - 1);
     258        final int endX = Math.min(this.getColumns(zoom), Math.toIntExact(Math.round(Math.ceil(visibleRect.getMaxX() / this.getTileSize()))) + 1);
     259        final int endY = Math.min(this.getRows(zoom), Math.toIntExact(Math.round(Math.ceil(visibleRect.getMaxY() / this.getTileSize()))) + 1);
     260        final TileSet tileSet = this.getDeepTileSet().getTileSet(zoom);
     261        tileSet.allTilesCreate();
     262        return IntStream.range(startX, endX).mapToObj(x -> IntStream.range(startY, endY).mapToObj(y -> new TilePosition(x, y, zoom)))
     263                .flatMap(stream -> stream).map(tileSet::getTile).filter(Objects::nonNull);
     264    }
     265
     266    /**
     267     * Check if tiling is enabled for this object.
     268     *
     269     * @return {@code true} if tiling should be u sed
     270     */
     271    default boolean isTilingEnabled() {
     272        return true;
     273    }
     274
     275    /* ************** The following are filler methods ***************** */
     276
     277    @Override
     278    default String getName() {
     279        if (this instanceof IImageEntry) {
     280            return ((IImageEntry<?>) this).getDisplayName();
     281        }
     282        return this.getClass().getSimpleName();
     283    }
     284
     285    @Override
     286    default String getId() {
     287        return this.getClass().getName();
     288    }
     289
     290    @Override
     291    default String getTileUrl(int zoom, int tilex, int tiley) throws IOException {
     292        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     293    }
     294
     295    @Override
     296    default String getTileId(int zoom, int tilex, int tiley) {
     297        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     298    }
     299
     300    @Override
     301    default double getDistance(double la1, double lo1, double la2, double lo2) {
     302        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     303    }
     304
     305    @Override
     306    default Point latLonToXY(double lat, double lon, int zoom) {
     307        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     308    }
     309
     310    @Override
     311    default Point latLonToXY(ICoordinate point, int zoom) {
     312        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     313    }
     314
     315    @Override
     316    default ICoordinate xyToLatLon(Point point, int zoom) {
     317        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     318    }
     319
     320    @Override
     321    default ICoordinate xyToLatLon(int x, int y, int zoom) {
     322        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     323    }
     324
     325    @Override
     326    default TileXY latLonToTileXY(double lat, double lon, int zoom) {
     327        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     328    }
     329
     330    @Override
     331    default TileXY latLonToTileXY(ICoordinate point, int zoom) {
     332        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     333    }
     334
     335    @Override
     336    default ICoordinate tileXYToLatLon(TileXY xy, int zoom) {
     337        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     338    }
     339
     340    @Override
     341    default ICoordinate tileXYToLatLon(Tile tile) {
     342        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     343    }
     344
     345    @Override
     346    default ICoordinate tileXYToLatLon(int x, int y, int zoom) {
     347        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     348    }
     349
     350    @Override
     351    default int getTileXMax(int zoom) {
     352        return this.getColumns(zoom);
     353    }
     354
     355    @Override
     356    default int getTileXMin(int zoom) {
     357        return 0;
     358    }
     359
     360    @Override
     361    default int getTileYMax(int zoom) {
     362        return this.getRows(zoom);
     363    }
     364
     365    @Override
     366    default int getTileYMin(int zoom) {
     367        return 0;
     368    }
     369
     370    @Override
     371    default boolean isNoTileAtZoom(Map<String, List<String>> headers, int statusCode, byte[] content) {
     372        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     373    }
     374
     375    @Override
     376    default Map<String, String> getMetadata(Map<String, List<String>> headers) {
     377        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     378    }
     379
     380    @Override
     381    default IProjected tileXYtoProjected(int x, int y, int zoom) {
     382        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     383    }
     384
     385    @Override
     386    default TileXY projectedToTileXY(IProjected p, int zoom) {
     387        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     388    }
     389
     390    @Override
     391    default boolean isInside(Tile inner, Tile outer) {
     392        // If the outer zoom is greater than inner zoom, then it the inner tile cannot be inside the outer tile
     393        final int zoomDifference = inner.getZoom() - outer.getZoom();
     394        // Example: outer(z13) > inner(z12), so outer covers a smaller "real" area than the inner.
     395        if (zoomDifference < 0) {
     396            return false;
     397        }
     398        // Each zoom level has 4x as many tiles, 2x in each direction
     399        final double tileScale = Math.pow(2, zoomDifference);
     400        final double minX = inner.getXtile() * tileScale;
     401        final double minY = inner.getXtile() * tileScale;
     402        final double maxX = minX + tileScale - 1;
     403        final double maxY = minY + tileScale - 1;
     404        return inner.getXtile() >= minX && inner.getXtile() <= maxX
     405                && inner.getYtile() >= minY && inner.getYtile() <= maxY;
     406    }
     407
     408    @Override
     409    default TileSet getCoveringTileRange(Tile tile, int newZoom) {
     410        final int tileZoom = tile.getZoom();
     411        final double tileScale = Math.pow(2, newZoom - tileZoom);
     412        final DoubleToIntFunction clampDouble = dbl -> Math.toIntExact(Math.round(dbl));
     413        if (tileScale < 1) {
     414            final TileXY superTile = new TileXY(clampDouble.applyAsInt(tile.getXtile() * tileScale),
     415                    clampDouble.applyAsInt(tile.getYtile() * tileScale));
     416            return new TileSet(superTile, superTile, newZoom, this.getDeepTileSet().getTileCache(), this);
     417        }
     418        final TileXY subTile1 = new TileXY(clampDouble.applyAsInt(tile.getXtile() * tileScale),
     419                clampDouble.applyAsInt(tile.getYtile() * tileScale));
     420        final TileXY subTile2 = new TileXY(subTile1.getXIndex() + clampDouble.applyAsInt(tileScale),
     421                subTile1.getY() + clampDouble.applyAsInt(tileScale));
     422        return new TileSet(subTile1, subTile2, newZoom, this.getDeepTileSet().getTileCache(), this);
     423    }
     424
     425    @Override
     426    default String getServerCRS() {
     427        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     428    }
     429
     430    @Override
     431    default boolean requiresAttribution() {
     432        return false;
     433    }
     434
     435    @Override
     436    default String getAttributionText(int zoom, ICoordinate topLeft, ICoordinate botRight) {
     437        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     438    }
     439
     440    @Override
     441    default String getAttributionLinkURL() {
     442        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     443    }
     444
     445    @Override
     446    default Image getAttributionImage() {
     447        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     448    }
     449
     450    @Override
     451    default String getAttributionImageURL() {
     452        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     453    }
     454
     455    @Override
     456    default String getTermsOfUseText() {
     457        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     458    }
     459
     460    @Override
     461    default String getTermsOfUseURL() {
     462        throw new UnsupportedOperationException("This method is not currently supported for " + this.getClass().getName());
     463    }
     464}
  • new file src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/TileSet.java

    diff --git a/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/TileSet.java b/src/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/TileSet.java
    new file mode 100644
    index 0000000000..4572f73fe6
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
     3
     4import java.util.Collection;
     5import java.util.Iterator;
     6import java.util.List;
     7import java.util.Objects;
     8import java.util.Set;
     9import java.util.function.Function;
     10import java.util.stream.Collectors;
     11import java.util.stream.IntStream;
     12import java.util.stream.Stream;
     13
     14import org.openstreetmap.gui.jmapviewer.Tile;
     15import org.openstreetmap.gui.jmapviewer.TileRange;
     16import org.openstreetmap.gui.jmapviewer.TileXY;
     17import org.openstreetmap.gui.jmapviewer.interfaces.TileCache;
     18import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
     19import org.openstreetmap.josm.gui.layer.imagery.TilePosition;
     20
     21/**
     22 * A set of tiles
     23 * @author Taylor Smock
     24 * @since xxx
     25 */
     26public class TileSet extends TileRange implements Set<Tile> {
     27    private final TileCache tileCache;
     28    private final TileSource tileSource;
     29
     30    /**
     31     * Constructs a new {@code TileRange}.
     32     * @param t1 first tile
     33     * @param t2 second tile
     34     * @param zoom zoom level
     35     */
     36    public TileSet(final TileXY t1, final TileXY t2, final int zoom, final TileCache tileCache, final TileSource tileSource) {
     37        super(t1, t2, zoom);
     38        this.tileCache = tileCache;
     39        this.tileSource = tileSource;
     40    }
     41
     42    TileSet() {
     43        this.tileCache = null;
     44        this.tileSource = null;
     45    }
     46
     47    /**
     48     * Gets a stream of all tile positions in this set
     49     * @return A stream of all positions
     50     */
     51    public Stream<TilePosition> tilePositions() {
     52        if (zoom == 0) {
     53            return Stream.empty(); // Tileset is empty
     54        } else {
     55            return IntStream.rangeClosed(minX, maxX).mapToObj(
     56                    x -> IntStream.rangeClosed(minY, maxY).mapToObj(y -> new TilePosition(x, y, zoom))
     57            ).flatMap(Function.identity());
     58        }
     59    }
     60
     61    /**
     62     * Get a tile at a position
     63     * @param tilePosition The position to get
     64     * @return The tile (may be null)
     65     */
     66    public Tile getTile(final TilePosition tilePosition) {
     67        return this.tileCache.getTile(this.tileSource, tilePosition.getX(), tilePosition.getY(), tilePosition.getZoom());
     68    }
     69
     70    protected List<Tile> allTilesCreate() {
     71        return this.allTiles(this::createOrGetTiles).collect(Collectors.toList());
     72    }
     73
     74    private Tile createOrGetTiles(final TilePosition tilePosition) {
     75        Tile tile = this.tileCache.getTile(this.tileSource, tilePosition.getX(), tilePosition.getY(), tilePosition.getZoom());
     76        if (tile != null) {
     77            return tile;
     78        }
     79        tile = new Tile(this.tileSource, tilePosition.getX(), tilePosition.getY(), tilePosition.getZoom());
     80        this.tileCache.addTile(tile);
     81        return tile;
     82    }
     83
     84    /**
     85     * Get all tiles
     86     * @return All tiles in this set
     87     */
     88    public Stream<Tile> allTiles() {
     89        return this.allTiles(this::getTile);
     90    }
     91
     92    private Stream<Tile> allTiles(Function<TilePosition, Tile> mapper) {
     93        return tilePositions().map(mapper).filter(Objects::nonNull);
     94    }
     95
     96    @Override
     97    public boolean isEmpty() {
     98        return this.allTiles(tile -> this.tileCache.getTile(this.tileSource, tile.getX(), tile.getY(), tile.getZoom()))
     99                .anyMatch(Objects::nonNull);
     100    }
     101
     102    @Override
     103    public boolean contains(Object o) {
     104        if (o instanceof Tile) {
     105            Tile tile = (Tile) o;
     106            return this.getTile(new TilePosition(tile.getXtile(), tile.getYtile(), tile.getZoom())) != null;
     107        }
     108        return false;
     109    }
     110
     111    @Override
     112    public Iterator<Tile> iterator() {
     113        return allTiles().iterator();
     114    }
     115
     116    @Override
     117    public Object[] toArray() {
     118        return allTiles().toArray();
     119    }
     120
     121    @Override
     122    public <T> T[] toArray(T[] array) {
     123        return allTiles().collect(Collectors.toList()).toArray(array);
     124    }
     125
     126    @Override
     127    public boolean add(Tile tile) {
     128        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support add");
     129    }
     130
     131    @Override
     132    public boolean remove(Object o) {
     133        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support remove");
     134    }
     135
     136    @Override
     137    public boolean containsAll(Collection<?> c) {
     138        return c.stream().allMatch(this::contains);
     139    }
     140
     141    @Override
     142    public boolean addAll(Collection<? extends Tile> c) {
     143        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support add");
     144    }
     145
     146    @Override
     147    public boolean retainAll(Collection<?> c) {
     148        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support remove");
     149    }
     150
     151    @Override
     152    public boolean removeAll(Collection<?> c) {
     153        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support remove");
     154    }
     155
     156    @Override
     157    public void clear() {
     158        throw new UnsupportedOperationException(this.getClass().getSimpleName() + " does not support remove");
     159    }
     160}
  • new file test/unit/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTilingTest.java

    diff --git a/test/unit/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTilingTest.java b/test/unit/org/openstreetmap/josm/gui/layer/geoimage/viewers/tiling/IImageTilingTest.java
    new file mode 100644
    index 0000000000..a5b8d11d6e
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.geoimage.viewers.tiling;
     3
     4import static org.junit.jupiter.api.Assertions.assertAll;
     5import static org.junit.jupiter.api.Assertions.assertEquals;
     6import static org.junit.jupiter.api.Assertions.assertNotEquals;
     7import static org.junit.jupiter.api.Assertions.assertNotNull;
     8import static org.junit.jupiter.api.Assertions.assertTrue;
     9
     10import java.awt.Image;
     11import java.awt.image.BufferedImage;
     12import java.util.concurrent.atomic.AtomicInteger;
     13import java.util.stream.Stream;
     14
     15import org.junit.jupiter.params.ParameterizedTest;
     16import org.junit.jupiter.params.provider.Arguments;
     17import org.junit.jupiter.params.provider.MethodSource;
     18import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
     19
     20/**
     21 * Test class for {@link IImageTiling}
     22 * @author Taylor Smock
     23 */
     24@BasicPreferences
     25class IImageTilingTest {
     26    static Stream<Arguments> testSizeAtZoom() {
     27        return Stream.of(Arguments.of(0, 1L), Arguments.of(1, 4L));
     28    }
     29
     30    @ParameterizedTest
     31    @MethodSource
     32    void testSizeAtZoom(int zoom, long expected) {
     33        assertEquals(expected, IImageTiling.getSizeAtZoom(zoom));
     34    }
     35
     36    static Stream<Arguments> getImageTilingSamples() {
     37        return Stream.of(
     38                Arguments.of(new ImageTiling(new BufferedImage(5000, 2500, BufferedImage.TYPE_INT_ARGB)), 13)
     39        );
     40    }
     41
     42    @ParameterizedTest
     43    @MethodSource("getImageTilingSamples")
     44    void testGetTileSizes(final ImageTiling imageTiling) {
     45        // The fake class uses default methods
     46        assertEquals(imageTiling.getTileSize(), imageTiling.getDefaultTileSize());
     47    }
     48
     49    @ParameterizedTest
     50    @MethodSource("getImageTilingSamples")
     51    void testGetMaxZoom(final ImageTiling imageTiling, final int maxZoom) {
     52        assertEquals(maxZoom, imageTiling.getMaxZoom());
     53    }
     54
     55    @ParameterizedTest
     56    @MethodSource("getImageTilingSamples")
     57    void testGetScale(final ImageTiling imageTiling, final int maxZoom) {
     58        assertEquals(1, imageTiling.getScale(maxZoom));
     59        assertEquals(0.5, imageTiling.getScale(maxZoom - 1));
     60        assertEquals(0.25, imageTiling.getScale(maxZoom - 2));
     61    }
     62
     63    @ParameterizedTest
     64    @MethodSource("getImageTilingSamples")
     65    void testGetWidth(final IImageTiling imageTiling, final int maxZoom) {
     66        assertEquals(imageTiling.getWidth(), imageTiling.getWidth(maxZoom));
     67        assertEquals(imageTiling.getWidth() / 2, imageTiling.getWidth(maxZoom - 1));
     68        assertEquals(imageTiling.getWidth() / 4, imageTiling.getWidth(maxZoom - 2));
     69    }
     70
     71    @ParameterizedTest
     72    @MethodSource("getImageTilingSamples")
     73    void testGetHeight(final IImageTiling imageTiling, final int maxZoom) {
     74        assertEquals(imageTiling.getHeight(), imageTiling.getHeight(maxZoom));
     75        assertEquals(imageTiling.getHeight() / 2, imageTiling.getHeight(maxZoom - 1));
     76        assertEquals(imageTiling.getHeight() / 4, imageTiling.getHeight(maxZoom - 2));
     77    }
     78
     79    @ParameterizedTest
     80    @MethodSource("getImageTilingSamples")
     81    void testGetRows(final IImageTiling imageTiling, final int maxZoom) {
     82        assertEquals(3, imageTiling.getRows(maxZoom));
     83        assertEquals(2, imageTiling.getRows(maxZoom - 1));
     84    }
     85
     86    @ParameterizedTest
     87    @MethodSource("getImageTilingSamples")
     88    void testGetColumns(final IImageTiling imageTiling, final int maxZoom) {
     89        assertEquals(5, imageTiling.getColumns(maxZoom));
     90        assertEquals(3, imageTiling.getColumns(maxZoom - 1));
     91    }
     92
     93    @ParameterizedTest
     94    @MethodSource("getImageTilingSamples")
     95    void testGetTileImage(final IImageTiling imageTiling, final int maxZoom) {
     96        assertNotNull(imageTiling.getTileImage(maxZoom, 0, 0));
     97        final Image cornerImage = imageTiling.getTileImage(maxZoom, imageTiling.getColumns(maxZoom) - 1, imageTiling.getRows(maxZoom) - 1);
     98        assertAll(() -> assertNotEquals(-1, cornerImage.getWidth(null)),
     99                () -> assertNotEquals(-1, cornerImage.getHeight(null)),
     100                () -> assertTrue(imageTiling.getTileSize() > cornerImage.getWidth(null)),
     101                () -> assertTrue(imageTiling.getTileSize() > cornerImage.getHeight(null)));
     102    }
     103
     104    @ParameterizedTest
     105    @MethodSource("getImageTilingSamples")
     106    void testGetTileDimension(final IImageTiling imageTiling) {
     107        imageTiling.getTileDimension(0, 0, 0);
     108    }
     109
     110    private static class ImageTiling implements IImageTiling {
     111        private final int width;
     112        private final int height;
     113        private final Image image;
     114        final AtomicInteger counter = new AtomicInteger(0);
     115        ImageTiling(final Image image) {
     116            this.image = image;
     117            this.width = image.getWidth(null);
     118            this.height = image.getHeight(null);
     119        }
     120
     121        @Override
     122        public int getWidth() {
     123            return this.width;
     124        }
     125
     126        @Override
     127        public int getHeight() {
     128            return this.height;
     129        }
     130
     131        @Override
     132        public DeepTileSet getDeepTileSet() {
     133            return new DeepTileSet(this.getMinZoom(), this.getMaxZoom(), this);
     134        }
     135
     136        @Override
     137        public BufferedImage getTileImage(int zoom, int tileSize, int column, int row) {
     138            this.counter.incrementAndGet();
     139            if (image instanceof BufferedImage) {
     140                final BufferedImage bufferedImage = (BufferedImage) image;
     141                return bufferedImage.getSubimage(column * tileSize, row * tileSize,
     142                        Math.min(tileSize, bufferedImage.getWidth() - column * tileSize - 1),
     143                        Math.min(tileSize, bufferedImage.getHeight() - row * tileSize - 1));
     144            }
     145            throw new UnsupportedOperationException("The test ImageTiling class only supports BufferedImages");
     146        }
     147    }
     148}