From 5bc8ff224c315ebba7b4986940040d07a9f873f3 Mon Sep 17 00:00:00 2001 From: fourbites Date: Wed, 27 Aug 2025 16:30:09 +0200 Subject: [PATCH] Add zoom support to VirtualMediaPanel --- .../threerings/media/VirtualMediaPanel.java | 124 ++++++++--- .../com/threerings/media/ZoomManager.java | 192 ++++++++++++++++++ .../miso/client/MisoScenePanel.java | 6 +- 3 files changed, 291 insertions(+), 31 deletions(-) create mode 100644 core/src/main/java/com/threerings/media/ZoomManager.java diff --git a/core/src/main/java/com/threerings/media/VirtualMediaPanel.java b/core/src/main/java/com/threerings/media/VirtualMediaPanel.java index 0b88ad6e..17814633 100644 --- a/core/src/main/java/com/threerings/media/VirtualMediaPanel.java +++ b/core/src/main/java/com/threerings/media/VirtualMediaPanel.java @@ -19,27 +19,26 @@ package com.threerings.media; -import java.util.ArrayList; - import java.awt.Graphics; import java.awt.Graphics2D; +import java.awt.Point; import java.awt.Rectangle; import java.awt.event.MouseEvent; import java.awt.event.MouseWheelEvent; +import java.awt.geom.AffineTransform; +import java.util.ArrayList; +import java.util.List; import javax.swing.SwingUtilities; import com.google.common.collect.Lists; - import com.samskivert.util.RunAnywhere; - +import static com.threerings.media.Log.log; import com.threerings.media.image.ImageUtil; import com.threerings.media.image.Mirage; import com.threerings.media.util.MathUtil; import com.threerings.media.util.Pathable; -import static com.threerings.media.Log.log; - /** * Extends the base media panel with the notion of a virtual coordinate system. All entities in * the virtual media panel have virtual coordinates and the virtual media panel displays a window @@ -67,6 +66,11 @@ public class VirtualMediaPanel extends MediaPanel public VirtualMediaPanel (FrameManager framemgr) { super(framemgr); + + addZoomListener((oldZoom, newZoom) -> { + _vbounds = _zoomManager.rescaleBounds(_vbounds, getWidth(), getHeight()); + _metamgr.getRegionManager().addDirtyRegion(_vbounds); + }); } /** @@ -149,7 +153,8 @@ public class VirtualMediaPanel extends MediaPanel @Override protected void processMouseEvent (MouseEvent event) { - event.translatePoint(_vbounds.x, _vbounds.y); + event.translatePoint(_nx, _ny); + _zoomManager.adjustMouseEvent(event, _vbounds); super.processMouseEvent(event); } @@ -160,7 +165,8 @@ public class VirtualMediaPanel extends MediaPanel @Override protected void processMouseMotionEvent (MouseEvent event) { - event.translatePoint(_vbounds.x, _vbounds.y); + event.translatePoint(_nx, _ny); + _zoomManager.adjustMouseEvent(event, _vbounds); super.processMouseMotionEvent(event); } @@ -171,7 +177,8 @@ public class VirtualMediaPanel extends MediaPanel @Override protected void processMouseWheelEvent (MouseWheelEvent event) { - event.translatePoint(_vbounds.x, _vbounds.y); + event.translatePoint(_nx, _ny); + _zoomManager.adjustMouseEvent(event, _vbounds); super.processMouseWheelEvent(event); } @@ -179,7 +186,7 @@ public class VirtualMediaPanel extends MediaPanel protected void dirtyScreenRect (Rectangle rect) { // translate the screen rect into happy coordinates - rect.translate(_vbounds.x, _vbounds.y); + rect.translate(_nx, _ny); _metamgr.getRegionManager().addDirtyRegion(rect); } @@ -201,6 +208,7 @@ public class VirtualMediaPanel extends MediaPanel // keep track of the size of the viewport _vbounds.width = getWidth(); _vbounds.height = getHeight(); + _vbounds = _zoomManager.scaleOnCenter(_vbounds); // we need to obtain our absolute screen coordinates to work // around the Windows copyArea() bug @@ -235,16 +243,23 @@ public class VirtualMediaPanel extends MediaPanel protected void adjustBoundsCenter () { - int width = getWidth(), height = getHeight(); + int width = _vbounds.width, height = _vbounds.height; // adjusts our view location to track any pathable we might be tracking trackPathable(); + // our offset is from the center of the view panel, so we need to adjust + // coordinates to account for zoom. + Point viewCenter = ZoomManager.center(_vbounds); + Point panelCenter = new Point(getWidth() / 2, getHeight() / 2); + int offsetX = viewCenter.x - panelCenter.x; + int offsetY = viewCenter.y - panelCenter.y; + // if we have a new target location, we'll need to generate dirty // regions for the area exposed by the scrolling - if (_nx != _vbounds.x || _ny != _vbounds.y) { + if (_nx != offsetX || _ny != offsetY) { // determine how far we'll be moving on this tick - int dx = _nx - _vbounds.x, dy = _ny - _vbounds.y; + int dx = _nx - offsetX, dy = _ny - offsetY; // log.info("Scrolling into place [n=(" + _nx + ", " + _ny + // "), t=(" + _vbounds.x + ", " + _vbounds.y + @@ -254,29 +269,29 @@ public class VirtualMediaPanel extends MediaPanel _dx = dx; _dy = dy; + // now go ahead and update our location so that changes in between here and the call + // to paint() for this tick don't booch everything + _vbounds.x += _dx; + _vbounds.y += _dy; + // these are used to prevent the vertical strip from // overlapping the horizontal strip - int sy = _ny, shei = height; + int sy = _vbounds.y, shei = height; // and add invalid rectangles for the exposed areas if (dy > 0) { shei = Math.max(shei - dy, 0); - _metamgr.getRegionManager().invalidateRegion(_nx, _ny + height - dy, width, dy); + _metamgr.getRegionManager().invalidateRegion(_vbounds.x, _vbounds.y + height - dy, width, dy); } else if (dy < 0) { sy -= dy; - _metamgr.getRegionManager().invalidateRegion(_nx, _ny, width, -dy); + _metamgr.getRegionManager().invalidateRegion(_vbounds.x, _vbounds.y, width, -dy); } if (dx > 0) { - _metamgr.getRegionManager().invalidateRegion(_nx + width - dx, sy, dx, shei); + _metamgr.getRegionManager().invalidateRegion(_vbounds.x + width - dx, sy, dx, shei); } else if (dx < 0) { - _metamgr.getRegionManager().invalidateRegion(_nx, sy, -dx, shei); + _metamgr.getRegionManager().invalidateRegion(_vbounds.x, sy, -dx, shei); } - - // now go ahead and update our location so that changes in between here and the call - // to paint() for this tick don't booch everything - _vbounds.x = _nx; _vbounds.y = _ny; - addObscurerDirtyRegions(false); // let derived classes react if they so desire @@ -313,8 +328,8 @@ public class VirtualMediaPanel extends MediaPanel return; } - int width = getWidth(), height = getHeight(); - int nx = _vbounds.x, ny = _vbounds.y; + int width = _vbounds.width, height = _vbounds.height; + int nx = _nx, ny = _ny; // figure out where to move switch (_fmode) { @@ -356,8 +371,18 @@ public class VirtualMediaPanel extends MediaPanel @Override protected void paint (Graphics2D gfx, Rectangle[] dirty) { - // if we're scrolling, go ahead and do the business - if (_dx != 0 || _dy != 0) { + // if we zoom too quickly, the zoomManager scale may not be in sync with the paint tick + // so we check based on the actual view bounds to be safe + double scale = getWidth() / (double) _vbounds.width; + + if (scale != 1.0) { + // if our scale is not 1.0, we can't rely on copyArea(). it doesn't support non integer + // movements, so it will cause an unpleasant stutter as the rounding errors accumulate. + // TODO?: keep track of movement over multiple frames to cancel out the rounding errors. + dirty = new Rectangle[]{_vbounds}; + + // if we're scrolling, go ahead and do the business + } else if (_dx != 0 || _dy != 0) { int width = getWidth(), height = getHeight(); int cx = (_dx > 0) ? _dx : 0; int cy = (_dy > 0) ? _dy : 0; @@ -396,21 +421,28 @@ public class VirtualMediaPanel extends MediaPanel _dx = 0; _dy = 0; } + AffineTransform originalTransform = gfx.getTransform(); + + int centerX = _vbounds.x + _vbounds.width / 2; + int centerY = _vbounds.y + _vbounds.height / 2; + // translate into happy space - gfx.translate(-_vbounds.x, -_vbounds.y); + gfx.translate(getWidth() / 2, getHeight() / 2); + gfx.scale(scale, scale); + gfx.translate(-centerX, -centerY); // now do the actual painting super.paint(gfx, dirty); // translate back out of happy space - gfx.translate(_vbounds.x, _vbounds.y); + gfx.setTransform(originalTransform); } @Override protected void constrainToBounds (Rectangle dirty) { SwingUtilities.computeIntersection( - _vbounds.x, _vbounds.y, getWidth(), getHeight(), dirty); + _vbounds.x, _vbounds.y, _vbounds.width, _vbounds.height, dirty); } @Override @@ -429,6 +461,38 @@ public class VirtualMediaPanel extends MediaPanel } } + protected void enableZoom() { + addMouseWheelListener(_zoomManager.createMouseWheelListener()); + } + + protected void enableZoom(double min, double max) { + enableZoom(); + setZoomConstraints(min, max); + } + + protected void setZoomConstraints(double min, double max) { + _zoomManager.setMinZoomLevel(min); + _zoomManager.setMaxZoomLevel(max); + } + + protected void setZoomLevel(double zoom) { + _zoomManager.setZoomLevel(zoom); + } + + protected double getZoomLevel() { + return _zoomManager.getZoomLevel(); + } + + protected void addZoomListener(ZoomManager.ZoomListener listener) { + _zoomManager.addZoomListener(listener); + } + + protected void removeZoomListener(ZoomManager.ZoomListener listener) { + _zoomManager.removeZoomListener(listener); + } + + protected ZoomManager _zoomManager = new ZoomManager(); + /** Our viewport bounds in virtual coordinates. */ protected Rectangle _vbounds = new Rectangle(); diff --git a/core/src/main/java/com/threerings/media/ZoomManager.java b/core/src/main/java/com/threerings/media/ZoomManager.java new file mode 100644 index 00000000..7c2a21e7 --- /dev/null +++ b/core/src/main/java/com/threerings/media/ZoomManager.java @@ -0,0 +1,192 @@ +package com.threerings.media; + +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelListener; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +public class ZoomManager { + + protected static final double ZOOM_SNAP_THRESHOLD = 0.05; + protected static final double ZOOM_OUT_FACTOR = 0.9; + protected static final double ZOOM_IN_FACTOR = 1.1; + + protected double _zoomLevel = 1.0; + protected double _minZoomLevel = 0.25; + protected double _maxZoomLevel = 2.0; + + public interface ZoomListener { + + void zoomChanged(double oldZoom, double newZoom); + } + + private final List _listeners = new CopyOnWriteArrayList<>(); + + public ZoomManager() { + } + + public ZoomManager(double zoomLevel) { + _zoomLevel = zoomLevel; + } + + public ZoomManager(double zoomLevel, double minZoomLevel, double maxZoomLevel) { + _zoomLevel = zoomLevel; + _minZoomLevel = minZoomLevel; + _maxZoomLevel = maxZoomLevel; + } + + /** + * Returns the center point of the given rectangle. + * + * @param rect the rectangle to find the center of + * @return the center point of the rectangle + */ + public static Point center(Rectangle rect) { + int centerX = rect.x + rect.width / 2; + int centerY = rect.y + rect.height / 2; + return new Point(centerX, centerY); + } + + /** + * Rescales the given bounds rectangle based on the current zoom level. + * Useful when the zoom level changes to update the viewport. + * + * @param viewport the rectangle to rescale + * @param width the new width + * @param height the new height + * @return a new rectangle representing the rescaled bounds + */ + public Rectangle rescaleBounds(Rectangle viewPort, int width, int height) { + Point center = center(viewPort); + int newWidth = (int) (width / _zoomLevel); + int newHeight = (int) (height / _zoomLevel); + return new Rectangle(center.x - newWidth / 2, center.y - newHeight / 2, newWidth, newHeight); + } + + /** + * Scales the given viewport rectangle based on the current zoom level, + * centering the scaling on the viewport's center. + * + * @param viewPort the viewport rectangle to scale + * @return a new rectangle representing the scaled viewport + */ + public Rectangle scaleOnCenter(Rectangle viewPort) { + Point center = center(viewPort); + int newWidth = (int) (viewPort.width / _zoomLevel); + int newHeight = (int) (viewPort.height / _zoomLevel); + return new Rectangle(center.x - newWidth / 2, center.y - newHeight / 2, newWidth, newHeight); + } + + /** + * Adjusts the given mouse event for the current zoom level. Uses scaled + * distance from the center of the panel + * + * @param event the mouse event to adjust + */ + public void adjustMouseEvent(MouseEvent event, Rectangle viewPort) { + Point adjustedPoint = screenToVirtual(viewPort, event.getPoint()); + event.translatePoint( + adjustedPoint.x - event.getX(), + adjustedPoint.y - event.getY()); + } + + /** + * Adjusts the given point for the current zoom level. Zoom happens from the + * center of the viewport. + * + * @param viewPort the viewport rectangle + * @param toAdjust the point to adjust + * @return a new point representing the adjusted coordinates + */ + public Point screenToVirtual(Rectangle viewPort, Point screenPoint) { + Point center = center(viewPort); + double scale = _zoomLevel; + int virtX = (int) ((screenPoint.x - center.x) / scale + center.x); + int virtY = (int) ((screenPoint.y - center.y) / scale + center.y); + return new Point(virtX, virtY); + } + + /** + * Creates a mouse wheel listener that adjusts the zoom level. + * + * @return a mouse wheel listener + */ + public MouseWheelListener createMouseWheelListener() { + return e -> { + int notches = e.getWheelRotation(); + scaleZoomLevel(notches > 0 ? ZOOM_OUT_FACTOR : ZOOM_IN_FACTOR); + }; + } + + public double getZoomLevel() { + return _zoomLevel; + } + + /** + * Scales the current zoom level by the given factor. + * + * @param scale the scale factor + * @return the actual scale factor applied after clamping to min and max + */ + public double scaleZoomLevel(double scale) { + double oldZoom = _zoomLevel; + setZoomLevel(_zoomLevel * scale); + return _zoomLevel / oldZoom; + } + + /** + * Sets the current zoom level, clamping it to the min and max zoom levels. + * + * @param zoomLevel the new zoom level + */ + public void setZoomLevel(double zoomLevel) { + double oldZoom = _zoomLevel; + if (zoomLevel < _minZoomLevel) { + _zoomLevel = _minZoomLevel; + } else if (zoomLevel > _maxZoomLevel) { + _zoomLevel = _maxZoomLevel; + } else { + _zoomLevel = zoomLevel; + // if we're close to 1.0, we might as well snap to it + if (Math.abs(_zoomLevel - 1.0) < ZOOM_SNAP_THRESHOLD) { + _zoomLevel = 1.0; + } + } + + if (oldZoom != _zoomLevel) { + fireZoomChanged(oldZoom, _zoomLevel); + } + } + + public double getMaxZoomLevel() { + return _maxZoomLevel; + } + + public void setMaxZoomLevel(double maxZoomLevel) { + _maxZoomLevel = maxZoomLevel; + } + + public double getMinZoomLevel() { + return _minZoomLevel; + } + + public void setMinZoomLevel(double minZoomLevel) { + _minZoomLevel = minZoomLevel; + } + + public void addZoomListener(ZoomListener listener) { + _listeners.add(listener); + } + + public void removeZoomListener(ZoomListener listener) { + _listeners.remove(listener); + } + + private void fireZoomChanged(double oldZoom, double newZoom) { + for (ZoomListener listener : _listeners) { + listener.zoomChanged(oldZoom, newZoom); + } + } +} diff --git a/core/src/main/java/com/threerings/miso/client/MisoScenePanel.java b/core/src/main/java/com/threerings/miso/client/MisoScenePanel.java index 8abd9a2e..05ecb2f6 100644 --- a/core/src/main/java/com/threerings/miso/client/MisoScenePanel.java +++ b/core/src/main/java/com/threerings/miso/client/MisoScenePanel.java @@ -118,6 +118,10 @@ public class MisoScenePanel extends VirtualMediaPanel _resolver.start(); _resolvers.put(_ctx, _resolver); } + + addZoomListener((oldZoom, newZoom) -> { + rethink(); + }); } /** @@ -716,7 +720,7 @@ public class MisoScenePanel extends VirtualMediaPanel // compute the tile coordinates of our upper left screen coordinate and request a rethink // if they've changed - MisoUtil.screenToTile(_metrics, _vbounds.x, _vbounds.y, _tcoords); + MisoUtil.screenToTile(_metrics, _nx, _ny, _tcoords); if (_ulpos == null || !_tcoords.equals(_ulpos)) { // if this is a forced rethink (_ulpos is null), we might delay paint as a result of // it, but only if we queue up blocks for resolution in our rethink