diff --git a/examples/worlds/minimal_scene.sdf b/examples/worlds/minimal_scene.sdf index 1eee1a7a82..a992a45b5f 100644 --- a/examples/worlds/minimal_scene.sdf +++ b/examples/worlds/minimal_scene.sdf @@ -13,14 +13,15 @@ Features: * Grid config * Select entities * Transform controls +* Spawn entities through GUI Missing for parity with GzScene3D: -* Spawn entities through GUI * Context menu * Record video * View angles * View collisions, wireframe, transparent, CoM, etc +* Drag and drop from Fuel / meshes * ... --> @@ -160,6 +161,20 @@ Missing for parity with GzScene3D: /world/buoyancy/stats + + + + + + + false + 5 + 5 + floating + false + + + diff --git a/src/gui/plugins/CMakeLists.txt b/src/gui/plugins/CMakeLists.txt index 2b0489a16c..c44d2e1f2b 100644 --- a/src/gui/plugins/CMakeLists.txt +++ b/src/gui/plugins/CMakeLists.txt @@ -132,6 +132,7 @@ add_subdirectory(scene3d) add_subdirectory(select_entities) add_subdirectory(scene_manager) add_subdirectory(shapes) +add_subdirectory(spawn) add_subdirectory(transform_control) add_subdirectory(video_recorder) add_subdirectory(view_angle) diff --git a/src/gui/plugins/select_entities/SelectEntities.cc b/src/gui/plugins/select_entities/SelectEntities.cc index 4e571cab54..a38adde8b9 100644 --- a/src/gui/plugins/select_entities/SelectEntities.cc +++ b/src/gui/plugins/select_entities/SelectEntities.cc @@ -135,6 +135,9 @@ class ignition::gazebo::gui::SelectEntitiesPrivate /// \brief is transform control active ? public: bool transformControlActive = false; + + /// \brief Is an entity being spawned + public: bool isSpawning{false}; }; using namespace ignition; @@ -485,11 +488,18 @@ bool SelectEntities::eventFilter(QObject *_obj, QEvent *_event) ignition::gui::events::LeftClickOnScene *_e = static_cast(_event); this->dataPtr->mouseEvent = _e->Mouse(); - // handle transform control + if (this->dataPtr->mouseEvent.Button() == common::MouseEvent::LEFT && - this->dataPtr->mouseEvent.Type() == common::MouseEvent::PRESS) + this->dataPtr->mouseEvent.Type() == common::MouseEvent::RELEASE) { - this->dataPtr->mouseDirty = true; + if (this->dataPtr->isSpawning) + { + this->dataPtr->isSpawning = false; + } + else + { + this->dataPtr->mouseDirty = true; + } } } else if (_event->type() == ignition::gui::events::Render::kType) @@ -545,6 +555,13 @@ bool SelectEntities::eventFilter(QObject *_obj, QEvent *_event) this->dataPtr->selectedEntitiesID.clear(); this->dataPtr->selectedEntities.clear(); } + else if (_event->type() == + ignition::gui::events::SpawnFromDescription::kType || + _event->type() == ignition::gui::events::SpawnFromPath::kType) + { + this->dataPtr->isSpawning = true; + this->dataPtr->mouseDirty = true; + } else if (_event->type() == ignition::gui::events::KeyReleaseOnScene::kType) { ignition::gui::events::KeyReleaseOnScene *_e = @@ -553,6 +570,7 @@ bool SelectEntities::eventFilter(QObject *_obj, QEvent *_event) { this->dataPtr->mouseDirty = true; this->dataPtr->selectionHelper.deselectAll = true; + this->dataPtr->isSpawning = false; } } diff --git a/src/gui/plugins/spawn/CMakeLists.txt b/src/gui/plugins/spawn/CMakeLists.txt new file mode 100644 index 0000000000..dada40b6b3 --- /dev/null +++ b/src/gui/plugins/spawn/CMakeLists.txt @@ -0,0 +1,8 @@ +gz_add_gui_plugin(Spawn + SOURCES + Spawn.cc + QT_HEADERS + Spawn.hh + PUBLIC_LINK_LIBS + ${PROJECT_LIBRARY_TARGET_NAME}-rendering +) diff --git a/src/gui/plugins/spawn/Spawn.cc b/src/gui/plugins/spawn/Spawn.cc new file mode 100644 index 0000000000..eaac31e183 --- /dev/null +++ b/src/gui/plugins/spawn/Spawn.cc @@ -0,0 +1,510 @@ +/* + * Copyright (C) 2021 Open Source Robotics Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +#include "Spawn.hh" + +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include "ignition/gazebo/rendering/RenderUtil.hh" +#include "ignition/gazebo/rendering/SceneManager.hh" + +namespace ignition::gazebo +{ + class SpawnPrivate + { + /// \brief Perform operations in the render thread. + public: void OnRender(); + + /// \brief Delete the visuals generated while an entity is being spawned. + public: void TerminateSpawnPreview(); + + /// \brief Generate a preview of a resource. + /// \param[in] _sdf The SDF to be previewed. + /// \return True on success, false if failure + public: bool GeneratePreview(const sdf::Root &_sdf); + + /// \brief Handle placement requests + public: void HandlePlacement(); + + /// \brief Retrieve the point on a plane at z = 0 in the 3D scene hit by a + /// ray cast from the given 2D screen coordinates. + /// \param[in] _screenPos 2D coordinates on the screen, in pixels. + /// \param[in] _camera User camera + /// \param[in] _rayQuery Ray query for mouse clicks + /// \param[in] _offset Offset along the plane normal + /// \return 3D coordinates of a point in the 3D scene. + math::Vector3d ScreenToPlane( + const math::Vector2i &_screenPos, + const rendering::CameraPtr &_camera, + const rendering::RayQueryPtr &_rayQuery, + const float offset = 0.0); + + /// \brief Generate a unique entity id. + /// \return The unique entity id + Entity UniqueId(); + + /// \brief Ignition communication node. + public: transport::Node node; + + /// \brief Flag for indicating whether the preview needs to be generated. + public: bool generatePreview = false; + + /// \brief Flag for indicating whether the user is currently placing a + /// resource or not + public: bool isPlacing = false; + + /// \brief The SDF string of the resource to be used with plugins that spawn + /// entities. + public: std::string spawnSdfString; + + /// \brief Path of an SDF file, to be used with plugins that spawn entities. + public: std::string spawnSdfPath; + + /// \brief Pointer to the rendering scene + public: rendering::ScenePtr scene{nullptr}; + + /// \brief A record of the ids currently used by the entity spawner + /// for easy deletion of visuals later + public: std::vector previewIds; + + /// \brief Pointer to the preview that the user is placing. + public: rendering::NodePtr spawnPreview{nullptr}; + + /// \brief Scene manager + public: SceneManager sceneManager; + + /// \brief The pose of the spawn preview. + public: math::Pose3d spawnPreviewPose = + math::Pose3d::Zero; + + /// \brief Mouse event + public: common::MouseEvent mouseEvent; + + /// \brief Flag to indicate if mouse event is dirty + public: bool mouseDirty = false; + + /// \brief Flag to indicate if hover event is dirty + public: bool hoverDirty = false; + + /// \brief Flag to indicate whether the escape key has been released. + public: bool escapeReleased = false; + + /// \brief The currently hovered mouse position in screen coordinates + public: math::Vector2i mouseHoverPos = math::Vector2i::Zero; + + /// \brief Ray query for mouse clicks + public: rendering::RayQueryPtr rayQuery{nullptr}; + + /// \brief User camera + public: rendering::CameraPtr camera{nullptr}; + + /// \brief Name of service for creating entity + public: std::string createCmdService; + + /// \brief Name of the world + public: std::string worldName; + }; +} + +using namespace ignition; +using namespace gazebo; + +///////////////////////////////////////////////// +Spawn::Spawn() + : ignition::gui::Plugin(), + dataPtr(std::make_unique()) +{ +} + +///////////////////////////////////////////////// +Spawn::~Spawn() = default; + +///////////////////////////////////////////////// +void Spawn::LoadConfig(const tinyxml2::XMLElement *) +{ + if (this->title.empty()) + this->title = "Spawn"; + + // World name from window, to construct default topics and services + auto worldNames = gui::worldNames(); + if (!worldNames.empty()) + this->dataPtr->worldName = worldNames[0].toStdString(); + + ignition::gui::App()->findChild + ()->installEventFilter(this); +} + + +// TODO(ahcorde): Replace this when this function is on ign-rendering6 +///////////////////////////////////////////////// +math::Vector3d SpawnPrivate::ScreenToPlane( + const math::Vector2i &_screenPos, + const rendering::CameraPtr &_camera, + const rendering::RayQueryPtr &_rayQuery, + const float offset) +{ + // Normalize point on the image + double width = _camera->ImageWidth(); + double height = _camera->ImageHeight(); + + double nx = 2.0 * _screenPos.X() / width - 1.0; + double ny = 1.0 - 2.0 * _screenPos.Y() / height; + + // Make a ray query + _rayQuery->SetFromCamera( + _camera, math::Vector2d(nx, ny)); + + math::Planed plane(math::Vector3d(0, 0, 1), offset); + + math::Vector3d origin = _rayQuery->Origin(); + math::Vector3d direction = _rayQuery->Direction(); + double distance = plane.Distance(origin, direction); + return origin + direction * distance; +} + +///////////////////////////////////////////////// +void SpawnPrivate::HandlePlacement() +{ + if (!this->isPlacing) + return; + + if (this->spawnPreview && this->hoverDirty) + { + math::Vector3d pos = this->ScreenToPlane( + this->mouseHoverPos, this->camera, this->rayQuery); + pos.Z(this->spawnPreview->WorldPosition().Z()); + this->spawnPreview->SetWorldPosition(pos); + this->hoverDirty = false; + } + if (this->mouseEvent.Button() == common::MouseEvent::LEFT && + this->mouseEvent.Type() == common::MouseEvent::RELEASE && + !this->mouseEvent.Dragging() && this->mouseDirty) + { + // Delete the generated visuals + this->TerminateSpawnPreview(); + + auto pose = this->spawnPreviewPose; + std::function cb = + [](const msgs::Boolean &/*_rep*/, const bool _result) + { + if (!_result) + ignerr << "Error creating entity" << std::endl; + }; + math::Vector3d pos = this->ScreenToPlane( + this->mouseEvent.Pos(), this->camera, this->rayQuery); + pos.Z(pose.Pos().Z()); + msgs::EntityFactory req; + if (!this->spawnSdfString.empty()) + { + req.set_sdf(this->spawnSdfString); + } + else if (!this->spawnSdfPath.empty()) + { + req.set_sdf_filename(this->spawnSdfPath); + } + else + { + ignwarn << "Failed to find SDF string or file path" << std::endl; + } + req.set_allow_renaming(true); + msgs::Set(req.mutable_pose(), math::Pose3d(pos, pose.Rot())); + + if (this->createCmdService.empty()) + { + this->createCmdService = "/world/" + this->worldName + + "/create"; + } + this->createCmdService = transport::TopicUtils::AsValidTopic( + this->createCmdService); + if (this->createCmdService.empty()) + { + ignerr << "Failed to create valid create command service for world [" + << this->worldName <<"]" << std::endl; + return; + } + + this->node.Request(this->createCmdService, req, cb); + this->isPlacing = false; + this->mouseDirty = false; + this->spawnSdfString.clear(); + this->spawnSdfPath.clear(); + } +} + +///////////////////////////////////////////////// +Entity SpawnPrivate::UniqueId() +{ + auto timeout = 100000u; + for (auto i = 0u; i < timeout; ++i) + { + Entity id = std::numeric_limits::max() - i; + if (!this->sceneManager.HasEntity(id)) + return id; + } + return kNullEntity; +} + +///////////////////////////////////////////////// +void SpawnPrivate::OnRender() +{ + if (nullptr == this->scene) + { + this->scene = rendering::sceneFromFirstRenderEngine(); + if (nullptr == this->scene) + { + return; + } + this->sceneManager.SetScene(this->scene); + + for (unsigned int i = 0; i < this->scene->NodeCount(); ++i) + { + auto cam = std::dynamic_pointer_cast( + this->scene->NodeByIndex(i)); + if (cam) + { + if (std::get(cam->UserData("user-camera"))) + { + this->camera = cam; + + // Ray Query + this->rayQuery = this->camera->Scene()->CreateRayQuery(); + + igndbg << "Spawn plugin is using camera [" + << this->camera->Name() << "]" << std::endl; + break; + } + } + } + } + + // Spawn + IGN_PROFILE("IgnRenderer::Render Spawn"); + if (this->generatePreview) + { + // Generate spawn preview + rendering::VisualPtr rootVis = this->scene->RootVisual(); + sdf::Root root; + if (!this->spawnSdfString.empty()) + { + root.LoadSdfString(this->spawnSdfString); + } + else if (!this->spawnSdfPath.empty()) + { + root.Load(this->spawnSdfPath); + } + else + { + ignwarn << "Failed to spawn: no SDF string or path" << std::endl; + } + this->isPlacing = this->GeneratePreview(root); + this->generatePreview = false; + } + + // Escape action, clear all selections and terminate any + // spawned previews if escape button is released + { + if (this->escapeReleased) + { + this->TerminateSpawnPreview(); + this->escapeReleased = false; + } + } + + this->HandlePlacement(); +} + +///////////////////////////////////////////////// +void SpawnPrivate::TerminateSpawnPreview() +{ + for (auto _id : this->previewIds) + { + this->sceneManager.RemoveEntity(_id); + } + this->previewIds.clear(); + this->isPlacing = false; +} + +///////////////////////////////////////////////// +bool SpawnPrivate::GeneratePreview(const sdf::Root &_sdf) +{ + // Terminate any pre-existing spawned entities + this->TerminateSpawnPreview(); + + if (nullptr == _sdf.Model() && nullptr == _sdf.Light()) + { + ignwarn << "Only model or light entities can be spawned at the moment." + << std::endl; + return false; + } + + if (_sdf.Model()) + { + // Only preview first model + sdf::Model model = *(_sdf.Model()); + this->spawnPreviewPose = model.RawPose(); + model.SetName(common::Uuid().String()); + Entity modelId = this->UniqueId(); + if (kNullEntity == modelId) + { + this->TerminateSpawnPreview(); + return false; + } + this->spawnPreview = this->sceneManager.CreateModel( + modelId, model, this->sceneManager.WorldId()); + + this->previewIds.push_back(modelId); + for (auto j = 0u; j < model.LinkCount(); j++) + { + sdf::Link link = *(model.LinkByIndex(j)); + link.SetName(common::Uuid().String()); + Entity linkId = this->UniqueId(); + if (!linkId) + { + this->TerminateSpawnPreview(); + return false; + } + this->sceneManager.CreateLink(linkId, link, modelId); + this->previewIds.push_back(linkId); + for (auto k = 0u; k < link.VisualCount(); k++) + { + sdf::Visual visual = *(link.VisualByIndex(k)); + visual.SetName(common::Uuid().String()); + Entity visualId = this->UniqueId(); + if (!visualId) + { + this->TerminateSpawnPreview(); + return false; + } + this->sceneManager.CreateVisual(visualId, visual, linkId); + this->previewIds.push_back(visualId); + } + } + } + else if (_sdf.Light()) + { + // Only preview first light + sdf::Light light = *(_sdf.Light()); + this->spawnPreviewPose = light.RawPose(); + light.SetName(common::Uuid().String()); + Entity lightVisualId = this->UniqueId(); + if (!lightVisualId) + { + this->TerminateSpawnPreview(); + return false; + } + Entity lightId = this->UniqueId(); + if (!lightId) + { + this->TerminateSpawnPreview(); + return false; + } + this->spawnPreview = this->sceneManager.CreateLight( + lightId, light, this->sceneManager.WorldId()); + this->sceneManager.CreateLightVisual( + lightVisualId, light, lightId); + + this->previewIds.push_back(lightId); + this->previewIds.push_back(lightVisualId); + } + return true; +} + +//////////////////////////////////////////////// +bool Spawn::eventFilter(QObject *_obj, QEvent *_event) +{ + if (_event->type() == ignition::gui::events::Render::kType) + { + this->dataPtr->OnRender(); + } + else if (_event->type() == ignition::gui::events::LeftClickOnScene::kType) + { + ignition::gui::events::LeftClickOnScene *_e = + static_cast(_event); + this->dataPtr->mouseEvent = _e->Mouse(); + if (this->dataPtr->generatePreview || this->dataPtr->isPlacing) + this->dataPtr->mouseDirty = true; + } + else if (_event->type() == ignition::gui::events::HoverOnScene::kType) + { + ignition::gui::events::HoverOnScene *_e = + static_cast(_event); + this->dataPtr->mouseHoverPos = _e->Mouse().Pos(); + this->dataPtr->hoverDirty = true; + } + else if (_event->type() == + ignition::gui::events::SpawnFromDescription::kType) + { + ignition::gui::events::SpawnFromDescription *_e = + static_cast(_event); + this->dataPtr->spawnSdfString = _e->Description(); + this->dataPtr->generatePreview = true; + } + else if (_event->type() == ignition::gui::events::SpawnFromPath::kType) + { + auto spawnPreviewPathEvent = + reinterpret_cast(_event); + this->dataPtr->spawnSdfPath = spawnPreviewPathEvent->FilePath(); + this->dataPtr->generatePreview = true; + } + else if (_event->type() == ignition::gui::events::KeyReleaseOnScene::kType) + { + ignition::gui::events::KeyReleaseOnScene *_e = + static_cast(_event); + if (_e->Key().Key() == Qt::Key_Escape) + { + this->dataPtr->escapeReleased = true; + } + } + + return QObject::eventFilter(_obj, _event); +} + +// Register this plugin +IGNITION_ADD_PLUGIN(ignition::gazebo::Spawn, + ignition::gui::Plugin) diff --git a/src/gui/plugins/spawn/Spawn.hh b/src/gui/plugins/spawn/Spawn.hh new file mode 100644 index 0000000000..b4c0380db1 --- /dev/null +++ b/src/gui/plugins/spawn/Spawn.hh @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2021 Open Source Robotics Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +#ifndef IGNITION_GAZEBO_GUI_SPAWN_HH_ +#define IGNITION_GAZEBO_GUI_SPAWN_HH_ + +#include + +#include + +namespace ignition +{ +namespace gazebo +{ + class SpawnPrivate; + + /// \brief Allows to spawn models and lights using the spawn gui events. + // TODO(anyone) Support drag and drop + class Spawn : public ignition::gui::Plugin + { + Q_OBJECT + + /// \brief Constructor + public: Spawn(); + + /// \brief Destructor + public: ~Spawn() override; + + // Documentation inherited + public: void LoadConfig(const tinyxml2::XMLElement *_pluginElem) override; + + // Documentation inherited + protected: bool eventFilter(QObject *_obj, QEvent *_event) override; + + /// \internal + /// \brief Pointer to private data. + private: std::unique_ptr dataPtr; + }; +} +} + +#endif diff --git a/src/gui/plugins/spawn/Spawn.qml b/src/gui/plugins/spawn/Spawn.qml new file mode 100644 index 0000000000..873da30014 --- /dev/null +++ b/src/gui/plugins/spawn/Spawn.qml @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2021 Open Source Robotics Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +import QtQuick 2.0 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.3 + +// TODO: remove invisible rectangle, see +// https://github.com/ignitionrobotics/ign-gui/issues/220 +Rectangle { + visible: false + Layout.minimumWidth: 100 + Layout.minimumHeight: 100 +} diff --git a/src/gui/plugins/spawn/Spawn.qrc b/src/gui/plugins/spawn/Spawn.qrc new file mode 100644 index 0000000000..bbdcea6f13 --- /dev/null +++ b/src/gui/plugins/spawn/Spawn.qrc @@ -0,0 +1,5 @@ + + + Spawn.qml + +