javaswinggraphicsgraphics2daffinetransform

Incorrect Position and Angle of Arrowhead with AffineTransform


I have written a test program using AffineTransform to draw an arrowhead. The program takes the center of the window as the starting point of the arrowhead and the mouse position as the ending point. However, the resulting arrowhead position and angle are incorrect. When my mouse is at the location of the green dot, the correct arrowhead position should be as shown by the red arrowhead in the image below. I'm wondering what could be causing the incorrect arrowhead to be drawn. result

The logic I used to draw the entire arrowhead is as follows:

Below is the code for drawing the arrowhead:

import javax.swing.*;
import javax.swing.event.MouseInputAdapter;
import java.awt.*;
import java.awt.event.MouseEvent;
import java.awt.geom.AffineTransform;
import java.awt.geom.Line2D;

class CanvasPane extends JPanel {
    Rectangle start = new Rectangle();
    Line2D.Double arrowShaft = new Line2D.Double();
    Rectangle arrowhead = new Rectangle(new Dimension(16, 16));
    double shearMultiplier = 0.5;
    double arrowheadLength = arrowhead.width * Math.sqrt(2) * (1 + shearMultiplier);
    MouseEvent me;
    double length, angle;

    CanvasPane() {
        setPreferredSize(new Dimension(1000, 600));

        addMouseMotionListener(new MouseInputAdapter() {
            @Override
            public void mouseMoved(MouseEvent e)
            {
                me = e;
                repaint();
            }
        });
    }

    @Override
    protected void paintComponent(Graphics g)
    {
        super.paintComponent(g);

        start.setBounds(getWidth()/2 - 5, getHeight()/2 - 5, 10, 10);
        Graphics2D g2d = (Graphics2D) g;
        g2d.fill(start);

        if (me != null) {
            length = Point.distance(start.getCenterX(), start.getCenterY(), me.getX(), me.getY());
            angle = Math.atan2(me.getY() - start.getCenterY(), me.getX() - start.getCenterX());

            arrowShaft.setLine(0, 0, length - arrowheadLength, 0);
            AffineTransform storedTransform = g2d.getTransform();
            AffineTransform AT = new AffineTransform();

            AT.translate(start.getCenterX(), start.getCenterY());
            AT.rotate(angle);
            // For the arrowShaft, it is first rotated and then translated.
            g2d.transform(AT);
            g2d.draw(arrowShaft);

            AT.translate(length - arrowheadLength, 0);
            AT.rotate(Math.toRadians(-45));
            AT.shear(shearMultiplier, shearMultiplier);
            // For the arrowhead, it is first sheared, then rotated, translated, rotated again and finally translated.
            g2d.transform(AT);
            g2d.draw(arrowhead);

            g2d.setTransform(storedTransform);
        }
    }
}

public class DrawLine {
    Container createContentPane() {
        JPanel contentPane = new JPanel(new BorderLayout());
        contentPane.setOpaque(true);
        contentPane.add(new CanvasPane(), BorderLayout.CENTER);
        return contentPane;
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            JFrame frame = new JFrame();
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

            DrawLine drawLine = new DrawLine();
            frame.setContentPane(drawLine.createContentPane());

            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
        });
    }
}

Could you please help me identify what might be causing the incorrect position and angle of the arrowhead to be drawn? Thank you.


Solution

  • I would reset the AffineTransform for the arrow head since needs an independent transformation on it before undergoing the main rotation and translation (which is already applied to the Graphics2D object.

    AT.translate(start.getCenterX(), start.getCenterY());
    AT.rotate(angle);
    g2d.transform(AT);
    g2d.draw(arrowShaft);    
                
    AT.setToIdentity();
    AT.translate(length - arrowheadLength, 0);
    AT.rotate(Math.toRadians(-45));
    AT.shear(shearMultiplier, shearMultiplier);
    g2d.transform(AT);
    g2d.draw(arrowhead);  
    

    Or alternatively, transform your head rectangle first, then the graphics coordinates, and then draw:

    AffineTransform headTransform = new AffineTransform();
    headTransform.translate(length - arrowheadLength, 0);
    headTransform.rotate(Math.toRadians(-45));
    headTransform.shear(shearMultiplier, shearMultiplier);
    Shape arrowhead2 = headTransform.createTransformedShape(new Rectangle(new Dimension(16, 16)));
    
    AT.translate(start.getCenterX(), start.getCenterY());
    AT.rotate(angle);
    
    g2d.transform(AT);
    g2d.draw(arrowShaft);
    g2d.draw(arrowhead2);
    

    Either way, the skewed rectangle needs its own transformation.

    But having said this, I wouldn't even use affine transforms for this but rather the java.awt.geom library including Path2D, and then using basic geometry draw my arrow head and shaft as it is needed. This way, you can still use effective RenderingHints to anti-alias the drawn shapes:

    enter image description here

    import java.awt.*;
    import java.awt.event.*;
    import java.awt.geom.*;
    import javax.swing.*;
    
    @SuppressWarnings("serial")
    public class CanvasPane2 extends JPanel {
        private static final int PREF_W = 1000;
        private static final int PREF_H = 600;
        private static final int ARROW_HEAD_LENGTH = 36;
        private static final int ARROW_HEAD_WIDTH = 16;
        private static final Color ARROW_HEAD_COLOR = Color.RED;
        private static final Stroke ARROW_HEAD_STROKE = new BasicStroke(3f);
        private double theta;
        private double r;
        private Line2D arrowShaft;
        private Shape arrowHead;
    
        public CanvasPane2() {
            addMouseMotionListener(new MyMouse());
        }
        
        @Override
        public Dimension getPreferredSize() {
            return new Dimension(PREF_W, PREF_H);
        }
        
        private class MyMouse extends MouseAdapter {
            @Override
            public void mouseMoved(MouseEvent e) {
                int x1 = e.getX() - getWidth() / 2;
                int y1 = e.getY() - getHeight() / 2;
                theta = Math.atan2(y1, x1);
                r = Point.distance(0, 0, x1, y1);
                arrowHead = createArrowHead();
                arrowShaft = createShaft();
                repaint();
            }
        }
        
        private Line2D createShaft() {
            double x = (r - ARROW_HEAD_LENGTH) * Math.cos(theta);
            double y = (r - ARROW_HEAD_LENGTH) * Math.sin(theta);
            
            Line2D line = new Line2D.Double(transX(0), transY(0), transX(x), transY(y));
            return line;
        }
        
        private Shape createArrowHead() {
            Path2D path = new Path2D.Double();
            Point2D tip, base, right, left;
    
            double tipX = r * Math.cos(theta);
            double tipY = r * Math.sin(theta);
            tip = new Point2D.Double(transX(tipX), transY(tipY));
            
            double baseR = (r - ARROW_HEAD_LENGTH);       
            double baseX = baseR * Math.cos(theta);
            double baseY = baseR * Math.sin(theta);        
            base = new Point2D.Double(transX(baseX), transY(baseY));
            
            double sideR = r - ARROW_HEAD_LENGTH / 2.0;       
            double deltaTheta = Math.atan2(ARROW_HEAD_WIDTH / 2.0, sideR); 
            
            double rightX = sideR * Math.cos(theta + deltaTheta);
            double rightY = sideR * Math.sin(theta + deltaTheta);        
            right = new Point2D.Double(transX(rightX), transY(rightY));
            
            double leftX = sideR * Math.cos(theta - deltaTheta);
            double leftY = sideR * Math.sin(theta - deltaTheta);        
            left = new Point2D.Double(transX(leftX), transY(leftY));
            
            path.moveTo(tip.getX(), tip.getY());
            path.lineTo(right.getX(), right.getY());
            path.lineTo(base.getX(), base.getY());
            path.lineTo(left.getX(), left.getY());
            path.closePath();
            return path;
        }
        
        public double transX(double x0) {
            return x0 + getWidth() / 2;
        }
    
        public double transY(double y0) {
            return y0 + getHeight() / 2;
        }
    
        
        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            Graphics2D g2d = (Graphics2D) g;
            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            int delta = 5;
            int x = getWidth() / 2 - delta;
            int y = getHeight() / 2 - delta;
            g2d.fill(new Rectangle(x, y, 2 * delta, 2 * delta));
            
            if (arrowShaft != null) {
                g2d.draw(arrowShaft);
            }
            
            if (arrowHead != null) {
                Graphics2D g2dB = (Graphics2D) g2d.create();
                g2dB.setColor(ARROW_HEAD_COLOR);
                g2dB.setStroke(ARROW_HEAD_STROKE);
                g2dB.fill(arrowHead);
                g2dB.dispose();
            }
        }
        
    
        public static void main(String[] args) {
            SwingUtilities.invokeLater(() -> {
                CanvasPane2 mainPanel = new CanvasPane2();
    
                JFrame frame = new JFrame("GUI");
                frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                frame.add(mainPanel);
                frame.pack();
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);
            });
        }
    }
    

    As an aside, you will want to learn and use Java naming conventions. Variable names should all begin with a lower letter while class names with an upper case letter. Learning this and following this will allow us to better understand your code, and would allow you to better understand the code of others.