Продолжаем планомерно продвигаться к достижению необходимого результата. Вчера мы закончили на том, что осуществили отрисовку 3D-объекта в виде кубоида, так что логически верным будет добавить к приложению панель выбора инструментов. Сейчас у нас всегда рисуются кубоиды по событиям мыши, независимо ни от каких внешних факторов.
Здесь я решил поместить небольшую врезку-спойлер с финальным результатом. Пока здесь будет пусто (update: результат добавлен), но по окончанию проекта я размещу результаты работы. И в некоторых из следующих статей "марафона" продублирую аналогичным образом. Итак:
Данная панель по нашему приблизительному плану должна находиться здесь:
В целом, по-прежнему это место кажется наиболее предпочтительным, так что так и делаем )
Добавим файл ToolPanel.qml, но предварительно добавим в PaintEntity
enum со всеми вариантами инструментов. На данный момент это будут:
- выбор объекта
- удаление объекта
- создание кубоида
Пока ограничимся этими тремя, соответственно, сегодня нужно будет реализовать логику выбора и удаления добавленных на сцену фигур:
class PaintEntity : public Qt3DCore::QEntity { Q_OBJECT public: enum class ToolType { Select = 0, Delete, Cuboid }; Q_ENUM(ToolType) // ...............
По традиции полный код и ссылки на проекты всегда находятся в конце статьи.
Возвращаемся к ToolPanel.qml и добавляем группу кнопок:
import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQml.Models 2.15 import microtechnics.paintEntity 1.0 Rectangle { id: rootRectangle color: "transparent" height: buttonsGrid.height property int selectedTool: PaintEntity.ToolType.Cuboid property var buttonsModel: [ {name: qsTr("select"), type: PaintEntity.ToolType.Select}, {name: qsTr("delete"), type: PaintEntity.ToolType.Delete}, {name: qsTr("cuboid"), type: PaintEntity.ToolType.Cuboid} ] ButtonGroup { id: buttonGroup exclusive: true } Grid { id: buttonsGrid columns: 3 spacing: 2 anchors.horizontalCenter: parent.horizontalCenter Repeater { id: buttonsRepeater model: buttonsModel Button { checkable: true text: modelData['name'] width: (rootRectangle.width - (buttonsGrid.columns - 1) * buttonsGrid.spacing) / buttonsGrid.columns height: width property var type: modelData['type'] property var normalColor: "#a0a080" property var hoveredColor: "#a0a0a0" property var checkedColor: "#505050" ButtonGroup.group: buttonGroup checked: type === PaintEntity.ToolType.Cuboid background: Rectangle { anchors.fill: parent color: checked ? checkedColor : hovered ? hoveredColor : normalColor } onCheckedChanged: { if (checked === true) { selectedTool = type; } } } } } }
Количество кнопок и некоторые их свойства полностью определяются моделью buttonsModel
. Если, а точнее, когда понадобятся еще кнопки, изменения будут заключаться только в добавлении элементов к buttonsModel
, что мы и проделаем в дальнейшем по мере развития проекта.
При выборе инструмента рисования есть один очевидный нюанс, который заключается в том, что одновременно может быть выбран только один, соответственно, и кнопка нажата может быть только одна. В связи с чем для ButtonGroup
выставляем:
exclusive: true
Далее кнопки помещаются в Grid, а само их создание происходит при помощи Repeater, которому мы указываем на модель buttonsModel
. И для использования свойств, перечисленных в buttonsModel
, применяется синтаксис вида:
text: modelData['name']
По итогу мы получим три кнопки, у которых текст и свойство type будут в точности соответствовать тому, что указано в buttonsModel
. По причинам, описанным в первой части, продолжаем успешно забивать на разработку дизайна, установим лишь разные цвета для разных состояний:
- Цвет кнопки, с которой ничего не происходит -
property var normalColor: "#808080"
- Button hovered -
property var hoveredColor: "#a0a0a0"
- Button checked -
property var checkedColor: "#505050"
Эти цвета следуют прямиком в свойство color элемента Rectangle
:
background: Rectangle { anchors.fill: parent color: checked ? checkedColor : hovered ? hoveredColor : normalColor }
Не буду дальше на этом останавливаться, в случае возникновения вопросов, последние крайне приветствуются и в комментариях, и на форуме.
QML элемент для панели инструментов создан, вставляем его в положенное место в main.qml:
GridRectangle { id: objectBlock Layout.row: 0 Layout.column: 0 Layout.rowSpan: 12 Layout.columnSpan: 1 ToolPanel { id: toolPanel anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top } }
Контрольный запуск:
Все в порядке ) Опять возвращаемся к тому, что QML-часть приложения у нас отвечает строго за интерфейс, поэтому на выборе инструмента полномочия ToolPanel
заканчиваются. Необходимо только передать данные пользовательского выбора в PaintEntity
, для чего добавляем в класс соответствующее свойство и стандартные getter/setter для него:
class PaintEntity : public Qt3DCore::QEntity { Q_OBJECT public: enum class ToolType { Select = 0, Delete, Cuboid }; Q_ENUM(ToolType) PaintEntity(QNode *parent = nullptr); Q_PROPERTY(ToolType activeTool READ getActiveTool WRITE setActiveTool); Q_INVOKABLE void onBasePlaneMousePressed(const QVector3D &coordinate); Q_INVOKABLE void onBasePlaneMouseMoved(const QVector3D &coordinate); ToolType getActiveTool(); void setActiveTool(ToolType tool); private slots: signals: private: QVector< QSharedPointer<PaintObject> > paintObjects; ToolType activeTool; };
И реализация в .cpp:
PaintEntity::ToolType PaintEntity::getActiveTool() { return activeTool; } void PaintEntity::setActiveTool(ToolType tool) { activeTool = tool; }
И, кстати, что для кнопок, что для activeTool
, по умолчанию я задал режим кубоида.
Поскольку свойство activeTool
мы объявили с Q_PROPERTY
, то из main.qml спокойно можем иметь к нему доступ, что и осуществляем в toolPanel
:
onSelectedToolChanged: { paintEntity.activeTool = toolPanel.selectedTool; }
Данный обработчик будет вызван при изменении свойства selectedTool
в ToolPanel
, это у нас происходит при нажатии какой-либо из кнопок панели. Никаких дополнительных объявлений не требуется - для добавляемых свойств QML автоматически генерирует событие вида onНазваниеСвойстваChanged
.
На данном этапе можно примерно прикинуть, как у нас будет осуществляться выбор одной из имеющихся на сцене фигур при выборе инструмента Select Tool. И для этого я планирую использовать QObjectPicker
, но не тот, который у нас прикреплен к baseCuboidEntity
, а свой собственный для каждого из PaintObject
. В принципе, пока на этом можно не зацикливаться, важен лишь только следующий вывод - при активации инструментов Delete и Select baseCuboidPicker
нам не нужен. И onBasePlaneMousePressed()
с onBasePlaneMouseMoved()
вызываться не должны.
Поэтому банальнейшим образом добавляем к вышеупомянутому baseCuboidPicker
еще одно свойство:
property bool paintEnabled: true
И ничуть не сложнее становится логика в обработчиках:
onPressed: { if (paintEnabled === true) { if (pick.buttons === Qt.LeftButton) { isAddingPaintObject = true; paintEntity.onBasePlaneMousePressed(pick.worldIntersection); } } } onMoved: { if (paintEnabled === true) { if (isAddingPaintObject === true) { paintEntity.onBasePlaneMouseMoved(pick.worldIntersection); } } }
И, соответственно, при выборе в ToolPanel
:
onSelectedToolChanged: { if ((toolPanel.selectedTool === PaintEntity.ToolType.Select) || (toolPanel.selectedTool === PaintEntity.ToolType.Delete)) { baseCuboidPicker.paintEnabled = false; } else { baseCuboidPicker.paintEnabled = true; } paintEntity.activeTool = toolPanel.selectedTool; }
Финишируем модификацией paintentity.cpp:
void PaintEntity::onBasePlaneMousePressed(const QVector3D &coordinate) { switch (activeTool) { case PaintEntity::ToolType::Cuboid: paintObjects.append(QSharedPointer<PaintObject>(new Cuboid(this, paintObjects.size(), coordinate))); break; default: // This branch should never be executed break; } }
Это нужно для создания других фигур, опять же в недалеком будущем. На Select и Delete не повлияет, так как априори в данный метод мы не должны попадать при выборе этих инструментов.
С панелью инструментов на этом заканчиваем на сегодня, никаких катастрофически сложных вещей мы там не осуществили, поэтому и результат закономерно работоспособен:
И сразу переходим к оставшимся двум задачам на эту статью:
- функционал выбора фигуры
- функционал удаления фигуры
Как я уже мельком упомянул в обоих случаях будем использовать QObjectPicker
. Добавим соответствующий член к классу PaintObject
. Идея тут проста - при нажатии на конкретную фигуру будет использован конкретный же QObjectPicker
, и из обработчика мы сможем прокинуть в PaintEntity
данные о выбранном таким образом объекте 👍
Привожу только изменения и дополнения. Первое, в PaintObject
- сигнал и слот. Сигнал - для передачи в PaintEntity
, слот - для фиксирования события от QObjectPicker
:
signals: void picked(quint32 pickedId); private slots: virtual void onClicked(Qt3DRender::QPickEvent *event);
Второе, там же:
private: Qt3DRender::QObjectPicker *picker;
Третье:
PaintObject::PaintObject(Qt3DCore::QEntity *parentEntity, quint32 objectId) : type(PaintObject::Type::None), id(objectId) { entity = QSharedPointer<Qt3DCore::QEntity>(new Qt3DCore::QEntity(parentEntity)); picker = new Qt3DRender::QObjectPicker(); picker->setEnabled(true); picker->setHoverEnabled(true); entity->addComponent(picker); QObject::connect(picker, &Qt3DRender::QObjectPicker::clicked, this, &PaintObject::onClicked); }
Четвертое - слот:
void PaintObject::onClicked(Qt3DRender::QPickEvent *event) { if (event->button() == Qt3DRender::QPickEvent::LeftButton) { emit picked(id); } }
Таким образом, PaintObject
будет пробрасывать свой id для идентификации объекта. На то он и идентификатор. В PaintEntity
объявляем слот и его реализацию:
private slots: void paintObjectPicked(quint32 pickedId);
void PaintEntity::paintObjectPicked(quint32 pickedId) { qDebug() << "Object picked" << pickedId; }
Остается законнектить сигнал и слот. Данное действие производим после создания 3D-объекта:
void PaintEntity::onBasePlaneMousePressed(const QVector3D &coordinate) { switch (activeTool) { case PaintEntity::ToolType::Cuboid: paintObjects.append(QSharedPointer<PaintObject>(new Cuboid(this, paintObjects.size(), coordinate))); break; default: // This branch should never be executed break; } int currentIndex = paintObjects.size() - 1; QObject::connect(paintObjects.at(currentIndex).data(), &PaintObject::picked, this, &PaintEntity::paintObjectPicked); }
Результат не заставляет себя ждать:
Неплохо бы дать пользователю обратную связь при выборе того или иного объекта. Для этого я решил добавить анимированное изменение размеров объекта, подвергнутого нажатию ) Не буду подробно описывать, ключевые строки в PaintEntity
(полный код в конце статьи):
void PaintEntity::createAnimation() { transformAnimationGroup = new QSequentialAnimationGroup(this); increaseAnimation = new QPropertyAnimation(this); increaseAnimation->setDuration(125); decreaseAnimation = new QPropertyAnimation(this); decreaseAnimation->setDuration(125); transformAnimationGroup->addAnimation(increaseAnimation); transformAnimationGroup->addAnimation(decreaseAnimation); }
void PaintEntity::paintObjectPicked(quint32 pickedId) { if (activeTool == PaintEntity::ToolType::Select) { activePaintObject = paintObjects.at(pickedId).data(); QVector3D currentScale3D = activePaintObject->getTransform()->scale3D(); increaseAnimation->setTargetObject(activePaintObject->getTransform()); increaseAnimation->setPropertyName("scale3D"); increaseAnimation->setStartValue(currentScale3D); increaseAnimation->setEndValue(currentScale3D * 1.15); decreaseAnimation->setTargetObject(activePaintObject->getTransform()); decreaseAnimation->setPropertyName("scale3D"); decreaseAnimation->setStartValue(currentScale3D * 1.15); decreaseAnimation->setEndValue(currentScale3D); transformAnimationGroup->start(); } }
increaseAnimation
производит увеличение геометрических размеров объекта в 1.15 раз за 125 мс, decreaseAnimation
- аналогичный возврат к исходным размерам. transformAnimationGroup
отвечает за последовательное выполнение добавленных к ней QPropertyAnimation
.
Между делом я добавил к PaintEntity
указатель на текущий активный (выбранный) элемент:
PaintObject *activePaintObject;
И метод getTransform()
к PaintObject
для получения компонента Qt3DCore::QTransform
:
virtual Qt3DCore::QTransform* getTransform();
Qt3DCore::QTransform* PaintObject::getTransform() { Qt3DCore::QTransform *transform = entity->componentsOfType<Qt3DCore::QTransform>().at(0); return transform; }
И снова сразу делаем его виртуальным, поскольку есть четкое осознание того, что для кривых Безье многие методы будут переопределены. Я по мере возможности, конечно, накидал необходимый код, но очевидно, что полная картина может быть только от полных исходников. Которые будут наличествовать в конце статьи 👇
Собираем, запускаем, созерцаем:
При наличии обратной связи о предпринятом действии у пользователя уже совсем другие ощущения при работе. Остается нечто похожее сделать и для удаления объекта. Также используем QPropertyAnimation
, но ключевое отличие будет заключаться в том, что удалить объект мы не можем, пока он используется для анимации. К счастью, QPropertyAnimation
имеет сигнал finished
, который мы и используем:
QObject::connect(deleteAnimation, &QPropertyAnimation::finished, this, &PaintEntity::deleteAnimationFinished);
Итоговый процесс:
void PaintEntity::paintObjectPicked(quint32 pickedId) { if (activeTool == PaintEntity::ToolType::Select) { activePaintObject = paintObjects.at(pickedId).data(); QVector3D currentScale3D = activePaintObject->getTransform()->scale3D(); increaseAnimation->setTargetObject(activePaintObject->getTransform()); increaseAnimation->setPropertyName("scale3D"); increaseAnimation->setStartValue(currentScale3D); increaseAnimation->setEndValue(currentScale3D * 1.15); decreaseAnimation->setTargetObject(activePaintObject->getTransform()); decreaseAnimation->setPropertyName("scale3D"); decreaseAnimation->setStartValue(currentScale3D * 1.15); decreaseAnimation->setEndValue(currentScale3D); transformAnimationGroup->start(); } else { if (activeTool == PaintEntity::ToolType::Delete) { objectToDeleteId = pickedId; QVector3D currentScale3D = paintObjects.at(objectToDeleteId)->getTransform()->scale3D(); deleteAnimation->setTargetObject(paintObjects.at(objectToDeleteId)->getTransform()); deleteAnimation->setPropertyName("scale3D"); deleteAnimation->setStartValue(currentScale3D); deleteAnimation->start(); } } } void PaintEntity::deleteAnimationFinished() { removePaintObject(objectToDeleteId); } void PaintEntity::removePaintObject(quint32 objectId) { if (activePaintObject != nullptr) { if (objectId == activePaintObject->getId()) { activePaintObject = nullptr; } } paintObjects.remove(objectId); updatePaintObjectIds(objectId); } void PaintEntity::updatePaintObjectIds(int deletedIndex) { for (int i = deletedIndex; i < paintObjects.size(); i++) { paintObjects.at(i)->setId(i); } }
Так выглядит процедура удаления объекта:
Несколько нюансов, достойных отдельного упоминания:
- как уже обсудили - удаление производим по окончанию анимации
- проверяем случай, если удаляемый объект является активным
- поскольку id всех элементов численно равен их индексу в
paintObjects
, то после удаления какого-либо из них, следует переопределить id оставшихся
Все-таки пока не особо получается опускать мелкие детали, поэтому получилось относительно объемно, так что нам не остается ничего иного, как на сегодня завершить конструктивную деятельность, до скорого!
Исходный код и проект: mtPaint3D_day_3