Tuesday, February 19, 2013

A Better 2D Scene Manipulation Widget for Qt

Introduction

I'm currently working on a project which requires a good 2D scene manipulation system. The application is being built with the Qt C++ framework. Initially I investigated using the base QGraphicsView class, but it really doesn't have the functionality I want so I'll have to roll my own solution somehow (either extend it or start from scratch).

For those curious as to what I want the ultimate look and feel to resemble, my inspiration for this design is based loosely off of other interfaces I've been highly impressed with. The two best similar type of interfaces I've used are the Blender and SolidWorks user interfaces. These programs are designed to work predominantly with 3D viewports, but my application only requires a 2D viewport. More details on the actual project will hopefully come later, depending on how committed I am to this project.

Design

Before writing the code I should outline roughly what I want the interface to be.

  • A left mouse button press will:
    • With no modifier keys pressed:
      • If there is an item close to the mouse pointer, the item will be selected. If there are multiple items near the mouse pointer, there should be some mechanism for distinguishing which item to select. Until the user releases the mouse button, the user can drag/move the current selection. If the item under the pointer is already selected, don't modify the selection, just allow the user to drag-move the selection.
      • If there are no items close to the mouse pointer, begin a selection box. On mouse button release items which intersect the selection box will be selected.
  • There should be some mechanism for selecting items and moving items. The mechanism should be triggered using a left mouse button with no keyboard modifier keys pressed. The mechanism's behaviour should be:
    • If there is a selectable item under the mouse cursor and it's already selected, allow drag/move of the current selection.
    • If there is a selectable item under the mouse cursor and it's not selected, select only that item and allow drag/move of the selection.
    • If there is no selectable item the mouse cursor begin making a drag selection box. On left mouse button release only items in the selection box will be selected.
  • There should be some mechanism for allowing the user to create a drag selection box. Using the shift key with this mechanism will add items which intersect the selection box to the current selection. Using the alt key with this mechanism will remove items which intersect the selection box from the current selection.
  • There should be some mechanism for allowing the user to rotate all selected items at the same time.
  • The middle mouse button can be used to pan the view.
  • The mouse wheel can be used to zoom in/out. Zooming will be centered on the mouse pointer location.
  • An option should be available to toggle if moving items can be toggled to snap moved items to a grid. Should also be able to snap angles, too.
  • An option should be available to toggle how items should be rotated. Two options I want are:
    • Rotate each selected item about its local origin.
    • Rotate all items together about a given point.
    • There should be a toggle-able display grid.
    • The actual working area should be scene-based, i.e. multiple viewports can view different parts (or the same parts) of the scene at the same time.

With these requirements in mind, I can now start thinking about the general code strategy I wish to persue. As I stated above I'm using the Qt C++ Framework. I chose this framework because I really like the Qt Framework. It allows me to create fully functioning C++ applications which can be easily ported to multiple operating systems. To be honest I chose Qt because it's what I'm familiar with. Other people may prefer GTK+ or wxWidgets or some other solution, oh well. I'm sure they're all decently functional, I just don't have experience with them.

The simplest way I can think of to solve this problem is to extend the QGraphicsView class. Most of the functionality is just there, I just need to provide the interactive functionality I want. The event handlers I need to override:

  • keyPressEvent
  • keyReleaseEvent
  • mouseMoveEvent
  • mousePressEvent
  • mouseReleaseEvent
  • paintEvent
  • wheelEvent

The Code

Here's a general description of the code. It's still incomplete, but I have basic item selection/movement and scene navigation code done.

Handling Mouse Press Events

The most important and complicated part of interface is handling the mouse events. I looked at the Qt source code to see how they handle movable items. The way Qt does it is keep track of the initial position of each item and the location of the last left mouse button press in screen coordinates. The target location is calculated directly. This is a fairly intensive process especially for large numbers of items. Instead the solution I want to persue is tracking the delta motion of the mouse in screen coordinates. It still takes O(n) time to actually move items, but there is no scalar factor for storing initial selected item positions. Keeping track of deltas has the potential for floating point error, but I think this is something I'm willing to live with. The double precision scene coordinates should be more than sufficient for my purposes.

Because certain actions can be performed at the same time, I have separate flags for different operations. Right now these are separate bool variables, in the future I may consider switching to a bit field.

     /**
     * @brief zoomFactor
     */
    qreal zoomFactor;
    /**
     * @brief drawSelectionBox
     */
    bool drawSelectionBox;
    /**
     * @brief movingSelection
     */
    bool movingSelection;
    /**
     * @brief panning
     */
    bool panning;

    /**
     * @brief Tracks where the cursor is relative to the scene at the start of a mouse event. Used by item move and box selection features.
     */
    QPointF cursorSceneStart;
    /**
     * @brief Tracks where the cursor is relative to the scene at the last mouse event for a mouse event. Used by item move and box selection features.
     */
    QPointF cursorSceneCurr;
    /**
     * @brief Tracks where the cursor is at the last mouse event for a pan event.
     */
    QPoint cursorPanCurr;
    
    /**
     * @brief drag selection box border pen style
     */
    QPen selectionBoxBorder;
    /**
     * @brief drag selection box fill color
     */
    QColor selectionBoxFill;

The general idea is that the mousePressEvent listener will look for the triggering mechanism for different possible tasks. If it finds one, the appropriate fields are initialized. Organizing the event listener to handle different mechanisms efficiently was a bit difficult, but here's a setup that works and I think does a decent job.

void Viewport::mousePressEvent(QMouseEvent *event)
{
    if(event->button() == Qt::LeftButton)
    {
        // get a list of potentially selected items
        QList<QGraphicsItem*> potentialSelections = items(event->pos());
        if(potentialSelections.size() == 0 || QApplication::keyboardModifiers() & Qt::ShiftModifier || QApplication::keyboardModifiers() & Qt::AltModifier)
        {
            // create selection box
            drawSelectionBox = true;
            cursorSceneStart = mapToScene(event->pos());
            cursorSceneCurr = cursorSceneStart;
        }
        else
        {
            // have some items available to select
            // for now just pick one
            bool foundSelectable = false;
            QGraphicsItem *item;
            for(auto iter = potentialSelections.begin(); iter != potentialSelections.end() && !foundSelectable; ++iter)
            {
                if((*iter)->flags() & QGraphicsItem::ItemIsSelectable)
                {
                    foundSelectable = true;
                    item = *iter;
                }
            }
            if(foundSelectable)
            {
                if (QApplication::keyboardModifiers() == Qt::NoModifier)
                {
                    if(!item->isSelected())
                    {
                        // clear selection
                        scene()->clearSelection();
                        
                    }
                    // select the item
                    item->setSelected(true);
                    movingSelection = true;
                }

                if(movingSelection)
                {
                    cursorSceneStart = mapToScene(event->pos());
                    cursorSceneCurr = cursorSceneStart;
                }
            }
            else
            {
                // nothing selectable nearby
                drawSelectionBox = true;
                cursorSceneStart = mapToScene(event->pos());
                cursorSceneCurr = cursorSceneStart;
            }
        }
    }
    else if (event->button() == Qt::MiddleButton)
    {
        // pan around
        panning = true;
        cursorPanCurr = event->pos();
    }
    else
    {
        // propogate
        QGraphicsView::mousePressEvent(event);
    }
}

Handling Mouse Move Events

Once the data has been initialized and the action flags are set, the mouse move handler can perform the necessary actions. Qt doesn't trigger a mouseMoveEvent unless the widget has grabbed the mouse, either programmatically or by the user physically holding a mouse button down. For this first step all of my actions require a mouse button to be held down so this isn't a problem.

Here's how I'm updating drag selection boxes. The idea is to trigger two different updates: one before the mouse coordinates get updated and one after. Qt will attempt to consolidate multiple update calls to update. Just in case Qt decides to start an update before the updates can be consolidated, I added a safety catch which would temporarily turn drawing of selection boxes off. Once it's ready to post another update request, I turn the selection box drawing back on. There's a slight possibility that the update might trigger between the time the bool value changes and the update call gets processed, but I should be ok. I'm not entirely sure how Qt consolidates multiple update events and this safety check may be completely uneccessary.

This is dual updating is done because I want to limit how much area needs to be updated. I was getting strange artifacts when I tried to make a minimalist update area, so I added a small buffer zone.

// only repaint the area inside the selection box
// just in case the scene gets repainted before the operation can finish
drawSelectionBox = false;
QRect selection = box(mapFromScene(cursorSceneStart), mapFromScene(cursorSceneCurr));
selection.adjust(-1, -1, 1, 1);
viewport()->update(selection);
cursorSceneCurr = mapToScene(event->pos());
drawSelectionBox = true;
selection = box(mapFromScene(cursorSceneStart), mapFromScene(cursorSceneCurr));
selection.adjust(-16, -16, 16, 16);
viewport()->update(selection);

box is a small helper function I created which will build a "proper" QRect from any given opposing corners. The standard QRect will allow negative widths/heights which don't properly work with the Qt painting implementation.

/**
 * @brief Creates a QRect with any 2 opposing corners.
 * @param p1
 * @param p2
 * @return 
 */
static QRect box(const QPoint &p1, const QPoint &p2)
{
    int x1 = p1.x();
    int y1 = p1.y();
    int x2 = p2.x();
    int y2 = p2.y();

    if(x2 < x1)
    {
        // swap x
        x1 = x2;
        x2 = p1.x();
    }
    if(y2 < y1)
    {
        // swap y
        y1 = y2;
        y2 = p1.y();
    }
    return QRect(x1, y1, x2-x1, y2-y1);
}

For keeping track of moving items I chose to work in scene coordinates. I did this because the scene is allowed to move with respect to the view. This led to problems when I panned or zoomed the view because the widget coordinate no longer correlates to the correct scene point.

// move selected items around
QPointF mouseScenePos = mapToScene(event->pos());
QList<QGraphicsItem*> items = scene()->selectedItems();
for(auto iter = items.begin(); iter != items.end(); ++iter)
{
    QPointF currentParentPos = (*iter)->mapToParent((*iter)->mapFromScene(mouseScenePos));
    QPointF buttonDownParentPos = (*iter)->mapToParent((*iter)->mapFromScene(cursorSceneCurr));
    (*iter)->moveBy(currentParentPos.x() - buttonDownParentPos.x(), currentParentPos.y() - buttonDownParentPos.y());
}
cursorSceneCurr = mouseScenePos;

For panning I have a solution, but I'm not particularly fond of it. The problem is that the QGraphicsView is scroll view and can hold a viewport widget which can extend the full 32-bit integer range. This means I can't just use the translate method. To get around this problem I'm using the scrollbars to simulate panning of the view and set an extremely large scene rect to allow essentially infinite panning. Since the scrollbars won't really be useful for this application I just kept them always hidden.

horizontalScrollBar()->setValue(horizontalScrollBar()->value() - event->pos().x() + cursorPanCurr.x());
verticalScrollBar()->setValue(verticalScrollBar()->value() - event->pos().y() + cursorPanCurr.y());
cursorPanCurr = event->pos();

Handling Mouse Release Events

The mouse release events aren't terribly exciting, they mostly just reset flags. In the future I'll need to add extra code to allow snapping of movements to a grid.

if(event->button() == Qt::LeftButton)
{
    if(drawSelectionBox)
    {
        // finish making the selection box
        drawSelectionBox = false;
        QList<QGraphicsItem*> items = Viewport::items(box(mapFromScene(cursorSceneStart), event->pos()));
        
        if(QApplication::keyboardModifiers() == Qt::ShiftModifier)
        {
            // add to selection
            for(auto iter = items.begin(); iter != items.end(); ++iter)
            {
                (*iter)->setSelected(true);
            }
        }
        else if (QApplication::keyboardModifiers() == Qt::AltModifier)
        {
            // remove from selection
            for(auto iter = items.begin(); iter != items.end(); ++iter)
            {
                (*iter)->setSelected(false);
            }
        }
        else
        {
            // set selection
            scene()->clearSelection();
            for(auto iter = items.begin(); iter != items.end(); ++iter)
            {
                (*iter)->setSelected(true);
            }
        }
        // make sure we erase the selection box
        viewport()->update();
    }
    else if(movingSelection)
    {
        movingSelection = false;
    }
}
else if(event->button() == Qt::MiddleButton)
{
    if(panning)
    {
        panning = false;
    }
}

Handling Wheel Events

There's a built-in scale function that I'm using for simulating zooming in/out. As the user zooms out the zooming needs to scale more, and as the user zooms in the scalling needs to be less. I haven't quite figured out how to zoom focus on a particular point yet. The delta() function counts in 1/8th degree increments. However, not all mouse wheels have the same step. For now, I just fixed that every 7 degrees rotation of the mouse zooms in/out by 1 zoom factor. This seems like a decent speed for me, perhaps slightly on the slow side.

scale(pow(zoomFactor, event->delta() / 56), pow(zoomFactor, event->delta() / 56));

Handling Paint Events

Because I'm not using the default drag selection box functionality of the QGraphicsView widget I need to override the paint event handler to draw my own box. One thing that's odd/different about the QGraphicsView is that painting is primarily dealt with by the viewport. In the othe handler code in order to get the paintEvent handler to trigger I needed to invoke update commands on the viewport widget, not my widget. I believe this is primarily a consequence of having QGraphicsView being a scroll area. I don't have code working yet which will draw the background grid. The selection box has the option of putting a fill color and a border pen style.

if(drawSelectionBox)
{
    QPainter painter(viewport());
    painter.setPen(selectionBoxBorder);
    QRect selectionBox = box(mapFromScene(cursorSceneStart), mapFromScene(cursorSceneCurr));
    painter.drawRect(selectionBox);
    
    painter.fillRect(selectionBox, selectionBoxFill);
}
// propogate
QGraphicsView::paintEvent(event);

Testing the Component

To test the component I created a test scene which has many rectangle items added (tens of thousands of items). It arranges them in a grid. I then created two different QMainWindow objects and added two different Viewport classes. The viewports then both point to the same test scene object. This allows me to have two separate viewports, each with separate navigation controls. However, when an operation which modifies the scene is performed in one viewport both viewports will be updated.

QApplication a(argc, argv);
QGraphicsScene *scene = new QGraphicsScene();
scene->setSceneRect(-1e5, -1e5, 2e5, 2e5);
// add a few items
const int dim = 256;
for(int i = 0; i < dim; ++i)
{
    for(int j = 0; j < dim; ++j)
    {
        QGraphicsRectItem *item = new QGraphicsRectItem(j * 20 - 16 * dim / 2, i * 20 - 16 * dim / 2, 16, 16);
        item->setFlag(QGraphicsItem::ItemIsSelectable);
        item->setFlag(QGraphicsItem::ItemIsMovable);
        scene->addItem(item);
    }
}

QMainWindow w;
Viewport *viewport = new Viewport(&w);
w.setCentralWidget(viewport);
viewport->setScene(scene);
w.setWindowTitle("Window1");
w.show();

QMainWindow w2;
viewport = new Viewport(&w);
w2.setCentralWidget(viewport);
w2.setWindowTitle("Window2");
viewport->setScene(scene);
w2.show();

return a.exec();

Figure 1 shows a screenshot I took of this test code. Even with ~65000 items in the scene, the application is still quite responsive. As you can see the two viewports have different transformations applied, but changes in one viewport are instantly reflected in the other viewport.

Figure 1. Screenshot of two different viewports with the same scene attached.

Conclusion

That's all for now, I'll post upcomming progress in the future as well as more details on what the actual project is. Even as it is right now I enjoy the navigation capabilities of this system much better than a lot of other similar type software. I'm thinking once I have the project in a more usable state I'll begin publishing it on some website. I still haven't decided which site I want to use.

No comments :

Post a Comment