I have a Java app where the user can crop a subimage from its original self. The crop area is selected by drawing a rectangle over the original image. The rectangle can then be resized diagonally. And so far, everything works!
The user also has an option to lock the aspect ratio of the rectangle to 4:3. I can achieve this simply by setting the width to w = h / 4 * 3;
However, when it comes to resizing with locked ratio, the rectangle behaves strangely and is no longer stationary when dragging from the northwest corner (see gif below). Had the same problem with southwest corner, but that could be fixed by instead setting height to h = w / 3 * 4;
but I can't figure out how to do this mathematically for the northwest corner. I have provided a copy-pastable demo for experimentation:
public class CropDemo {
public static void main(String[] args) {
CropPanel cropPanel = new CropPanel();
cropPanel.setPreferredSize(new Dimension(640, 480));
JFrame jFrame = new JFrame("Crop Panel");
jFrame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
jFrame.getContentPane().add(cropPanel);
jFrame.setResizable(false);
jFrame.pack();
jFrame.setLocationRelativeTo(null);
jFrame.setVisible(true);
}
}
class CropPanel extends JPanel {
private static final long serialVersionUID = 1L;
private boolean fixedRatio = true;
private Rectangle rectangle;
private Point clickPoint;
private static final int HOVERING = 0;
private static final int MOVING = 1;
private static final int RESIZING = 2;
public CropPanel() {
setCursor(Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR));
MouseAdapter mouseHandler = new MouseAdapter() {
private Point startPoint = null;
@Override
public void mouseClicked(MouseEvent e) {
if (rectangle != null && getCursorState() == HOVERING) {
rectangle = null;
repaint();
}
}
@Override
public void mousePressed(MouseEvent e) {
clickPoint = e.getPoint();
startPoint = e.getPoint();
}
@Override
public void mouseMoved(MouseEvent e) {
if (rectangle != null) {
Point mouse = e.getPoint();
int width = rectangle.x + rectangle.width;
int height = rectangle.y + rectangle.height;
final int off = 5;
if (mouse.x > rectangle.x - off && mouse.x < width + off && mouse.y > rectangle.y - off
&& mouse.y < height + off) {
if (mouse.x <= rectangle.x + off && mouse.y >= height - off) {
setCursor(Cursor.getPredefinedCursor(Cursor.SW_RESIZE_CURSOR));
} else if (mouse.x >= width - off && mouse.y >= height - off) {
setCursor(Cursor.getPredefinedCursor(Cursor.SE_RESIZE_CURSOR));
} else if (mouse.x <= rectangle.x + off && mouse.y <= rectangle.y + off) {
setCursor(Cursor.getPredefinedCursor(Cursor.NW_RESIZE_CURSOR));
} else if (mouse.x >= width - off && mouse.y <= rectangle.y + off) {
setCursor(Cursor.getPredefinedCursor(Cursor.NE_RESIZE_CURSOR));
} else {
setCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR));
}
} else {
setCursor(Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR));
}
}
}
@Override
public void mouseDragged(MouseEvent e) {
if (clickPoint != null) {
Point mouse = e.getPoint();
if (getCursorState() == MOVING) {
int dx = rectangle.x + mouse.x - clickPoint.x;
int dy = rectangle.y + mouse.y - clickPoint.y;
rectangle.setLocation(dx, dy);
clickPoint = e.getPoint();
} else if (getCursorState() == RESIZING) {
int dx = mouse.x - startPoint.x;
int dy = mouse.y - startPoint.y;
int height = rectangle.height;
int width = rectangle.width;
int x = 0;
int y = 0;
int w = 0;
int h = 0;
switch (getCursor().getType()) {
case Cursor.SW_RESIZE_CURSOR:
x = mouse.x + dx;
y = rectangle.y;
w = width - dx;
h = height + dy;
if (fixedRatio) {
h = w / 3 * 4;
}
break;
case Cursor.SE_RESIZE_CURSOR:
x = rectangle.x;
y = rectangle.y;
w = width + dx;
h = height + dy;
if (fixedRatio) {
w = h / 4 * 3;
}
break;
case Cursor.NW_RESIZE_CURSOR:
x = mouse.x + dx;
y = mouse.y + dy;
w = width - dx;
h = height - dy;
// This is where I'm lost
// something else needs to be done
if (fixedRatio) {
w = h / 4 * 3;
}
break;
case Cursor.NE_RESIZE_CURSOR:
x = rectangle.x;
y = mouse.y + dy;
w = width + dx;
h = height - dy;
if (fixedRatio) {
w = h / 4 * 3;
}
break;
}
rectangle.setBounds(x, y, w, h);
startPoint = mouse;
} else {
int x = Math.min(clickPoint.x, mouse.x);
int y = Math.min(clickPoint.y, mouse.y);
int w = Math.max(clickPoint.x - mouse.x, mouse.x - clickPoint.x);
int h = Math.max(clickPoint.y - mouse.y, mouse.y - clickPoint.y);
if (rectangle == null) {
rectangle = new Rectangle(x, y, w, h);
} else {
rectangle.setBounds(x, y, w, h);
}
}
repaint();
}
}
};
addMouseListener(mouseHandler);
addMouseMotionListener(mouseHandler);
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
g.setColor(Color.DARK_GRAY);
g.fillRect(0, 0, getWidth(), getHeight());
Graphics2D graphics2D = (Graphics2D) g.create();
if (rectangle != null) {
Area fill = new Area(new Rectangle(new Point(0, 0), getSize()));
fill.subtract(new Area(rectangle));
if (clickPoint != null) {
graphics2D.setColor(new Color(0, 0, 0, 0));
} else {
graphics2D.setColor(new Color(0, 0, 0, 200));
}
int x = rectangle.x;
int y = rectangle.y;
int w = rectangle.width;
int h = rectangle.height;
graphics2D.fill(fill);
graphics2D.setColor(Color.WHITE);
graphics2D.setStroke(
new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL, 0, new float[] { 6 }, 0));
graphics2D.drawRect(x, y, w, h);
if (w >= 30 && h >= 30) {
graphics2D.setStroke(new BasicStroke(3));
graphics2D.drawLine(x + 1, y + 1, x + 8, y + 1);
graphics2D.drawLine(x + 1, y + 1, x + 1, y + 8);
graphics2D.drawLine(x + w - 1, y + 1, x + w - 8, y + 1);
graphics2D.drawLine(x + w - 1, y + 1, x + w - 1, y + 8);
graphics2D.drawLine(x + 1, y + h - 1, x + 8, y + h - 1);
graphics2D.drawLine(x + 1, y + h - 1, x + 1, y + h - 8);
graphics2D.drawLine(x + w - 1, y + h - 1, x + w - 8, y + h - 1);
graphics2D.drawLine(x + w - 1, y + h - 1, x + w - 1, y + h - 8);
}
}
graphics2D.dispose();
g.dispose();
}
private int getCursorState() {
switch (getCursor().getType()) {
case Cursor.CROSSHAIR_CURSOR:
return HOVERING;
case Cursor.MOVE_CURSOR:
return MOVING;
case Cursor.SW_RESIZE_CURSOR:
case Cursor.SE_RESIZE_CURSOR:
case Cursor.NW_RESIZE_CURSOR:
case Cursor.NE_RESIZE_CURSOR:
case Cursor.N_RESIZE_CURSOR:
case Cursor.S_RESIZE_CURSOR:
case Cursor.W_RESIZE_CURSOR:
case Cursor.E_RESIZE_CURSOR:
return RESIZING;
default:
return -1;
}
}
}
Firstly just to note, the aspect ratio you are using is 3:4
not 4:3
:
3:4
means that for every 3 units of width there are 4 units of height.
4:3
means that for every 4 units of width, there are 3 units of height.
w = h / 4 * 3
is calculating 3:4
, not 4:3
.
w = h / 3 * 4
or h = w / 4 * 3
calculates 4:3
Moving on to why your resizing breaks, when you create a Rectangle
you provide the x, y coordinates of it's top left corner, and it's width and height:
Rectangle rectangle = new Rectangle(x, y, width, height)
The rectangle will then be drawn from x, y
to x + width, y + height
The resizing part of your code works fine, when the mouse is dragged you update x
, y
, width
, and height
correctly.
The reason why applying the aspect ratio breaks it, is because you are updating width
, and height
, but you are not updating x
and y
.
Lets say the user performed a Northwest resize, and you now have a rectangle as follows:
x => 10
y => 10
width => 5
height => 10
You then apply your aspect ratio w = h / 4 * 3
:
x => 10
y => 10
width => 8
height => 10
Because you are drawing from the top left corner, the rectangle has now grown from left to right, but you want it to grow from right to left. When you resize in the Northwest direction, you always want the bottom right corner of the rectangle to remain in the same place. The reason why this does not happen with your code is because when you apply the aspect ratio to the rectangle's width, you do not then update the start x, y point of the rectangle.
Using the above example, x and y should be updated as follows:
x => 7
y => 10
width => 8
height => 10
Here is a solution that I came up with:
else if (getCursorState() == RESIZING) {
Point startPoint = null;
Point endPoint = null;
switch(getCursor().getType()) {
case Cursor.SW_RESIZE_CURSOR:
startPoint = new Point((int) mouse.getX(), (int) rectangle.getMinY());
endPoint = new Point((int) rectangle.getMaxX(), (int) mouse.getY());
break;
case Cursor.NW_RESIZE_CURSOR:
startPoint = new Point((int) mouse.getX(), (int) mouse.getY());
endPoint = new Point((int) rectangle.getMaxX(), (int) rectangle.getMaxY());
break;
case Cursor.NE_RESIZE_CURSOR:
startPoint = new Point((int) rectangle.getMinX(), (int) mouse.getY());
endPoint = new Point((int) mouse.getX(), (int) rectangle.getMaxY());
break;
case Cursor.SE_RESIZE_CURSOR:
startPoint = new Point((int) rectangle.getMinX(), (int) rectangle.getMinY());
endPoint = new Point((int) mouse.getX(), (int) mouse.getY());
break;
}
rectangle.setFrameFromDiagonal(startPoint, endPoint);
if (fixedRatio) {
// Calculate 3:4 aspect ratio
rectangle.height = rectangle.width / 3 * 4;
// If this is a NW or NE resize, we need to adjust the start y coordinate to account for the new height
// This keeps the bottom right corner in the same place for a NW resize
// and the bottom left corner in the same place for a NE resize
if (getCursor().getType() == Cursor.NW_RESIZE_CURSOR || getCursor().getType() == Cursor.NE_RESIZE_CURSOR) {
rectangle.y = endPoint.y - rectangle.height;
}
}
}
So when the rectangle is resized in the Northwest or Northeast directions, and the aspect ratio is applied, I also update the rectangle's start y coordinate to account for the change in height.