/* * Copyright (C) 2015 Patryk Strach * * This file is part of Virtual Slide Viewer. * * Virtual Slide Viewer is free software: you can redistribute it and/or modify it under * the terms of the GNU General Public License as published by the Free Software Foundation, * either version 3 of the License, or (at your option) any later version. * * Virtual Slide Viewer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. * See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with Virtual Slide Viewer. * If not, see <http://www.gnu.org/licenses/>. */ package virtualslideviewer.ui.imageviewing; import java.awt.Dimension; import java.awt.Point; import java.awt.Rectangle; import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; import java.util.ArrayList; import java.util.List; import virtualslideviewer.util.ImageUtil; /** * A camera used to control the visible region for rendering purposes. * * It maintains the state of a visible region, such as its position, size and current zoom. */ public class Camera { /** * Listener notified when visible region changes. */ public interface Listener { /** * Called when visible region has changed. */ public void onVisibleRegionUpdate(); } private Point2D.Double mPosition = new Point2D.Double(); private Dimension mViewportSize = new Dimension(0, 0); private Dimension mImageSize = new Dimension(0, 0); private double mZoom = 1.0; private List<Listener> mListeners = new ArrayList<>(); /** * Adds a listener which will be notified of any changes of visible region. */ public void addChangeListener(Listener newListener) { if(newListener == null) throw new IllegalArgumentException("newListener cannot be null."); mListeners.add(newListener); } /** * Sets the size of an image which can be explored by this camera. */ public void setImageSize(Dimension imageSize) { if(imageSize == null) throw new IllegalArgumentException("regionSize cannot be null!"); mImageSize.setSize(imageSize); mListeners.forEach(l -> l.onVisibleRegionUpdate()); } /** * Sets the camera position in relative coordinates. * * Camera position represents the center of visible region. * * @param position Camera position in relative coordinates, i.e. (0.0, 0.0) - top left corner of explorable region, * (1.0, 1.0) - bottom right corner */ public void setPosition(Point2D position) { if(position == null) throw new IllegalArgumentException("position cannot be null!"); mPosition.setLocation(position); mListeners.forEach(l -> l.onVisibleRegionUpdate()); } public Dimension getViewportSize() { return new Dimension(mViewportSize); } /** * Sets the size of visible region. * * @param viewportSize The size of viewport in pixels. */ public void setViewportSize(Dimension viewportSize) { if(viewportSize == null) throw new IllegalArgumentException("regionSize cannot be null!"); mViewportSize.setSize(viewportSize); mListeners.forEach(l -> l.onVisibleRegionUpdate()); } /** * Translates the camera by specified amount. * * @param translation The translation in pixels. */ public void pan(Point translation) { if(translation == null) throw new IllegalArgumentException("translation cannot be null."); Rectangle2D correctedVisibleRegionBounds = getVisibleRegionBounds(); Dimension currentImageSize = getImageSizeAtZoom(mZoom); mPosition.x = correctedVisibleRegionBounds.getCenterX() + translation.getX() / currentImageSize.getWidth(); mPosition.y = correctedVisibleRegionBounds.getCenterY() + translation.getY() / currentImageSize.getHeight(); mListeners.forEach(l -> l.onVisibleRegionUpdate()); } private Dimension getImageSizeAtZoom(double zoom) { return new Dimension((int)(mImageSize.getWidth() * zoom), (int)(mImageSize.getHeight() * zoom)); } /** * Sets new zoom level. * * @param zoom The percentage of a zoom. 1.0 means native image size. */ public void setZoom(double zoom) { if(zoom <= 0.0) throw new IllegalArgumentException("Zoom has to be larger than 0%."); mZoom = zoom; mListeners.forEach(l -> l.onVisibleRegionUpdate()); } /** * Zooms the camera at specified point. * * @param zoomIncrement Zoom increment to use. Positive values zooms in, while negative the view out. * @param zoomAt The point in <b>pixels</b> <b>relative</b> to the upper left corner of visible region * at which the image will be zoomed. Has to be positive. */ public void setZoom(double zoom, Point zoomAt) { if(zoomAt == null) throw new IllegalArgumentException("zoomAt cannot be null."); if(zoomAt.x < 0 || zoomAt.y < 0) throw new IllegalArgumentException("Point to zoom at cannot be negative."); Rectangle2D oldRegionBounds = getVisibleRegionBounds(); double oldZoomLevel = mZoom; setZoom(zoom); mPosition.setLocation(computeNewPosition(oldZoomLevel, oldRegionBounds, zoomAt)); } /** * Zooms the camera to fit the entire image. */ public void zoomToFit() { double zoomPercentToFit = ImageUtil.getScaleToFit(mImageSize, mViewportSize); setZoom(Math.min(zoomPercentToFit, 1.0)); } /** * Computes new camera position after zooming. * * The new position is computed in such a way that the pixel pointed at by <b>zoomAt</b> parameter * is in the same place relative to upper left corner of visible region as it was before zooming. */ private Point2D computeNewPosition(double previousZoomLevel, Rectangle2D previousRegionBounds, Point zoomAt) { double zoomAtInRelativeCoordX = zoomAt.getX() / getImageSizeAtZoom(previousZoomLevel).getWidth(); double zoomAtInRelativeCoordY = zoomAt.getY() / getImageSizeAtZoom(previousZoomLevel).getHeight(); double zoomAtInImageSpaceX = previousRegionBounds.getX() + zoomAtInRelativeCoordX; double zoomAtInImageSpaceY = previousRegionBounds.getY() + zoomAtInRelativeCoordY; double originalCenterToPointDiffX = previousRegionBounds.getCenterX() - zoomAtInImageSpaceX; double originalCenterToPointDiffY = previousRegionBounds.getCenterY() - zoomAtInImageSpaceY; double scale = mZoom / previousZoomLevel; double newX = zoomAtInImageSpaceX + originalCenterToPointDiffX / scale; double newY = zoomAtInImageSpaceY + originalCenterToPointDiffY / scale; return new Point2D.Double(newX, newY); } /** * Returns the bounds of currently visible region in relative coordinates. * * In the case of a position, (0.0, 0.0) means upper left corner of image and (1.0, 1.0) is a lower right corner. * In the case of a size, it is percentages of image size at current zoom level, i.e. 1.0 means that entire image is visible. * * The returned value is corrected before returning to ensure that region outside of image is not visible. */ public Rectangle2D getVisibleRegionBounds() { double relativeWidth = Math.min(mViewportSize.getWidth() / getImageSizeAtZoom(mZoom).getWidth(), 1.0); double relativeHeight = Math.min(mViewportSize.getHeight() / getImageSizeAtZoom(mZoom).getHeight(), 1.0); double newCenterX = Math.min(Math.max(mPosition.getX(), relativeWidth * 0.5), 1.0 - relativeWidth * 0.5); double newCenterY = Math.min(Math.max(mPosition.getY(), relativeHeight * 0.5), 1.0 - relativeHeight * 0.5); double relativeX = newCenterX - relativeWidth * 0.5; double relativeY = newCenterY - relativeHeight * 0.5; return new Rectangle2D.Double(relativeX, relativeY, relativeWidth, relativeHeight); } /** * Gets an <b>absolute</b> bounds of visible region of image in pixels at current zoom. */ public Rectangle getAbsoluteVisibleRegionBounds() { return relativeToAbsoluteBounds(getVisibleRegionBounds(), getImageSizeAtZoom(mZoom)); } /** * Returns current zoom level. */ public double getZoom() { return mZoom; } /** * Converts bounds in relative coordinates to an absolute ones. * * Relative coordinates are in 0.0 - 1.0 range, while absolute coordinates are in range 0 - (maxsize - 1). * * @param relativeBounds Relative bounds to convert to absolute bounds. * @param areaSize The maximum value of an absolute position. * * @return Bounds in absolute coordinates. */ public static Rectangle relativeToAbsoluteBounds(Rectangle2D relativeBounds, Dimension areaSize) { if(relativeBounds == null) throw new IllegalArgumentException("relativeBounds cannot be null."); if(areaSize == null) throw new IllegalArgumentException("areaSize cannot be null."); int absoluteX = (int)Math.round(relativeBounds.getX() * areaSize.getWidth()); int absoluteY = (int)Math.round(relativeBounds.getY() * areaSize.getHeight()); int absoluteWidth = (int)Math.max(relativeBounds.getWidth() * areaSize.getWidth(), 1); int absoluteHeight = (int)Math.max(relativeBounds.getHeight() * areaSize.getHeight(), 1); return new Rectangle(absoluteX, absoluteY, absoluteWidth, absoluteHeight); } /** * Returns the best resolution level to use at current zoom. */ public int getBestResolutionForCurrentZoom(List<Dimension> resolutions, double transitionThreshold) { if(resolutions == null) throw new IllegalArgumentException("resolutions cannot be null."); int widthAtCurrentZoom = getImageSizeAtZoom(mZoom).width; if(widthAtCurrentZoom <= resolutions.get(0).width) return 0; for(int i = 1; i < resolutions.size(); i++) { if(widthAtCurrentZoom < resolutions.get(i).width) { int lowerResolutionLevel = i - 1; int higherResolutionLevel = i; int min = resolutions.get(lowerResolutionLevel).width; int max = resolutions.get(higherResolutionLevel).width; double transition = (widthAtCurrentZoom - min) / (double)(max - min); return (transition < transitionThreshold) ? lowerResolutionLevel : higherResolutionLevel; } } return resolutions.size() - 1; } }