Lab 3: 2D Interactive Drawing
Due Sunday 28 September 2025, 11:59pm
This is part two of a two week lab. You will work with the same partner as last week. :sectnums:
Lab Goals
Through this lab you will learn to
-
Set up a change of frame from world coordinates to clip coordinates
-
Handle mouse events in a Qt OpenGL widget
-
Design an interactive canvas
-
Test for Counter-Clockwise polygon orientations
-
Determine if a point is in a polygon
-
Code in C++ using Qt6 and OpenGL
-
Use inheritance and virtual methods
-
References
1. Copying New Files
You can work in your same git repo as last week. To begin the second part of the lab, you will copy over a new .ui file and a few new source files.
cd cs40/lab2-you
cp ~adanner/public/cs40/lab03/* ./
Only the mainwindow.ui should match an existing file. It is safe to overwrite your lab02
copy. You will need to add colorbox.h
and colorbox.cpp
to your CMakeLists.txt
.
Your code will not compile yet as you need to add some support for new slots in your MyPanelOpenGL
class. Open mypanelopengl.h
and add the following public snippets.
First, add the following enum
after your includes, but before the MyPanelOpenGL
class definition.
typedef enum CS40_APP_MODE{
NONE=0,
MOVING,
DELETING,
CHANGE_COLOR,
ADD_CIRCLE,
ADD_RECTANGLE,
ADD_TRIANGLE,
ADD_LINE
/* TODO: add more as needed */
} app_mode_t;
Next, in your private:
section, add the following member variables:
app_mode_t m_currentMode; /* current application mode * /
QMatrix4x4 m_matrix; /* matrix to convert world to clip coordinates */
QVector3D m_color; /* current drawing color */
Finally, in your public slots:
section, add the following method declarations:
void modeMove(){ m_currentMode = MOVING; }
void modeDelete(){ m_currentMode = DELETING; }
void modeColor(){ m_currentMode = CHANGE_COLOR; }
void modeCircle(){ m_currentMode = ADD_CIRCLE; }
void modeRectangle(){ m_currentMode = ADD_RECTANGLE; }
void modeTriangle(){ m_currentMode = ADD_TRIANGLE; }
void modeLine(){ m_currentMode = ADD_LINE; }
In mypanelopengl.cpp
, add the following lines to your constructor to initialize the new member variables:
m_currentMode = MOVING;
m_color = QVector3D(1,0,0); /* default color is red */
m_matrix.setToIdentity();
Check that your code compiles and runs after copying these files and making the CMakeLists.txt changes.
1.1. Building and Running your lab
You should just need to run make -j8 && ./lab2
from the build
directory inside your lab2
folder. If you do not have a build
directory, create one and run cmake ..
from inside it first.
cd ~/cs40/lab2-you
mkdir build
cd build
cmake ..
make -j8
./lab2
At this point, you should see the scene you constructed in lab2.
2. Handling a Mouse Click
The first task is to handle mouse clicks in the OpenGL widget. You will need to override the mousePressEvent(QMouseEvent* event)
method in mypanelopengl.cpp
. The QMouseEvent
object contains information about the mouse event, including the position of the mouse click in window coordinates. You can get the x and y coordinates of the mouse click using event→position().x()
and event→position().y()
. Note that these coordinates are in pixels, with (0,0) at the top-left corner of the widget.
Note that QWidget::mousePressEvent, QMouseEvent, and QWidget::mouseMoveEvent are all virtual methods that you can override in your subclass of QOpenGLWidget
. You will later override mouseMoveEvent(QMouseEvent* event)
to handle mouse dragging.
Add the declaration of mousePressEvent
to your mypanelopengl.h
file in the protected:
section:
protected:
void initializeGL();
void resizeGL(int w, int h);
void paintGL();
void mousePressEvent(QMouseEvent *event);
A start to your implementation of mousePressEvent
is provided below for mypanelopengl.cpp
:
void MyPanelOpenGL::mousePressEvent(QMouseEvent *event){
// Get the mouse position in window coordinates
float x = event->position().x();
float y = event->position().y();
QVector2D click(x, y);
qDebug() << "click: " << click;
}
Compile and run your program. You should see the mouse click coordinates printed to the console when you click in the OpenGL widget. Note the location of the origin (0,0) in window coordinates is at the top-left corner of the widget, with x increasing to the right and y increasing downwards. Let’s make a small change to move the origin to the bottom-left corner, with y increasing upwards. You can do this by subtracting the y coordinate from the height of the widget, which you can get using height()
. Update your mousePressEvent
method as follows:
vec2 click(x, height() - y);
Compile and run your program again. Now the origin should be at the bottom-left corner of the widget, with y increasing upwards.
3. A Sample Change of Frame
The click coordinates from the mousePressEvent will be a convenient way to think about the window space. However, OpenGL works in clip space, which is a square with corners at (-1,-1) and (1,1). To convert from window coordinates to clip coordinates, we will use a change of frame matrix. On the CPU side, the change of frame matrix will be a member variable of MyPanelOpenGL
called m_matrix
, which you already added above.
3.1. Adjusting the Vertex Shader
On the shader side, we will add a uniform variable to the vertex shader to hold the change of frame matrix. Open vshader.glsl
and add the mat4 mview
uniform declaration after the in vec2 vPosition;
line. The updated vertex shader should look like this. Note that we compute the position initially in world coordinates, then apply the mview
matrix to convert to clip coordinates.
in vec2 vPosition;
uniform mat4 mview;
uniform vec2 displacement;
void main() {
vec4 pos = vec4(vPosition + displacement, 0., 1.);
gl_Position = mview * pos;
}
3.2. Passing the Matrix to the Shader
In mypanelopengl.cpp
, you will need to connect the m_matrix
member variable to the mview
uniform in the shader program. You can do this in the paintGL()
method, after binding the shader program. Add the following line to paintGL()
:
m_gpuInfo->getProgram()->bind();
m_gpuInfo->getProgram()->setUniformValue("mview", m_matrix);
glClear(GL_COLOR_BUFFER_BIT)
3.3. Testing the Change of Frame
By default, the m_matrix
is initialized to the identity matrix in the MyPanelOpenGL
constructor. Compiling and running your program now should show the same scene as before, since the identity matrix does not change the coordinates. To test the change of frame, you can modify m_matrix
to scale and translate the scene. Temporarily add the following lines in your paintGL()
method before setting the uniform value on the GPU.
m_matrix(0,0)=0.5;
m_matrix(1,1)=0.5;
This will scale the scene down by a factor of 2. Compile and run your program. You should see the scene scaled down and centered in the window. Remove these lines after testing.
3.4. A Real Change of Frame
Recall from class our change of frame from world coordinates to clip coordinates is given by the following matrix:
where w and h are the width and height of the window in pixels. The last column is constant for the life of our application, but the first two columns will change if the window is resized. Add the following line to your MyPanelOpenGL
constructor to change the last column of m_matrix
to match the above matrix.
m_matrix.setColumn(3, QVector4D(-1, -1, 0, 1));
Next, override the resizeGL(int w, int h)
method in mypanelopengl.cpp
to update the first two columns of m_matrix
whenever the window is resized. Add the following code to resizeGL
:
void MyPanelOpenGL::resizeGL(int w, int h){
m_matrix.setColumn(0,QVector4D(2./w,0,0,0));
m_matrix.setColumn(1,QVector4D(0,2./h,0,0));
glViewport(0, 0, w, h);
update();
}
When you compile and run your program now all your shape drawn in the scene will likely disappear as they were set in clip coordinates, but the program now expects shapes in world coordinates. Try creating and drawing a shape in world coordinates and adding it to your scene. You should see the shape drawn correctly in the window.
since the geometry of the shapes is now in world/pixel coordinates, it should be possible to create a circle and have the circle not be distorted when the window is resized to a rectangular, non-square shape |
4. Adding New Shapes
You should now be at the point where you can add new shapes to your scene interactively. When the user clicks an add shape radio button, the m_currentMode
variable is set to the corresponding mode. In your mousePressEvent
method, you can check the value of m_currentMode
to determine what action to take when the user clicks in the OpenGL widget. For example, if m_currentMode
is ADD_CIRCLE
, you should create a new Circle
object using two clicks: the first click sets the center of the circle, and the second click sets a point on the circumference, which determines the radius. Similarly rectangles and lines can be created with two clicks, and triangles with three clicks.
The mousePressEvent
method only handles one click at a time. You can add a member variable of type QList<QVector2D>
to store the points of the current shape being created. You only need to store points to the list when you are in one of the four adding modes. When the user has clicked enough times to define the shape, you can create the shape object, add it to your scene, and clear the list of points, and call update()
to redraw the scene.
You can use the clear() method of QList to clear the list of points. You should also clear the list of points if the user switches to a different mode before completing the current shape.
|
4.1. Setting the Color
You should set the color of the new shape to the current color stored in m_color
. You can change the current color by clicking the "Change Color" button (at the bottom), which will open a color dialog. When you change the color, it emits a signal connected to a updateColor(QColor color)
slot in MyPanelOpenGL
. You will need to implement this slot to update the m_color
member variable. Add the following method declaration to the public slots:
section of mypanelopengl.h
:
void updateColor(QColor color);
Then implement the method in mypanelopengl.cpp
as follows:
void MyPanelOpenGL::updateColor(QColor color){
// TODO: Convert QColor to QVector3D and store in m_color
}
Read the QColor documentation to find out how to get the RGB components of a QColor
object. Check the range of the RGB components and convert them to the range [0,1] if needed before storing them in m_color
.
4.2. Testing
Gradually add support for each shape type, testing as you go. Confirm that you can create and draw each shape type correctly. If some shapes are not drawn correctly, check the order of the vertices to ensure they are specified in counter-clockwise order. You should not assume that the user will click in any particular order. You may need to reorder the vertices in your shape constructors before creating the shape object. You may want to turn off GL_CULL_FACE in initializeGL()
while testing to make sure all shapes are drawn, even if the vertices are not in counter-clockwise order, but be sure to turn it back on when you are done testing.
5. Changing Colors of an Existing Shape
When the user clicks the "Change Color" button, the m_currentMode
variable is set to CHANGE_COLOR
. In this mode, when the user clicks on a shape, the shape should change to the current color stored in m_color
. This feature will test your implementation of the contains
method for each shape. If two or more shapes overlap at the clicked point, you should change the color of the topmost shape (the last shape in your scene list that contains the point). If no shape contains the clicked point, you do not need to do anything. You should be able to change the color of multiple shapes without switching modes.
Don’t forget to call update()
after changing the color of a shape to redraw the scene.
6. Deleting a Shape
When the user clicks the "Delete" button, the m_currentMode
variable is set to DELETING
. In this mode, when the user clicks on a shape, the shape should be removed from the scene. This feature will also test your implementation of the contains
method for each shape. If two or more shapes overlap at the clicked point, you should delete the topmost shape (the last shape in your scene list that contains the point). If no shape contains the clicked point, you do not need to do anything. You should be able to delete multiple shapes without switching modes.
Instead of physically removing the shape from the list, we can instead perform a lazy delete by simply calling the hide()
method on the shape object. This will set the shape’s internal visibility flag to false. Once changed, you can then modify your paintGL()
method to skip drawing any shapes that are hidden. This would make it easy to implement an "undo" feature in the future if desired.
Don’t forget to call update()
after deleting a shape to redraw the scene.
7. Moving a Shape
When the user clicks the "Move" button, the m_currentMode
variable is set to MOVING
. In this mode, when the user clicks on a shape, that shape should be selected for moving. Use the contains
method to determine which shape was clicked in your mousePressEvent
method. You will need to remember this shape and the original mouse position, as the mousePressEvent
method only handles the initial click. You separately need to override the mouseMoveEvent(QMouseEvent* event)
method to handle mouse dragging. In mouseMoveEvent
, you can compute the displacement of the mouse from the original position, and update the position of the selected shape accordingly.
The mouseMoveEvent
method will be called repeatedly as the user drags the mouse, so you can update the shape’s position incrementally.
If two or more shapes overlap at the clicked point, you should delete the topmost shape (the last shape in your scene list that contains the point). If no shape contains the clicked point, you do not need to do anything. You should be able to move multiple shapes without switching modes.
8. Requirements
Your project will be graded on the following components:
-
Implementations of Triangle, Rectangle, Line, and Circle shapes
-
All derived shape classes have a working draw method
-
All derived shape classes have a working copy constructor
-
OpenGL primitives for Triangle, Rectangle, and Circle oriented counter-clockwise
-
Create a scene interactively by clicking in the OpenGL widget
-
Answers to the concept questions in the README3.adoc file
-
All changes added, committed, and pushed to team repo on Swarthmore GHE
You will not be graded on the lab survey questions in the README3.adoc
file
Submit
Once you have edited the files, you should publish your changes using the following steps:
$ git add <files you changed>
The git add step adds modified files to be part of the next commit to the github server.
$ git commit -m "completed lab2"
The git commit step makes a record of the recently added changes. The -m "completed lab1"
part is a descriptive message describing what are the primary changes in this commit. Making a commit allows you to review or undo changes easily in the future, if needed.
$ git push
The git push
command sends your committed changes to the github server. If you do not run git push before the submission deadline, I will not see your changes, even if you have finished coding your solution in your local directory.
If you make changes to files after your push and want to share these changes, repeat the add, commit, push
loop again to update the github server.
If you want to commit changes to files that have already been committed to git once, you can combine the add and commit steps using
$ git commit -am "bug fix/updates"
The -a
flag will automatically add files that have been previously committed. It will not add new files. When in doubt, use git status
, and please do not use git add * ./
To recap, the git commit cycle is git add, git commit, git push
. Don’t forget to git push when you have completed an assignment.
Optional Extension: Pan/Zoom
This section is entirely optional and will not be graded. Do not attempt this unless the required features are working.
The idea behind panning and zooming is that you will be working with three coordinate frames:
-
the window frame used by mouse events
-
the world frame used by the coordinates of the shapes
-
the clip frame used by the fragment shader.
You will need one matrix to transform from window coordinates to world coordinates, and a second matrix to transform from world to clip coordinates.
Start by representing the bounding box of visible scene in world coordinates using a struct.
typedef struct {
float xmin, xmax, ymin, ymax;
} boundingBox;
boundingBox worldBox;
You can choose the initial size of your world. Initialize the mouse to world matrix to transform from the OpenGL window size in mouse coordinates, to coordinates in the world frame. Note you will need to update this matrix if either the window resizes or you pan/zoom the world.
Initialize the world to clip matrix to map the current worldBox`
onto the clip square from (-1,-1) to (1,1). Pass only this matrix as a uniform to your vertex shader. By storing a global bounding box representing the projection, it is fairly easy to add support of zooming in, zooming out, and panning around the display. Add support to your code to allow users to zoom in and out using the 'z'/'Z' keys, respectively and to pan around the scene using up, down, left and right arrow keys. You may find the QKeyEvent method helpful.
Alternatively, you could add QT widgets to your UI to allow zooming panning with the mouse. A zoom or pan would update the bounding box of your current view of the world and require updates to the change of frame matrices. Modify as needed. When adding a shape, convert mouse clicks to world coordinates and add you shapes using world coordinates, not mouse coordinates.
Errata
GLSL syntax highlighting
If you are using vscode and your GLSL files do not have syntax highlighting, you can add it by installing the Shader languages support for VS Code
extension (or GLSL linter
) from the vscode extensions marketplace.