├── .gitignore
├── src
└── rqt_py_trees
│ ├── plugins
│ ├── __init__.py
│ ├── plugin.py
│ ├── message_view.py
│ ├── timeline_renderer.py
│ ├── topic_message_view.py
│ └── raw_view.py
│ ├── Readme.md
│ ├── qt_dotgraph
│ ├── __init__.py
│ ├── graph_item.py
│ ├── pygraphvizfactory.py
│ ├── pydotfactory.py
│ ├── edge_item.py
│ ├── node_item.py
│ └── dot_to_qt.py
│ ├── __init__.py
│ ├── timeline_listener.py
│ ├── dynamic_timeline_listener.py
│ ├── topic_helper.py
│ ├── visibility.py
│ ├── message_loader_thread.py
│ ├── dotcode_behaviour.py
│ ├── dynamic_timeline.py
│ └── behaviour_tree.py
├── launch
└── demo.launch
├── setup.py
├── .project
├── .pydevproject
├── CMakeLists.txt
├── plugin.xml
├── CHANGELOG.rst
├── scripts
└── rqt_py_trees
├── LICENSE
├── package.xml
├── README.md
├── test
└── dotcode_tf_test.py
└── resource
└── RosBehaviourTree.ui
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/plugins/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/launch/demo.launch:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/Readme.md:
--------------------------------------------------------------------------------
1 | # About
2 |
3 | This readme is about making sense of what is happening inside the rqt py trees program. I forget
4 | inbetween bouts of hacking on it.
5 |
6 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | from distutils.core import setup
4 | from catkin_pkg.python_setup import generate_distutils_setup
5 |
6 | d = generate_distutils_setup(
7 | packages=['rqt_py_trees', 'rqt_py_trees.plugins', 'rqt_py_trees.qt_dotgraph'],
8 | package_dir={'': 'src'},
9 | scripts=['scripts/rqt_py_trees']
10 | )
11 |
12 | setup(**d)
13 |
--------------------------------------------------------------------------------
/.project:
--------------------------------------------------------------------------------
1 |
2 |
3 | rqt_py_trees
4 |
5 |
6 |
7 |
8 |
9 | org.python.pydev.PyDevBuilder
10 |
11 |
12 |
13 |
14 |
15 | org.python.pydev.pythonNature
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.pydevproject:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | /${PROJECT_DIR_NAME}/src
5 |
6 | python 2.7
7 | Default
8 |
9 |
--------------------------------------------------------------------------------
/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | cmake_minimum_required(VERSION 3.0.2)
2 | project(rqt_py_trees)
3 | set(CMAKE_CXX_FLAGS "-g ${CMAKE_CXX_FLAGS}")
4 | # Load catkin and all dependencies required for this package
5 | find_package(catkin REQUIRED py_trees_msgs rqt_bag)
6 | catkin_package()
7 | catkin_python_setup()
8 |
9 | install(FILES plugin.xml
10 | DESTINATION ${CATKIN_PACKAGE_SHARE_DESTINATION}
11 | )
12 |
13 | install(DIRECTORY resource
14 | DESTINATION ${CATKIN_PACKAGE_SHARE_DESTINATION}
15 | )
16 |
17 | install(PROGRAMS scripts/rqt_py_trees
18 | DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION}
19 | )
20 |
--------------------------------------------------------------------------------
/plugin.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | A Python GUI plugin to visualize py_trees behaviour trees.
5 |
6 |
7 |
8 |
9 | folder
10 | Plugins related to visualization.
11 |
12 |
13 | preferences-system-network
14 | A Python GUI plugin for visualizing py_trees behaviour trees.
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | Changelog
2 | =========
3 |
4 | 0.4.1 (2021-05-09)
5 | ------------------
6 | * bugfix a python2->3 iterator->list generator in dynamic_timeline
7 | * provide a --topic argument on the command line
8 |
9 | 0.4.0 (2020-08-03)
10 | ------------------
11 | * python3 and noetic upgrades
12 |
13 | 0.3.1 (2017-04-11)
14 | ------------------
15 | * add missing python-pygraphviz dependency
16 |
17 | 0.3.0 (2017-03-02)
18 | ------------------
19 | * getting ready for first kinetic release
20 |
21 | 0.2.0 (2017-01-13)
22 | ------------------
23 | * drop 'd' as an option since rqt stole it
24 | * parallels and choosers now draw notes and double octagons like dot graphs
25 |
26 | 0.1.0 (2016-08-25)
27 | ------------------
28 | * initial import from Yujin
29 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/qt_dotgraph/__init__.py:
--------------------------------------------------------------------------------
1 | #
2 | # License: BSD
3 | # https://raw.github.com/stonier/rqt_py_trees/license/LICENSE
4 | #
5 | ##############################################################################
6 | # Documentation
7 | ##############################################################################
8 |
9 | """
10 | Repackages the qt_dotgraph_ ROS package. There are a few
11 | problems with both that and the underlying pydot/pygraphviz
12 | packages, so this has been brought in here for experimentation.
13 |
14 | .. _py_trees: http://wiki.ros.org/qt_dotgraph
15 |
16 | """
17 | ##############################################################################
18 | # Imports
19 | ##############################################################################
20 |
21 | from . import dot_to_qt
22 | from . import edge_item, node_item, graph_item
23 | from . import pydotfactory
--------------------------------------------------------------------------------
/src/rqt_py_trees/__init__.py:
--------------------------------------------------------------------------------
1 | #
2 | # License: BSD
3 | # https://raw.github.com/stonier/rqt_py_trees/license/LICENSE
4 | #
5 | ##############################################################################
6 | # Documentation
7 | ##############################################################################
8 |
9 | """
10 | The rqt behaviour tree plugin.
11 | """
12 | ##############################################################################
13 | # Imports
14 | ##############################################################################
15 |
16 | from . import behaviour_tree
17 | from . import dotcode_behaviour
18 | from . import dynamic_timeline_frame
19 | from . import dynamic_timeline_listener
20 | from . import dynamic_timeline
21 | from . import message_loader_thread
22 | from . import timeline_listener
23 | from . import topic_helper
24 | from . import visibility
25 |
26 | # subpackages
27 | from . import plugins
28 | from . import qt_dotgraph
--------------------------------------------------------------------------------
/scripts/rqt_py_trees:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import sys
4 | import signal
5 | try: # indigo
6 | from python_qt_binding.QtGui import QApplication, QMainWindow
7 | except ImportError: # kinetic+ (pyqt5)
8 | from python_qt_binding.QtWidgets import QApplication, QMainWindow
9 |
10 | from rqt_py_trees.behaviour_tree import RosBehaviourTree
11 |
12 | from rqt_gui.main import Main
13 |
14 | # Crude, but needed to make sure that the display of all the GUI arguments works
15 | # when the -h option is given. If we try to parse options before they won't be
16 | # displayed.
17 | if RosBehaviourTree.no_roscore_switch in sys.argv:
18 | app = QApplication(sys.argv)
19 | window = QMainWindow()
20 | myapp = RosBehaviourTree(window)
21 | window.show()
22 | signal.signal(signal.SIGINT, signal.SIG_DFL)
23 | sys.exit(app.exec_())
24 | else:
25 | main = Main()
26 | sys.exit(main.main(sys.argv, standalone='rqt_py_trees.behaviour_tree.RosBehaviourTree', plugin_argument_provider=RosBehaviourTree.add_arguments))
27 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/timeline_listener.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | from rqt_bag import MessageView
4 |
5 | class TimelineListener(MessageView):
6 | """Basic listener for the timeline. The current message can be accessed using
7 | the ``msg`` member.
8 |
9 | """
10 | def __init__(self, timeline, topic, signal_viewed, signal_cleared):
11 | """
12 | :param timeline: the timeline that this object is attached to
13 | :param topic: the topic that this object is interested in
14 | :param signal_viewed: the signal that should be emitted when the message being viewed changes
15 | :param signal_cleared: the signal that should be emitted when the message being viewed is cleared
16 | """
17 | super(TimelineListener, self).__init__(timeline, topic)
18 | self.signal_viewed = signal_viewed
19 | self.signal_cleared = signal_cleared
20 | self.msg = None
21 |
22 | def message_viewed(self, bag, msg_details):
23 | """Called whenever the message is updated. Updates the stored message and emits
24 | a signal.
25 |
26 | """
27 | self.msg = msg_details[1]
28 | self.signal_viewed.emit()
29 |
30 | def message_cleared(self):
31 | self.signal_cleared.emit()
32 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/dynamic_timeline_listener.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import rospy
4 | from .plugins.message_view import MessageView
5 |
6 | class DynamicTimelineListener(MessageView):
7 | """Basic listener for the timeline. The current message can be accessed using
8 | the ``msg`` member.
9 |
10 | """
11 | def __init__(self, timeline, topic, signal_viewed, signal_cleared):
12 | """
13 | :param timeline: the timeline that this object is attached to
14 | :param topic: the topic that this object is interested in
15 | :param signal_viewed: the signal that should be emitted when the message being viewed changes
16 | :param signal_cleared: the signal that should be emitted when the message being viewed is cleared
17 | """
18 | super(DynamicTimelineListener, self).__init__(timeline, topic)
19 | self.signal_viewed = signal_viewed
20 | self.signal_cleared = signal_cleared
21 | self.msg = None
22 |
23 | def message_viewed(self, msg_details):
24 | """Called whenever the message is updated. Updates the stored message and emits
25 | a signal.
26 | """
27 | self.msg = msg_details
28 | # rospy.loginfo("got message {0} on topic {1}".format(msg_details.behaviours[0].name, self.topic))
29 | self.signal_viewed.emit()
30 |
31 | def message_cleared(self):
32 | self.signal_cleared.emit()
33 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/qt_dotgraph/graph_item.py:
--------------------------------------------------------------------------------
1 | #
2 | # License: Yujin
3 | #
4 | ##############################################################################
5 | # Description
6 | ##############################################################################
7 |
8 | """
9 | .. module:: graph_item
10 | :platform: Unix
11 | :synopsis: Repackaging of the limiting ROS qt_dotgraph.graph_item module.
12 |
13 | Oh my spaghettified magnificence,
14 | Bless my noggin with a tickle from your noodly appendages!
15 |
16 | """
17 |
18 | ##############################################################################
19 | # Imports
20 | ##############################################################################
21 |
22 | from python_qt_binding.QtGui import QColor
23 | try: # indigo
24 | from python_qt_binding.QtGui import QGraphicsItemGroup
25 | except ImportError: # kinetic+ (pyqt5)
26 | from python_qt_binding.QtWidgets import QGraphicsItemGroup
27 |
28 | ##############################################################################
29 | # Classes
30 | ##############################################################################
31 |
32 |
33 | class GraphItem(QGraphicsItemGroup):
34 |
35 | _COLOR_BLACK = QColor(0, 0, 0)
36 | _COLOR_BLUE = QColor(0, 0, 204)
37 | _COLOR_GREEN = QColor(0, 170, 0)
38 | _COLOR_ORANGE = QColor(255, 165, 0)
39 | _COLOR_RED = QColor(255, 0, 0)
40 | _COLOR_TEAL = QColor(0, 170, 170)
41 |
42 | def __init__(self, highlight_level, parent=None):
43 | super(GraphItem, self).__init__(parent)
44 | self._highlight_level = highlight_level
45 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | # Software License Agreement (BSD License)
2 | #
3 | # Copyright (c) 2015 Daniel Stonier
4 | # All rights reserved.
5 | #
6 | # Redistribution and use in source and binary forms, with or without
7 | # modification, are permitted provided that the following conditions
8 | # are met:
9 | #
10 | # * Redistributions of source code must retain the above copyright
11 | # notice, this list of conditions and the following disclaimer.
12 | # * Redistributions in binary form must reproduce the above
13 | # copyright notice, this list of conditions and the following
14 | # disclaimer in the documentation and/or other materials provided
15 | # with the distribution.
16 | # * Neither the name of Yujin Robot nor the names of its
17 | # contributors may be used to endorse or promote products derived
18 | # from this software without specific prior written permission.
19 | #
20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
23 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
24 | # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
25 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
26 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
29 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
30 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31 | # POSSIBILITY OF SUCH DAMAGE.
32 |
33 |
--------------------------------------------------------------------------------
/package.xml:
--------------------------------------------------------------------------------
1 |
2 | rqt_py_trees
3 | 0.4.1
4 |
5 | rqt_py_trees provides a GUI plugin for visualizing py_trees behaviour trees based on rqt_tf_tree.
6 |
7 |
8 | Michal Staniaszek
9 | Daniel Stonier
10 | Naveed Usmani
11 |
12 | BSD
13 |
14 | http://ros.org/wiki/rqt_py_trees
15 | https://github.com/stonier/rqt_py_trees
16 | https://github.com/stonier/rqt_py_trees/issues
17 |
18 | Thibault Kruse
19 | Michal Staniaszek
20 | Daniel Stonier
21 | Naveed Usmani
22 |
23 | catkin
24 | py_trees
25 | py_trees_msgs
26 | rqt_bag
27 |
28 | geometry_msgs
29 | python3-rospkg
30 | python3-termcolor
31 | qt_dotgraph
32 | rospy
33 | rqt_graph
34 | rqt_gui
35 | rqt_gui_py
36 | py_trees
37 | py_trees_msgs
38 | rqt_bag
39 | unique_id
40 | python3-pygraphviz
41 |
42 | python3-mock
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | *ROS1 only!!!*
2 |
3 | *See [py_trees_ros_viewer](https://github.com/splintered-reality/py_trees_ros_viewer) for the ROS2 visualisation tool for py_trees.*
4 |
5 | # Rqt Py Trees
6 |
7 | Refer to the sphinx documentation for [py_trees](http://py-trees.readthedocs.io/en/devel/) as a primer and reference on behaviour trees & [py_trees_ros](https://stonier.github.io/py_trees_ros/) for tutorials on getting started with behaviour trees in ROS.
8 |
9 | ## The ROS Py Trees Packages
10 |
11 | * [py_trees](https://github.com/stonier/py_trees) ([sphinx documentation](http://py-trees.readthedocs.io/en/devel/))
12 | * [py_trees_msgs](https://github.com/stonier/py_trees_msgs)
13 | * [py_trees_ros](https://github.com/stonier/py_trees_ros) ([sphinx documentation](https://stonier.github.io/py_trees_ros/))
14 | * [rqt_py_trees](https://github.com/stonier/rqt_py_trees)
15 |
16 |
17 | ## Build Status
18 |
19 | | Devel | Kinetic |
20 | |:---:|:---:|
21 | | [![devel-Sources][devel-sources-image]][devel-sources] | [![0.3.x-Sources][0.3.x-sources-image]][0.3.x-sources] |
22 | | [![devel-Status][devel-build-status-image]][devel-build-status] | [![kinetic-Status][kinetic-build-status-image]][kinetic-build-status] | |
23 |
24 | [devel-sources-image]: http://img.shields.io/badge/sources-devel-blue.svg?style=plastic
25 | [devel-sources]: https://github.com/stonier/rqt_py_trees/tree/devel
26 | [0.3.x-sources-image]: http://img.shields.io/badge/sources-0.3.x--kinetic-blue.svg?style=plastic
27 | [0.3.x-sources]: https://github.com/stonier/rqt_py_trees/tree/release/0.3-kinetic
28 |
29 | [devel-build-status-image]: http://build.ros.org/job/Kdev__rqt_py_trees__ubuntu_xenial_amd64/badge/icon?style=plastic
30 | [devel-build-status]: http://build.ros.org/job/Kdev__rqt_py_trees__ubuntu_xenial_amd64
31 | [kinetic-build-status-image]: http://build.ros.org/job/Kbin_uX64__rqt_py_trees__ubuntu_xenial_amd64__binary/badge/icon?style=plastic
32 | [kinetic-build-status]: http://build.ros.org/job/Kbin_uX64__rqt_py_trees__ubuntu_xenial_amd64__binary
33 |
34 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/plugins/plugin.py:
--------------------------------------------------------------------------------
1 | # Software License Agreement (BSD License)
2 | #
3 | # Copyright (c) 2012, Willow Garage, Inc.
4 | # All rights reserved.
5 | #
6 | # Redistribution and use in source and binary forms, with or without
7 | # modification, are permitted provided that the following conditions
8 | # are met:
9 | #
10 | # * Redistributions of source code must retain the above copyright
11 | # notice, this list of conditions and the following disclaimer.
12 | # * Redistributions in binary form must reproduce the above
13 | # copyright notice, this list of conditions and the following
14 | # disclaimer in the documentation and/or other materials provided
15 | # with the distribution.
16 | # * Neither the name of Willow Garage, Inc. nor the names of its
17 | # contributors may be used to endorse or promote products derived
18 | # from this software without specific prior written permission.
19 | #
20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
23 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
24 | # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
25 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
26 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
29 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
30 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31 | # POSSIBILITY OF SUCH DAMAGE.
32 |
33 |
34 | class Plugin(object):
35 |
36 | """
37 | Interface for rqt_bag plugins.
38 | User-defined plugins may either subclass `rqt_bag.plugin.Plugin` or according to duck typing implement only the needed methods.
39 | """
40 |
41 | def __init__(self):
42 | pass
43 |
44 | def get_view_class(self):
45 | """Return a class which is a child of rqt_bag.plugin.topic_message_view.TopicMessageView."""
46 | raise NotImplementedError()
47 |
48 | def get_renderer_class(self):
49 | """
50 | Return a class which is a child of rqt_bag.plugin.timeline_renderer.TimelineRenderer.
51 | To omit the renderer component simply return None.
52 | """
53 | return None
54 |
55 | def get_message_types(self):
56 | """
57 | Return alist of message types which this plugin operates on.
58 | To allow your plugin to be run on all message types return ['*'].
59 | """
60 | return []
61 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/topic_helper.py:
--------------------------------------------------------------------------------
1 | # Software License Agreement (BSD License)
2 | #
3 | # Copyright (c) 2012, Willow Garage, Inc.
4 | # All rights reserved.
5 | #
6 | # Redistribution and use in source and binary forms, with or without
7 | # modification, are permitted provided that the following conditions
8 | # are met:
9 | #
10 | # * Redistributions of source code must retain the above copyright
11 | # notice, this list of conditions and the following disclaimer.
12 | # * Redistributions in binary form must reproduce the above
13 | # copyright notice, this list of conditions and the following
14 | # disclaimer in the documentation and/or other materials provided
15 | # with the distribution.
16 | # * Neither the name of Willow Garage, Inc. nor the names of its
17 | # contributors may be used to endorse or promote products derived
18 | # from this software without specific prior written permission.
19 | #
20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
23 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
24 | # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
25 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
26 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICTS
29 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
30 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31 | # POSSIBILITY OF SUCH DAMAGE.
32 |
33 | """
34 | Helper functions for bag files and timestamps.
35 | """
36 |
37 | import time
38 | import rospy
39 |
40 |
41 | def stamp_to_str(t):
42 | """
43 | Convert a rospy.Time to a human-readable string.
44 |
45 | @param t: time to convert
46 | @type t: rospy.Time
47 | """
48 | t_sec = t.to_sec()
49 | if t < rospy.Time.from_sec(60 * 60 * 24 * 365 * 5):
50 | # Display timestamps earlier than 1975 as seconds
51 | return '%.3fs' % t_sec
52 | else:
53 | return time.strftime('%b %d %Y %H:%M:%S', time.localtime(t_sec)) + '.%03d' % (t.nsecs / 1000000)
54 |
55 | def get_start_stamp(topic):
56 | """
57 | Get the earliest timestamp in the topic.
58 |
59 | :param topic: topic tuple
60 | :type topic: ``DynamicTimeline.Topic`` named tuple
61 | :return: earliest timestamp, ``rospy.Time``
62 | """
63 | start_stamp = None
64 | try:
65 | start_stamp = topic.queue[0].stamp
66 | except IndexError:
67 | pass
68 |
69 | return start_stamp
70 |
71 | def get_end_stamp(topic):
72 | """
73 | Get the latest timestamp in the topic.
74 |
75 | :param topic: topic tuple
76 | :type topic: ``DynamicTimeline.Topic`` named tuple
77 | :return: latest timestamp, ``rospy.Time``
78 | """
79 | end_stamp = None
80 | try:
81 | end_stamp = topic.queue[-1].stamp
82 | except IndexError:
83 | pass
84 |
85 | return end_stamp
86 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/plugins/message_view.py:
--------------------------------------------------------------------------------
1 | # Software License Agreement (BSD License)
2 | #
3 | # Copyright (c) 2012, Willow Garage, Inc.
4 | # All rights reserved.
5 | #
6 | # Redistribution and use in source and binary forms, with or without
7 | # modification, are permitted provided that the following conditions
8 | # are met:
9 | #
10 | # * Redistributions of source code must retain the above copyright
11 | # notice, this list of conditions and the following disclaimer.
12 | # * Redistributions in binary form must reproduce the above
13 | # copyright notice, this list of conditions and the following
14 | # disclaimer in the documentation and/or other materials provided
15 | # with the distribution.
16 | # * Neither the name of Willow Garage, Inc. nor the names of its
17 | # contributors may be used to endorse or promote products derived
18 | # from this software without specific prior written permission.
19 | #
20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
23 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
24 | # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
25 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
26 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
29 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
30 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31 | # POSSIBILITY OF SUCH DAMAGE.
32 |
33 |
34 | from python_qt_binding.QtCore import QObject
35 |
36 |
37 | class MessageView(QObject):
38 | """
39 | A message details renderer. When registered with rqt_bag, a MessageView is called
40 | whenever the timeline playhead moves.
41 | """
42 | name = 'Untitled'
43 |
44 | def __init__(self, timeline, topic):
45 | super(MessageView, self).__init__()
46 | self.timeline = timeline
47 | self.topic = topic
48 |
49 | def message_viewed(self, msg_details):
50 | """View the message.
51 |
52 | :param msg_details: the details of the message to be viewed
53 | :type msg_details: Message namedtuple (stamp, message). Stamp is the
54 | timestamp, message is the message received
55 |
56 | """
57 | pass
58 |
59 | def message_cleared(self):
60 | """
61 | Clear the currently viewed message (if any).
62 | """
63 | pass
64 |
65 | def timeline_changed(self):
66 | """
67 | Called when the messages in a timeline change, e.g. if a new message is recorded, or
68 | a bag file is added
69 | """
70 | pass
71 |
72 | def close(self):
73 | """
74 | Close the message view, releasing any resources.
75 | """
76 | pass
77 |
78 | # NOTE: event function should not be changed in subclasses
79 | def event(self, event):
80 | """
81 | This function will be called to process events posted by post_event
82 | it will call message_cleared or message_viewed with the relevant data
83 | """
84 | msg_data = event.data
85 | if msg_data:
86 | self.message_viewed(msg_data)
87 | else:
88 | self.message_cleared()
89 | return True
90 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/plugins/timeline_renderer.py:
--------------------------------------------------------------------------------
1 | # Software License Agreement (BSD License)
2 | #
3 | # Copyright (c) 2012, Willow Garage, Inc.
4 | # All rights reserved.
5 | #
6 | # Redistribution and use in source and binary forms, with or without
7 | # modification, are permitted provided that the following conditions
8 | # are met:
9 | #
10 | # * Redistributions of source code must retain the above copyright
11 | # notice, this list of conditions and the following disclaimer.
12 | # * Redistributions in binary form must reproduce the above
13 | # copyright notice, this list of conditions and the following
14 | # disclaimer in the documentation and/or other materials provided
15 | # with the distribution.
16 | # * Neither the name of Willow Garage, Inc. nor the names of its
17 | # contributors may be used to endorse or promote products derived
18 | # from this software without specific prior written permission.
19 | #
20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
23 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
24 | # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
25 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
26 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
29 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
30 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31 | # POSSIBILITY OF SUCH DAMAGE.
32 |
33 | from python_qt_binding.QtCore import QObject
34 |
35 |
36 | class TimelineRenderer(QObject):
37 | """
38 | A custom renderer for interval of time of a topic on the timeline.
39 |
40 | @param msg_combine_px: don't draw discrete messages if they're less than this many pixels separated [default: 1.5]
41 | @type msg_combine_px: float
42 | """
43 | def __init__(self, timeline, msg_combine_px=1.5):
44 | self.timeline = timeline
45 | self.msg_combine_px = msg_combine_px
46 |
47 | def get_segment_height(self, topic):
48 | """
49 | Get the height of the topic segment on the timeline.
50 |
51 | @param topic: topic name to draw
52 | @type topic: str
53 | @return: height in pixels of the topic segment. If none, the timeline default is used.
54 | @rtype: int or None
55 | """
56 | return None
57 |
58 | def draw_timeline_segment(self, painter, topic, stamp_start, stamp_end, x, y, width, height):
59 | """
60 | Draw the timeline segment.
61 |
62 | @param painter: QPainter context to render into
63 | @param topic: topic name
64 | @param stamp_start: start of the interval on the timeline
65 | @param stamp_end: start of the interval on the timeline
66 | @param x: x coordinate of the timeline interval
67 | @param y: y coordinate of the timeline interval
68 | @param width: width in pixels of the timeline interval
69 | @param height: height in pixels of the timeline interval
70 | @return: whether the interval was renderered
71 | @rtype: bool
72 | """
73 | return False
74 |
75 | def close(self):
76 | """
77 | Close the renderer, releasing any resources.
78 | """
79 | pass
80 |
--------------------------------------------------------------------------------
/test/dotcode_tf_test.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # Software License Agreement (BSD License)
3 | #
4 | # Copyright (c) 2009, Willow Garage, Inc.
5 | # All rights reserved.
6 | #
7 | # Redistribution and use in source and binary forms, with or without
8 | # modification, are permitted provided that the following conditions
9 | # are met:
10 | #
11 | # * Redistributions of source code must retain the above copyright
12 | # notice, this list of conditions and the following disclaimer.
13 | # * Redistributions in binary form must reproduce the above
14 | # copyright notice, this list of conditions and the following
15 | # disclaimer in the documentation and/or other materials provided
16 | # with the distribution.
17 | # * Neither the name of Willow Garage, Inc. nor the names of its
18 | # contributors may be used to endorse or promote products derived
19 | # from this software without specific prior written permission.
20 | #
21 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
22 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
23 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
24 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
25 | # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
26 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
27 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
28 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
29 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
30 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
31 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
32 | # POSSIBILITY OF SUCH DAMAGE.
33 |
34 | # import unittest
35 | # import rospkg
36 |
37 | # import tf
38 | # from tf.srv import *
39 |
40 | # # get mock from pypi as 'mock'
41 | # from mock import Mock, MagicMock, patch
42 |
43 | # from rqt_tf_tree.dotcode_tf import RosTfTreeDotcodeGenerator
44 |
45 |
46 | # class DotcodeGeneratorTest(unittest.TestCase):
47 |
48 | # def test_generate_dotcode(self):
49 | # with patch('tf.TransformListener') as tf:
50 | # def tf_srv_fun_mock():
51 | # return tf
52 |
53 | # yaml_data = {'frame1': {'parent': 'fr_parent',
54 | # 'broadcaster': 'fr_broadcaster',
55 | # 'rate': 'fr_rate',
56 | # 'buffer_length': 'fr_buffer_length',
57 | # 'most_recent_transform': 'fr_most_recent_transform',
58 | # 'oldest_transform': 'fr_oldest_transform',}}
59 | # tf.frame_yaml = str(yaml_data)
60 |
61 | # factoryMock = Mock()
62 | # graphMock = Mock()
63 | # timeMock = Mock()
64 | # timerMock = Mock()
65 | # timerMock.now.return_value=timeMock
66 | # timeMock.to_sec.return_value=42
67 |
68 | # yamlmock = Mock()
69 | # yamlmock.load.return_value = yaml_data
70 |
71 | # factoryMock.create_dot.return_value = "foo"
72 | # factoryMock.get_graph.return_value = graphMock
73 |
74 | # gen = RosTfTreeDotcodeGenerator(0)
75 | # graph = gen.generate_dotcode(factoryMock, tf_srv_fun_mock, timerMock)
76 |
77 | # timerMock.now.assert_called_with()
78 | # timeMock.to_sec.assert_called_with()
79 | # factoryMock.create_dot.assert_called_with(graphMock)
80 |
81 | # self.assertEqual(graph, 'foo')
82 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/qt_dotgraph/pygraphvizfactory.py:
--------------------------------------------------------------------------------
1 | #
2 | # License: Yujin
3 | #
4 | ##############################################################################
5 | # Description
6 | ##############################################################################
7 |
8 | """
9 | .. module:: pygraphvizfactory
10 | :platform: Unix
11 | :synopsis: Repackaging of the limiting ROS qt_dotgraph.pygraphvizfactory module.
12 |
13 | Oh my spaghettified magnificence,
14 | Bless my noggin with a tickle from your noodly appendages!
15 |
16 | """
17 |
18 | ##############################################################################
19 | # Imports
20 | ##############################################################################
21 |
22 | import pygraphviz
23 |
24 | ##############################################################################
25 | # Classes
26 | ##############################################################################
27 |
28 |
29 | # Reference implementation for a dotcode factory
30 | class PygraphvizFactory():
31 |
32 | def __init__(self):
33 | pass
34 |
35 | def get_graph(self, graph_type='digraph', rank='same', simplify=True, rankdir='TB', ranksep=0.2, compound=True):
36 | graph = pygraphviz.AGraph(directed=(graph_type == 'digraph'), ranksep=ranksep, rankdir=rankdir, rank=rank, compound=True, simplify=simplify)
37 | return graph
38 |
39 | def add_node_to_graph(self,
40 | graph,
41 | nodename,
42 | nodelabel=None,
43 | shape='box',
44 | color=None,
45 | url=None,
46 | tooltip=None):
47 | """
48 | creates a node item for this factory, adds it to the graph.
49 | Node name can vary from label but must always be same for the same node label
50 | """
51 | if nodename is None or nodename == '':
52 | raise ValueError('Empty Node name')
53 | if nodelabel is None:
54 | nodelabel = nodename
55 |
56 | kwargs = {}
57 | if tooltip is not None:
58 | kwargs['tooltip'] = tooltip
59 | if color is not None:
60 | kwargs['color'] = color
61 |
62 | graph.add_node(nodename, label=str(nodelabel), shape=shape, url=url, **kwargs)
63 |
64 | def add_subgraph_to_graph(self,
65 | graph,
66 | subgraphlabel,
67 | rank='same',
68 | simplify=True,
69 | rankdir='TB',
70 | ranksep=0.2,
71 | compound=True,
72 | color=None,
73 | shape='box',
74 | style='bold'):
75 | """
76 | creates a cluster subgraph item for this factory, adds it to the graph.
77 | cluster name can vary from label but must always be same for the same node label.
78 | Most layouters require cluster names to start with cluster.
79 | """
80 | if subgraphlabel is None or subgraphlabel == '':
81 | raise ValueError('Empty subgraph label')
82 |
83 | sg = graph.add_subgraph(name="cluster_%s" % subgraphlabel, ranksep=ranksep, rankdir=rankdir, rank=rank, compound=compound, label=str(subgraphlabel), style=style, color=color)
84 |
85 | return sg
86 |
87 | def add_edge_to_graph(self, graph, nodename1, nodename2, label=None, url=None, simplify=True, style=None, penwidth=1, color=None):
88 | kwargs = {'url': url}
89 | if label is not None:
90 | kwargs['label'] = label
91 | if style is not None:
92 | kwargs['style'] = style
93 | kwargs['penwidth'] = str(penwidth)
94 | if color is not None:
95 | kwargs['colorR'] = str(color[0])
96 | kwargs['colorG'] = str(color[1])
97 | kwargs['colorB'] = str(color[2])
98 | graph.add_edge(nodename1, nodename2, **kwargs)
99 |
100 | def create_dot(self, graph):
101 | graph.layout('dot')
102 | # sadly pygraphviz generates line wraps cutting between numbers
103 | return graph.string().replace("\\\n", "")
104 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/visibility.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | #
3 | # License: BSD
4 | # https://raw.github.com/stonier/rqt_py_trees/license/LICENSE
5 | #
6 | ##############################################################################
7 | # Documentation
8 | ##############################################################################
9 |
10 | """
11 | .. module:: visibility
12 | :platform: Unix
13 | :synopsis: Converting back and forth the visibility level formats.
14 |
15 | We have a python variable, a ros msg variable, and also the combobox variable!
16 | """
17 |
18 | ##############################################################################
19 | # Imports
20 | ##############################################################################
21 |
22 | import collections
23 | import py_trees
24 | import py_trees_msgs.msg as py_trees_msgs
25 | import uuid_msgs.msg as uuid_msgs
26 | import unique_id
27 |
28 | ##############################################################################
29 | # Imports
30 | ##############################################################################
31 |
32 | combo_to_py_trees = collections.OrderedDict([
33 | ("All", py_trees.common.VisibilityLevel.ALL),
34 | ("Detail", py_trees.common.VisibilityLevel.DETAIL),
35 | ("Component", py_trees.common.VisibilityLevel.COMPONENT),
36 | ("Big Picture", py_trees.common.VisibilityLevel.BIG_PICTURE)]
37 | )
38 |
39 | saved_setting_to_combo_index = {
40 | 0: 0,
41 | 1: 1,
42 | 2: 2,
43 | 3: 3
44 | }
45 |
46 | msg_to_py_trees = {
47 | py_trees_msgs.Behaviour.BLACKBOX_LEVEL_DETAIL: py_trees.common.BlackBoxLevel.DETAIL,
48 | py_trees_msgs.Behaviour.BLACKBOX_LEVEL_COMPONENT: py_trees.common.BlackBoxLevel.COMPONENT,
49 | py_trees_msgs.Behaviour.BLACKBOX_LEVEL_BIG_PICTURE: py_trees.common.BlackBoxLevel.BIG_PICTURE,
50 | py_trees_msgs.Behaviour.BLACKBOX_LEVEL_NOT_A_BLACKBOX: py_trees.common.BlackBoxLevel.NOT_A_BLACKBOX
51 | }
52 |
53 |
54 | def is_root(behaviour_id):
55 | """
56 | Check the unique id to determine if it is the root (all zeros).
57 |
58 | :param uuid.UUID behaviour_id:
59 | """
60 | return behaviour_id == unique_id.fromMsg(uuid_msgs.UniqueID())
61 |
62 |
63 | def get_branch_blackbox_level(behaviours, behaviour_id, current_level):
64 | """
65 | Computes the critial (minimum) blackbox level present in the branch above
66 | this behaviour.
67 |
68 | :param {id: py_trees_msgs.Behaviour} behaviours: (sub)tree of all behaviours, including this one
69 | :param uuid.UUID behaviour_id: id of this behavour
70 | :param py_trees.common.BlackBoxLevel current_level
71 | """
72 | if is_root(behaviour_id):
73 | return current_level
74 | parent_id = unique_id.fromMsg(behaviours[behaviour_id].parent_id)
75 | new_level = min(behaviours[behaviour_id].blackbox_level, current_level)
76 | return get_branch_blackbox_level(behaviours, parent_id, new_level)
77 |
78 |
79 | def is_visible(behaviours, behaviour_id, visibility_level):
80 | """
81 | :param {id: py_trees_msgs.Behaviour} behaviours:
82 | :param uuid.UUID behaviour_id:
83 | :param py_trees.common.VisibilityLevel visibility_level
84 | """
85 | branch_blackbox_level = get_branch_blackbox_level(
86 | behaviours,
87 | unique_id.fromMsg(behaviours[behaviour_id].parent_id),
88 | py_trees.common.BlackBoxLevel.NOT_A_BLACKBOX
89 | )
90 | # see also py_trees.display.generate_pydot_graph
91 | return visibility_level < branch_blackbox_level
92 |
93 |
94 | def filter_behaviours_by_visibility_level(behaviours, visibility_level):
95 | """
96 | Drops any behaviours whose blackbox level does not match the required visibility
97 | level. See the py_trees.common module for more information.
98 |
99 | :param py_trees_msgs.msg.Behaviour[] behaviours
100 | :returns: py_trees_msgs.msg.Behaviour[]
101 | """
102 | behaviours_by_id = {unique_id.fromMsg(b.own_id): b for b in behaviours}
103 | visible_behaviours = [b for b in behaviours if is_visible(behaviours_by_id,
104 | unique_id.fromMsg(b.own_id),
105 | visibility_level)
106 | ]
107 | return visible_behaviours
108 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/message_loader_thread.py:
--------------------------------------------------------------------------------
1 | # Software License Agreement (BSD License)
2 | #
3 | # Copyright (c) 2012, Willow Garage, Inc.
4 | # All rights reserved.
5 | #
6 | # Redistribution and use in source and binary forms, with or without
7 | # modification, are permitted provided that the following conditions
8 | # are met:
9 | #
10 | # * Redistributions of source code must retain the above copyright
11 | # notice, this list of conditions and the following disclaimer.
12 | # * Redistributions in binary form must reproduce the above
13 | # copyright notice, this list of conditions and the following
14 | # disclaimer in the documentation and/or other materials provided
15 | # with the distribution.
16 | # * Neither the name of Willow Garage, Inc. nor the names of its
17 | # contributors may be used to endorse or promote products derived
18 | # from this software without specific prior written permission.
19 | #
20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
23 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
24 | # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
25 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
26 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
29 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
30 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31 | # POSSIBILITY OF SUCH DAMAGE.
32 |
33 | import threading
34 |
35 |
36 | class MessageLoaderThread(threading.Thread):
37 | """
38 | Waits for a new playhead position on the given topic, then loads the message at that position and notifies the view threads.
39 |
40 | One thread per topic. Maintains a cache of recently loaded messages.
41 | """
42 | def __init__(self, timeline, topic):
43 | threading.Thread.__init__(self)
44 |
45 | self.timeline = timeline
46 | self.topic = topic
47 |
48 | self.topic_playhead_position = None
49 |
50 | self._message_cache_capacity = 50
51 | self._message_cache = {}
52 | self._message_cache_keys = []
53 |
54 | self._stop_flag = False
55 |
56 | self.setDaemon(True)
57 | self.start()
58 |
59 | def reset(self):
60 | self.bag_playhead_position = None
61 |
62 | def run(self):
63 | while not self._stop_flag:
64 | # Wait for a new entry
65 | cv = self.timeline._playhead_positions_cvs[self.topic]
66 | with cv:
67 | while (self.topic not in self.timeline._playhead_positions) or (self.topic_playhead_position == self.timeline._playhead_positions[self.topic]):
68 | cv.wait()
69 | if self._stop_flag:
70 | return
71 | playhead_position = self.timeline._playhead_positions[self.topic]
72 |
73 | self.topic_playhead_position = playhead_position
74 |
75 | # Don't bother loading the message if there are no listeners
76 | if not self.timeline.has_listeners(self.topic):
77 | continue
78 |
79 | # Load the message
80 | if playhead_position is None:
81 | msg_data = None
82 | else:
83 | msg_data = self._get_message(playhead_position)
84 |
85 | # Inform the views
86 | messages_cv = self.timeline._messages_cvs[self.topic]
87 | with messages_cv:
88 | self.timeline._messages[self.topic] = msg_data
89 | messages_cv.notify_all() # notify all views that a message is loaded
90 |
91 | def _get_message(self, position):
92 | key = str(position)
93 | if key in self._message_cache:
94 | return self._message_cache[key]
95 |
96 | msg_data = self.timeline.read_message(self.topic, position)
97 |
98 | self._message_cache[key] = msg_data
99 | self._message_cache_keys.append(key)
100 |
101 | if len(self._message_cache) > self._message_cache_capacity:
102 | oldest_key = self._message_cache_keys[0]
103 | del self._message_cache[oldest_key]
104 | self._message_cache_keys.remove(oldest_key)
105 |
106 | return msg_data
107 |
108 | def stop(self):
109 | self._stop_flag = True
110 | cv = self.timeline._playhead_positions_cvs[self.topic]
111 | with cv:
112 | print("DJS: self.timeline._playhead_positions_cvs[self.topic].notify_all() [MessageLoader:stop")
113 | cv.notify_all()
114 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/plugins/topic_message_view.py:
--------------------------------------------------------------------------------
1 | # Software License Agreement (BSD License)
2 | #
3 | # Copyright (c) 2012, Willow Garage, Inc.
4 | # All rights reserved.
5 | #
6 | # Redistribution and use in source and binary forms, with or without
7 | # modification, are permitted provided that the following conditions
8 | # are met:
9 | #
10 | # * Redistributions of source code must retain the above copyright
11 | # notice, this list of conditions and the following disclaimer.
12 | # * Redistributions in binary form must reproduce the above
13 | # copyright notice, this list of conditions and the following
14 | # disclaimer in the documentation and/or other materials provided
15 | # with the distribution.
16 | # * Neither the name of Willow Garage, Inc. nor the names of its
17 | # contributors may be used to endorse or promote products derived
18 | # from this software without specific prior written permission.
19 | #
20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
23 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
24 | # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
25 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
26 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
29 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
30 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31 | # POSSIBILITY OF SUCH DAMAGE.
32 | from .message_view import MessageView
33 |
34 | from python_qt_binding.QtGui import QIcon
35 | try: # indigo
36 | from python_qt_binding.QtGui import QAction, QToolBar
37 | except ImportError: # kinetic+ (pyqt5)
38 | from python_qt_binding.QtWidgets import QAction, QToolBar
39 |
40 |
41 | class TopicMessageView(MessageView):
42 | """
43 | A message view with a toolbar for navigating messages in a single topic.
44 | """
45 | def __init__(self, timeline, parent, topic):
46 | MessageView.__init__(self, timeline, topic)
47 |
48 | self._parent = parent
49 | self._stamp = None
50 | self._name = parent.objectName()
51 |
52 | self.toolbar = QToolBar()
53 | self._first_action = QAction(QIcon.fromTheme('go-first'), '', self.toolbar)
54 | self._first_action.triggered.connect(self.navigate_first)
55 | self.toolbar.addAction(self._first_action)
56 | self._prev_action = QAction(QIcon.fromTheme('go-previous'), '', self.toolbar)
57 | self._prev_action.triggered.connect(self.navigate_previous)
58 | self.toolbar.addAction(self._prev_action)
59 | self._next_action = QAction(QIcon.fromTheme('go-next'), '', self.toolbar)
60 | self._next_action.triggered.connect(self.navigate_next)
61 | self.toolbar.addAction(self._next_action)
62 | self._last_action = QAction(QIcon.fromTheme('go-last'), '', self.toolbar)
63 | self._last_action.triggered.connect(self.navigate_last)
64 | self.toolbar.addAction(self._last_action)
65 | parent.layout().addWidget(self.toolbar)
66 |
67 | @property
68 | def parent(self):
69 | return self._parent
70 |
71 | @property
72 | def stamp(self):
73 | return self._stamp
74 |
75 | # MessageView implementation
76 |
77 | def message_viewed(self, bag, msg_details):
78 | _, _, self._stamp = msg_details[:3]
79 |
80 | # Events
81 | def navigate_first(self):
82 | for entry in self.timeline.get_entries([self.topic], *self.timeline._timeline_frame.play_region):
83 | self.timeline._timeline_frame.playhead = entry.time
84 | break
85 |
86 | def navigate_previous(self):
87 | last_entry = None
88 | for entry in self.timeline.get_entries([self.topic], self.timeline._timeline_frame.start_stamp, self.timeline._timeline_frame.playhead):
89 | if entry.time < self.timeline._timeline_frame.playhead:
90 | last_entry = entry
91 |
92 | if last_entry:
93 | self.timeline._timeline_frame.playhead = last_entry.time
94 |
95 | def navigate_next(self):
96 | for entry in self.timeline.get_entries([self.topic], self.timeline._timeline_frame.playhead, self.timeline._timeline_frame.end_stamp):
97 | if entry.time > self.timeline._timeline_frame.playhead:
98 | self.timeline._timeline_frame.playhead = entry.time
99 | break
100 |
101 | def navigate_last(self):
102 | last_entry = None
103 | for entry in self.timeline.get_entries([self.topic], *self.timeline._timeline_frame.play_region):
104 | last_entry = entry
105 |
106 | if last_entry:
107 | self.timeline._timeline_frame.playhead = last_entry.time
108 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/qt_dotgraph/pydotfactory.py:
--------------------------------------------------------------------------------
1 | #
2 | # License: Yujin
3 | #
4 | ##############################################################################
5 | # Description
6 | ##############################################################################
7 |
8 | """
9 | .. module:: pydotfactory
10 | :platform: Unix
11 | :synopsis: Repackaging of the limiting ROS qt_dotgraph.pydotfactory module.
12 |
13 | Oh my spaghettified magnificence,
14 | Bless my noggin with a tickle from your noodly appendages!
15 |
16 | """
17 |
18 | ##############################################################################
19 | # Imports
20 | ##############################################################################
21 |
22 | from distutils.version import LooseVersion
23 | import urllib
24 |
25 | import pydot
26 | # work around for https://bugs.launchpad.net/ubuntu/+source/pydot/+bug/1321135
27 | import pyparsing
28 | pyparsing._noncomma = "".join([c for c in pyparsing.printables if c != ","])
29 |
30 | ##############################################################################
31 | # Classes
32 | ##############################################################################
33 |
34 |
35 | # Reference implementation for a dotcode factory
36 | class PydotFactory():
37 |
38 | def __init__(self):
39 | pass
40 |
41 | def escape_label(self, name):
42 | if name in ['graph', 'subgraph', 'node', 'edge']:
43 | ret = "%s_" % name
44 | else:
45 | ret = name
46 | return ret
47 |
48 | def escape_name(self, name):
49 | ret = urllib.quote(name.strip())
50 | ret = ret.replace('/', '_')
51 | ret = ret.replace('%', '_')
52 | ret = ret.replace('-', '_')
53 | return self.escape_label(ret)
54 |
55 | def get_graph(self, graph_type='digraph', rank='same', simplify=True, rankdir='TB', ranksep=0.2, compound=True):
56 | # Lucid version of pydot bugs with certain settings, not sure which version exactly fixes those
57 | if LooseVersion(pydot.__version__) > LooseVersion('1.0.10'):
58 | graph = pydot.Dot('graphname',
59 | graph_type=graph_type,
60 | rank=rank,
61 | rankdir=rankdir,
62 | simplify=simplify
63 | )
64 | graph.set_ranksep(ranksep)
65 | graph.set_compound(compound)
66 | else:
67 | graph = pydot.Dot('graphname',
68 | graph_type=graph_type,
69 | rank=rank,
70 | rankdir=rankdir)
71 | return graph
72 |
73 | def add_node_to_graph(self,
74 | graph,
75 | nodename,
76 | nodelabel=None,
77 | shape='box',
78 | color=None,
79 | url=None,
80 | tooltip=None):
81 | """
82 | creates a node item for this factory, adds it to the graph.
83 | Node name can vary from label but must always be same for the same node label
84 | """
85 | if nodename is None or nodename == '':
86 | raise ValueError('Empty Node name')
87 | if nodelabel is None:
88 | nodelabel = nodename
89 | node = pydot.Node(self.escape_name(nodename))
90 | node.set_shape(shape)
91 | node.set_label(self.escape_label(nodelabel))
92 | if tooltip is not None:
93 | node.set_tooltip(tooltip)
94 | if url is not None:
95 | node.set_URL(self.escape_name(url))
96 | if color is not None:
97 | node.set_color(color)
98 | graph.add_node(node)
99 |
100 | def add_subgraph_to_graph(self,
101 | graph,
102 | subgraphname,
103 | rank='same',
104 | simplify=True,
105 | rankdir='TB',
106 | ranksep=0.2,
107 | compound=True,
108 | color=None,
109 | shape='box',
110 | style='bold',
111 | subgraphlabel=None):
112 | """
113 | creates a cluster subgraph item for this factory, adds it to the graph.
114 | cluster name can vary from label but must always be same for the same node label.
115 | Most layouters require cluster names to start with cluster.
116 | """
117 | if subgraphname is None or subgraphname == '':
118 | raise ValueError('Empty subgraph name')
119 | g = pydot.Cluster(self.escape_name(subgraphname), rank=rank, rankdir=rankdir, simplify=simplify, color=color)
120 | if 'set_style' in g.__dict__:
121 | g.set_style(style)
122 | if 'set_shape' in g.__dict__:
123 | g.set_shape(shape)
124 | if LooseVersion(pydot.__version__) > LooseVersion('1.0.10'):
125 | g.set_compound(compound)
126 | g.set_ranksep(ranksep)
127 | subgraphlabel = subgraphname if subgraphlabel is None else subgraphlabel
128 | subgraphlabel = self.escape_label(subgraphlabel)
129 | if subgraphlabel:
130 | g.set_label(subgraphlabel)
131 | if 'set_color' in g.__dict__:
132 | if color is not None:
133 | g.set_color(color)
134 | graph.add_subgraph(g)
135 | return g
136 |
137 | def add_edge_to_graph(self, graph, nodename1, nodename2, label=None, url=None, simplify=True, style=None, penwidth=1, color=None):
138 | if simplify and LooseVersion(pydot.__version__) < LooseVersion('1.0.10'):
139 | if graph.get_edge(self.escape_name(nodename1), self.escape_name(nodename2)) != []:
140 | return
141 | edge = pydot.Edge(self.escape_name(nodename1), self.escape_name(nodename2))
142 | if label is not None and label != '':
143 | edge.set_label(label)
144 | if url is not None:
145 | edge.set_URL(self.escape_name(url))
146 | if style is not None:
147 | edge.set_style(style)
148 | edge.obj_dict['attributes']['penwidth'] = str(penwidth)
149 | if color is not None:
150 | edge.obj_dict['attributes']['colorR'] = str(color[0])
151 | edge.obj_dict['attributes']['colorG'] = str(color[1])
152 | edge.obj_dict['attributes']['colorB'] = str(color[2])
153 | graph.add_edge(edge)
154 |
155 | def create_dot(self, graph):
156 | dot = graph.create_dot()
157 | # sadly pydot generates line wraps cutting between numbers
158 | return dot.replace("\\\n", "")
159 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/qt_dotgraph/edge_item.py:
--------------------------------------------------------------------------------
1 | #
2 | # License: Yujin
3 | #
4 | ##############################################################################
5 | # Description
6 | ##############################################################################
7 |
8 | """
9 | .. module:: edge_item
10 | :platform: Unix
11 | :synopsis: Repackaging of the limiting ROS qt_dotgraph.edge_item module.
12 |
13 | Oh my spaghettified magnificence,
14 | Bless my noggin with a tickle from your noodly appendages!
15 |
16 | """
17 |
18 | ##############################################################################
19 | # Imports
20 | ##############################################################################
21 |
22 | from python_qt_binding.QtCore import QPointF, Qt
23 | from python_qt_binding.QtGui import QBrush, QPainterPath, QPen, QPolygonF
24 | try: # indigo
25 | from python_qt_binding.QtGui import QGraphicsPathItem, QGraphicsPolygonItem, QGraphicsSimpleTextItem
26 | except ImportError: # kinetic+ (pyqt5)
27 | from python_qt_binding.QtWidgets import QGraphicsPathItem, QGraphicsPolygonItem, QGraphicsSimpleTextItem
28 |
29 | from .graph_item import GraphItem
30 |
31 | ##############################################################################
32 | # Classes
33 | ##############################################################################
34 |
35 |
36 | class EdgeItem(GraphItem):
37 |
38 | _qt_pen_styles = {
39 | 'dashed': Qt.DashLine,
40 | 'dotted': Qt.DotLine,
41 | 'solid': Qt.SolidLine,
42 | }
43 |
44 | def __init__(self, highlight_level, spline, label_center, label, from_node, to_node, parent=None, penwidth=1, edge_color=None, style='solid'):
45 | super(EdgeItem, self).__init__(highlight_level, parent)
46 |
47 | self.from_node = from_node
48 | self.from_node.add_outgoing_edge(self)
49 | self.to_node = to_node
50 | self.to_node.add_incoming_edge(self)
51 |
52 | self._default_edge_color = self._COLOR_BLACK
53 | self._default_shape_color = self._COLOR_BLACK
54 | if edge_color is not None:
55 | self._default_edge_color = edge_color
56 | self._default_shape_color = edge_color
57 |
58 | self._default_text_color = self._COLOR_BLACK
59 | self._default_color = self._COLOR_BLACK
60 | self._text_brush = QBrush(self._default_color)
61 | self._shape_brush = QBrush(self._default_color)
62 | if style in ['dashed', 'dotted']:
63 | self._shape_brush = QBrush(Qt.transparent)
64 | self._label_pen = QPen()
65 | self._label_pen.setColor(self._default_text_color)
66 | self._label_pen.setJoinStyle(Qt.RoundJoin)
67 | self._edge_pen = QPen(self._label_pen)
68 | self._edge_pen.setWidth(penwidth)
69 | self._edge_pen.setColor(self._default_edge_color)
70 | self._edge_pen.setStyle(self._qt_pen_styles.get(style, Qt.SolidLine))
71 | self._sibling_edges = set()
72 |
73 | self._label = None
74 | if label is not None:
75 | self._label = QGraphicsSimpleTextItem(label)
76 | label_rect = self._label.boundingRect()
77 | label_rect.moveCenter(label_center)
78 | self._label.setPos(label_rect.x(), label_rect.y())
79 | self._label.hoverEnterEvent = self._handle_hoverEnterEvent
80 | self._label.hoverLeaveEvent = self._handle_hoverLeaveEvent
81 | self._label.setAcceptHoverEvents(True)
82 |
83 | # spline specification according to http://www.graphviz.org/doc/info/attrs.html#k:splineType
84 | coordinates = spline.split(' ')
85 | # extract optional end_point
86 | end_point = None
87 | if (coordinates[0].startswith('e,')):
88 | parts = coordinates.pop(0)[2:].split(',')
89 | end_point = QPointF(float(parts[0]), -float(parts[1]))
90 | # extract optional start_point
91 | if (coordinates[0].startswith('s,')):
92 | parts = coordinates.pop(0).split(',')
93 |
94 | # first point
95 | parts = coordinates.pop(0).split(',')
96 | point = QPointF(float(parts[0]), -float(parts[1]))
97 | path = QPainterPath(point)
98 |
99 | while len(coordinates) > 2:
100 | # extract triple of points for a cubic spline
101 | parts = coordinates.pop(0).split(',')
102 | point1 = QPointF(float(parts[0]), -float(parts[1]))
103 | parts = coordinates.pop(0).split(',')
104 | point2 = QPointF(float(parts[0]), -float(parts[1]))
105 | parts = coordinates.pop(0).split(',')
106 | point3 = QPointF(float(parts[0]), -float(parts[1]))
107 | path.cubicTo(point1, point2, point3)
108 |
109 | self._arrow = None
110 | if end_point is not None:
111 | # draw arrow
112 | self._arrow = QGraphicsPolygonItem()
113 | polygon = QPolygonF()
114 | polygon.append(point3)
115 | offset = QPointF(end_point - point3)
116 | corner1 = QPointF(-offset.y(), offset.x()) * 0.35
117 | corner2 = QPointF(offset.y(), -offset.x()) * 0.35
118 | polygon.append(point3 + corner1)
119 | polygon.append(end_point)
120 | polygon.append(point3 + corner2)
121 | self._arrow.setPolygon(polygon)
122 | self._arrow.hoverEnterEvent = self._handle_hoverEnterEvent
123 | self._arrow.hoverLeaveEvent = self._handle_hoverLeaveEvent
124 | self._arrow.setAcceptHoverEvents(True)
125 |
126 | self._path = QGraphicsPathItem()
127 | self._path.setPath(path)
128 | self.addToGroup(self._path)
129 |
130 | self.set_node_color()
131 | self.set_label_color()
132 |
133 | def add_to_scene(self, scene):
134 | scene.addItem(self)
135 | if self._label is not None:
136 | scene.addItem(self._label)
137 | if self._arrow is not None:
138 | scene.addItem(self._arrow)
139 |
140 | def setToolTip(self, tool_tip):
141 | super(EdgeItem, self).setToolTip(tool_tip)
142 | if self._label is not None:
143 | self._label.setToolTip(tool_tip)
144 | if self._arrow is not None:
145 | self._arrow.setToolTip(tool_tip)
146 |
147 | def add_sibling_edge(self, edge):
148 | self._sibling_edges.add(edge)
149 |
150 | def set_node_color(self, color=None):
151 | if color is None:
152 | self._label_pen.setColor(self._default_text_color)
153 | self._text_brush.setColor(self._default_color)
154 | if self._shape_brush.isOpaque():
155 | self._shape_brush.setColor(self._default_shape_color)
156 | self._edge_pen.setColor(self._default_edge_color)
157 | else:
158 | self._label_pen.setColor(color)
159 | self._text_brush.setColor(color)
160 | if self._shape_brush.isOpaque():
161 | self._shape_brush.setColor(color)
162 | self._edge_pen.setColor(color)
163 |
164 | self._path.setPen(self._edge_pen)
165 | if self._arrow is not None:
166 | self._arrow.setBrush(self._shape_brush)
167 | self._arrow.setPen(self._edge_pen)
168 |
169 | def set_label_color(self, color=None):
170 | if color is None:
171 | self._label_pen.setColor(self._default_text_color)
172 | else:
173 | self._label_pen.setColor(color)
174 |
175 | if self._label is not None:
176 | self._label.setBrush(self._text_brush)
177 | self._label.setPen(self._label_pen)
178 |
179 | def _handle_hoverEnterEvent(self, event):
180 | # hovered edge item in red
181 | self.set_node_color(self._COLOR_RED)
182 |
183 | if self._highlight_level > 1:
184 | if self.from_node != self.to_node:
185 | # from-node in blue
186 | self.from_node.set_node_color(self._COLOR_BLUE)
187 | # to-node in green
188 | self.to_node.set_node_color(self._COLOR_GREEN)
189 | else:
190 | # from-node/in-node in teal
191 | self.from_node.set_node_color(self._COLOR_TEAL)
192 | self.to_node.set_node_color(self._COLOR_TEAL)
193 | if self._highlight_level > 2:
194 | # sibling edges in orange
195 | for sibling_edge in self._sibling_edges:
196 | sibling_edge.set_node_color(self._COLOR_ORANGE)
197 |
198 | def _handle_hoverLeaveEvent(self, event):
199 | self.set_node_color()
200 | if self._highlight_level > 1:
201 | self.from_node.set_node_color()
202 | self.to_node.set_node_color()
203 | if self._highlight_level > 2:
204 | for sibling_edge in self._sibling_edges:
205 | sibling_edge.set_node_color()
206 |
--------------------------------------------------------------------------------
/resource/RosBehaviourTree.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | RosBehaviourTreeWidget
4 |
5 |
6 | true
7 |
8 |
9 |
10 | 0
11 | 0
12 | 1144
13 | 503
14 |
15 |
16 |
17 | PyTrees Behaviour Tree
18 |
19 |
20 | -
21 |
22 |
-
23 |
24 |
25 |
26 | 0
27 | 0
28 |
29 |
30 |
31 |
32 | 170
33 | 0
34 |
35 |
36 |
37 |
38 | 0
39 | 0
40 |
41 |
42 |
43 | false
44 |
45 |
46 | QComboBox::AdjustToContentsOnFirstShow
47 |
48 |
49 |
50 | -
51 |
52 |
53 | Highlight incoming and outgoing connections on mouse over
54 |
55 |
56 | Highlight
57 |
58 |
59 | true
60 |
61 |
62 |
63 | -
64 |
65 |
66 | Automatically fit graph into view on update
67 |
68 |
69 | Fit
70 |
71 |
72 | true
73 |
74 |
75 |
76 | -
77 |
78 |
79 | Fit graph in view
80 |
81 |
82 |
83 | -
84 |
85 |
86 | Play
87 |
88 |
89 |
90 | -
91 |
92 |
93 | Stop
94 |
95 |
96 |
97 | -
98 |
99 |
100 | -
101 |
102 |
103 | Qt::Horizontal
104 |
105 |
106 |
107 | 40
108 | 20
109 |
110 |
111 |
112 |
113 | -
114 |
115 |
116 |
117 | 15
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 | -
126 |
127 |
128 | Qt::Horizontal
129 |
130 |
131 |
132 | 40
133 | 20
134 |
135 |
136 |
137 |
138 | -
139 |
140 |
141 | Load and view trees from a .bag file
142 |
143 |
144 |
145 |
146 |
147 |
148 | -
149 |
150 |
151 | Load DOT graph from file
152 |
153 |
154 |
155 | -
156 |
157 |
158 | Save as DOT graph
159 |
160 |
161 |
162 | -
163 |
164 |
165 | Save as SVG
166 |
167 |
168 |
169 | -
170 |
171 |
172 | Save as image
173 |
174 |
175 |
176 |
177 |
178 | -
179 |
180 |
181 | QPainter::Antialiasing|QPainter::HighQualityAntialiasing|QPainter::SmoothPixmapTransform|QPainter::TextAntialiasing
182 |
183 |
184 | QGraphicsView::AnchorViewCenter
185 |
186 |
187 |
188 | -
189 |
190 |
-
191 |
192 |
193 | Go to the first tree
194 |
195 |
196 | First
197 |
198 |
199 |
200 | -
201 |
202 |
203 | View the previous tree in the sequence
204 |
205 |
206 | Previous
207 |
208 |
209 |
210 | -
211 |
212 |
213 | 0
214 |
215 |
-
216 |
217 |
218 |
219 | 0
220 | 0
221 |
222 |
223 |
224 |
225 | 0
226 | 0
227 |
228 |
229 |
230 |
231 | 16777215
232 | 60
233 |
234 |
235 |
236 | false
237 |
238 |
239 | Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
240 |
241 |
242 |
243 |
244 |
245 | -
246 |
247 |
248 | View the next tree in the sequence
249 |
250 |
251 | Qt::LeftToRight
252 |
253 |
254 | Next
255 |
256 |
257 |
258 | -
259 |
260 |
261 | Go to the latest tree
262 |
263 |
264 | Last
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 | InteractiveGraphicsView
275 | QGraphicsView
276 | rqt_graph.interactive_graphics_view
277 |
278 |
279 |
280 |
281 |
282 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/plugins/raw_view.py:
--------------------------------------------------------------------------------
1 | # Software License Agreement (BSD License)
2 | #
3 | # Copyright (c) 2012, Willow Garage, Inc.
4 | # All rights reserved.
5 | #
6 | # Redistribution and use in source and binary forms, with or without
7 | # modification, are permitted provided that the following conditions
8 | # are met:
9 | #
10 | # * Redistributions of source code must retain the above copyright
11 | # notice, this list of conditions and the following disclaimer.
12 | # * Redistributions in binary form must reproduce the above
13 | # copyright notice, this list of conditions and the following
14 | # disclaimer in the documentation and/or other materials provided
15 | # with the distribution.
16 | # * Neither the name of Willow Garage, Inc. nor the names of its
17 | # contributors may be used to endorse or promote products derived
18 | # from this software without specific prior written permission.
19 | #
20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
23 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
24 | # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
25 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
26 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
29 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
30 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31 | # POSSIBILITY OF SUCH DAMAGE.
32 | """
33 | Defines a raw view: a TopicMessageView that displays the message contents in a tree.
34 | """
35 | import rospy
36 | import codecs
37 | import math
38 |
39 | from python_qt_binding.QtCore import Qt
40 | try: # indigo
41 | from python_qt_binding.QtGui import QApplication, QAbstractItemView, QSizePolicy, QTreeWidget, QTreeWidgetItem
42 | except ImportError: # kinetic+ (pyqt5)
43 | from python_qt_binding.QtWidgets import QApplication, QAbstractItemView, QSizePolicy, QTreeWidget, QTreeWidgetItem
44 | from .topic_message_view import TopicMessageView
45 |
46 |
47 | class RawView(TopicMessageView):
48 | name = 'Raw'
49 | """
50 | Plugin to view a message in a treeview window
51 | The message is loaded into a custum treewidget
52 | """
53 | def __init__(self, timeline, parent, topic):
54 | """
55 | :param timeline: timeline data object, ''BagTimeline''
56 | :param parent: widget that will be added to the ros_gui context, ''QWidget''
57 | """
58 | super(RawView, self).__init__(timeline, parent, topic)
59 | self.message_tree = MessageTree(parent)
60 | parent.layout().addWidget(self.message_tree) # This will automatically resize the message_tree to the windowsize
61 |
62 | def message_viewed(self, bag, msg_details):
63 | super(RawView, self).message_viewed(bag, msg_details)
64 | _, msg, t = msg_details # topic, msg, t = msg_details
65 | if t is None:
66 | self.message_cleared()
67 | else:
68 | self.message_tree.set_message(msg)
69 |
70 | def message_cleared(self):
71 | TopicMessageView.message_cleared(self)
72 | self.message_tree.set_message(None)
73 |
74 |
75 | class MessageTree(QTreeWidget):
76 | def __init__(self, parent):
77 | super(MessageTree, self).__init__(parent)
78 | self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
79 | self.setHeaderHidden(True)
80 | self.setSelectionMode(QAbstractItemView.ExtendedSelection)
81 | self._msg = None
82 |
83 | self._expanded_paths = None
84 | self.keyPressEvent = self.on_key_press
85 |
86 | @property
87 | def msg(self):
88 | return self._msg
89 |
90 | def set_message(self, msg):
91 | """
92 | Clears the tree view and displays the new message
93 | :param msg: message object to display in the treeview, ''msg''
94 | """
95 | # Remember whether items were expanded or not before deleting
96 | if self._msg:
97 | for item in self.get_all_items():
98 | path = self.get_item_path(item)
99 | if item.isExpanded():
100 | self._expanded_paths.add(path)
101 | elif path in self._expanded_paths:
102 | self._expanded_paths.remove(path)
103 | self.clear()
104 | if msg:
105 | # Populate the tree
106 | self._add_msg_object(None, '', '', msg, msg._type)
107 |
108 | if self._expanded_paths is None:
109 | self._expanded_paths = set()
110 | else:
111 | # Expand those that were previously expanded, and collapse any paths that we've seen for the first time
112 | for item in self.get_all_items():
113 | path = self.get_item_path(item)
114 | if path in self._expanded_paths:
115 | item.setExpanded(True)
116 | else:
117 | item.setExpanded(False)
118 | self._msg = msg
119 | self.update()
120 |
121 | # Keyboard handler
122 | def on_key_press(self, event):
123 | key, ctrl = event.key(), event.modifiers() & Qt.ControlModifier
124 | if ctrl:
125 | if key == ord('C') or key == ord('c'):
126 | # Ctrl-C: copy text from selected items to clipboard
127 | self._copy_text_to_clipboard()
128 | event.accept()
129 | elif key == ord('A') or key == ord('a'):
130 | # Ctrl-A: select all
131 | self._select_all()
132 |
133 | def _select_all(self):
134 | for i in self.get_all_items():
135 | if not i.isSelected():
136 | i.setSelected(True)
137 | i.setExpanded(True)
138 |
139 | def _copy_text_to_clipboard(self):
140 | # Get tab indented text for all selected items
141 | def get_distance(item, ancestor, distance=0):
142 | parent = item.parent()
143 | if parent == None:
144 | return distance
145 | else:
146 | return get_distance(parent, ancestor, distance + 1)
147 | text = ''
148 | for i in self.get_all_items():
149 | if i in self.selectedItems():
150 | text += ('\t' * (get_distance(i, None))) + i.text(0) + '\n'
151 | # Copy the text to the clipboard
152 | clipboard = QApplication.clipboard()
153 | clipboard.setText(text)
154 |
155 | def get_item_path(self, item):
156 | return item.data(0, Qt.UserRole)[0].replace(' ', '') # remove spaces that may get introduced in indexing, e.g. [ 3] is [3]
157 |
158 | def get_all_items(self):
159 | items = []
160 | try:
161 | root = self.invisibleRootItem()
162 | self.traverse(root, items.append)
163 | except Exception:
164 | # TODO: very large messages can cause a stack overflow due to recursion
165 | pass
166 | return items
167 |
168 | def traverse(self, root, function):
169 | for i in range(root.childCount()):
170 | child = root.child(i)
171 | function(child)
172 | self.traverse(child, function)
173 |
174 | def _add_msg_object(self, parent, path, name, obj, obj_type):
175 | label = name
176 |
177 | if hasattr(obj, '__slots__'):
178 | subobjs = [(slot, getattr(obj, slot)) for slot in obj.__slots__]
179 | elif type(obj) in [list, tuple]:
180 | len_obj = len(obj)
181 | if len_obj == 0:
182 | subobjs = []
183 | else:
184 | w = int(math.ceil(math.log10(len_obj)))
185 | subobjs = [('[%*d]' % (w, i), subobj) for (i, subobj) in enumerate(obj)]
186 | else:
187 | subobjs = []
188 |
189 | if type(obj) in [int, long, float]:
190 | if type(obj) == float:
191 | obj_repr = '%.6f' % obj
192 | else:
193 | obj_repr = str(obj)
194 |
195 | if obj_repr[0] == '-':
196 | label += ': %s' % obj_repr
197 | else:
198 | label += ': %s' % obj_repr
199 |
200 | elif type(obj) in [str, bool, int, long, float, complex, rospy.Time]:
201 | # Ignore any binary data
202 | obj_repr = codecs.utf_8_decode(str(obj), 'ignore')[0]
203 |
204 | # Truncate long representations
205 | if len(obj_repr) >= 50:
206 | obj_repr = obj_repr[:50] + '...'
207 |
208 | label += ': ' + obj_repr
209 | item = QTreeWidgetItem([label])
210 | if name == '':
211 | pass
212 | elif path.find('.') == -1 and path.find('[') == -1:
213 | self.addTopLevelItem(item)
214 | else:
215 | parent.addChild(item)
216 | item.setData(0, Qt.UserRole, (path, obj_type))
217 |
218 | for subobj_name, subobj in subobjs:
219 | if subobj is None:
220 | continue
221 |
222 | if path == '':
223 | subpath = subobj_name # root field
224 | elif subobj_name.startswith('['):
225 | subpath = '%s%s' % (path, subobj_name) # list, dict, or tuple
226 | else:
227 | subpath = '%s.%s' % (path, subobj_name) # attribute (prefix with '.')
228 |
229 | if hasattr(subobj, '_type'):
230 | subobj_type = subobj._type
231 | else:
232 | subobj_type = type(subobj).__name__
233 |
234 | self._add_msg_object(item, subpath, subobj_name, subobj, subobj_type)
235 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/dotcode_behaviour.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | #
3 | # License: BSD
4 | # https://raw.github.com/stonier/rqt_py_trees/license/LICENSE
5 | #
6 | ##############################################################################
7 | # Documentation
8 | ##############################################################################
9 |
10 | """
11 | .. module:: dotcode_behaviour
12 | :platform: Unix
13 | :synopsis: Dot code a behaviour tree
14 |
15 | Dotcode generation for a py_trees behaviour tree.
16 | """
17 |
18 | ##############################################################################
19 | # Imports
20 | ##############################################################################
21 |
22 | from __future__ import with_statement, print_function
23 |
24 | import rospy
25 | import py_trees_msgs.msg as py_trees_msgs
26 | import unique_id
27 |
28 | ##############################################################################
29 | # Classes
30 | ##############################################################################
31 |
32 |
33 | class RosBehaviourTreeDotcodeGenerator(object):
34 |
35 | def __init__(self):
36 | self.last_drawargs = None
37 | self.dotcode = None
38 | self.firstcall = True
39 | self.rank = None
40 | self.rankdir = None
41 | self.ranksep = None
42 | self.graph = None
43 | self.dotcode_factory = None
44 | self.active_and_status_colour_hex_map = {
45 | (False, py_trees_msgs.Behaviour.INVALID): '#e4e4e4',
46 | (True, py_trees_msgs.Behaviour.INVALID): '#e4e4e4',
47 | (False, py_trees_msgs.Behaviour.RUNNING): '#9696c8',
48 | (True, py_trees_msgs.Behaviour.RUNNING): '#0000ff',
49 | (False, py_trees_msgs.Behaviour.FAILURE): '#c89696',
50 | (True, py_trees_msgs.Behaviour.FAILURE): '#ff0000',
51 | (False, py_trees_msgs.Behaviour.SUCCESS): '#96c896',
52 | (True, py_trees_msgs.Behaviour.SUCCESS): '#00ff00',
53 | }
54 | self.active_and_status_colour_tuple_map = {
55 | (False, py_trees_msgs.Behaviour.INVALID): (228, 228, 228),
56 | (True, py_trees_msgs.Behaviour.INVALID): (228, 228, 228),
57 | (False, py_trees_msgs.Behaviour.RUNNING): (150, 150, 200),
58 | (True, py_trees_msgs.Behaviour.RUNNING): (0, 0, 255),
59 | (False, py_trees_msgs.Behaviour.FAILURE): (200, 150, 150),
60 | (True, py_trees_msgs.Behaviour.FAILURE): (255, 0, 0),
61 | (False, py_trees_msgs.Behaviour.SUCCESS): (150, 200, 150),
62 | (True, py_trees_msgs.Behaviour.SUCCESS): (0, 255, 0),
63 | }
64 |
65 | def generate_dotcode(self,
66 | dotcode_factory,
67 | timer=rospy.Time,
68 | behaviours=None,
69 | timestamp=None,
70 | rank='same', # None, same, min, max, source, sink
71 | ranksep=0.2, # vertical distance between layers
72 | rankdir='TB', # direction of layout (TB top > bottom, LR left > right)
73 | force_refresh=False):
74 | """
75 | :param py_trees_msgs.Behaviour[] behaviours:
76 | :param force_refresh: if False, may return same dotcode as last time
77 | """
78 | if self.firstcall is True:
79 | self.firstcall = False
80 | force_refresh = True
81 |
82 | drawing_args = {
83 | 'dotcode_factory': dotcode_factory,
84 | "rank": rank,
85 | "rankdir": rankdir,
86 | "ranksep": ranksep}
87 |
88 | selection_changed = False
89 | if self.last_drawargs != drawing_args:
90 | selection_changed = True
91 | self.last_drawargs = drawing_args
92 |
93 | self.dotcode_factory = dotcode_factory
94 | self.rank = rank
95 | self.rankdir = rankdir
96 | self.ranksep = ranksep
97 |
98 | self.graph = self.generate(behaviours, timestamp)
99 | self.dotcode = self.dotcode_factory.create_dot(self.graph)
100 | return self.dotcode
101 |
102 | def type_to_shape(self, behaviour_type):
103 | """
104 | qt_dotgraph.node_item only supports drawing in qt of two
105 | shapes - box, ellipse.
106 | """
107 | if behaviour_type == py_trees_msgs.Behaviour.BEHAVIOUR:
108 | return 'ellipse'
109 | elif behaviour_type == py_trees_msgs.Behaviour.SEQUENCE:
110 | return 'box'
111 | elif behaviour_type == py_trees_msgs.Behaviour.SELECTOR:
112 | return 'octagon'
113 | elif behaviour_type == py_trees_msgs.Behaviour.PARALLEL:
114 | return 'note'
115 | elif behaviour_type == py_trees_msgs.Behaviour.CHOOSER:
116 | return 'doubleoctagon'
117 | else:
118 | return 'ellipse'
119 |
120 | def type_to_colour(self, behaviour_type):
121 | if behaviour_type == py_trees_msgs.Behaviour.BEHAVIOUR:
122 | return None
123 | elif behaviour_type == py_trees_msgs.Behaviour.SEQUENCE:
124 | return '#ff9900'
125 | elif behaviour_type == py_trees_msgs.Behaviour.SELECTOR:
126 | return '#808080'
127 | elif behaviour_type == py_trees_msgs.Behaviour.PARALLEL:
128 | return '#ffd700'
129 | elif behaviour_type == py_trees_msgs.Behaviour.CHOOSER:
130 | return '#808080'
131 | else:
132 | return None
133 |
134 | def type_to_string(self, behaviour_type):
135 | if behaviour_type == py_trees_msgs.Behaviour.BEHAVIOUR:
136 | return 'Behaviour'
137 | elif behaviour_type == py_trees_msgs.Behaviour.SEQUENCE:
138 | return 'Sequence'
139 | elif behaviour_type == py_trees_msgs.Behaviour.SELECTOR:
140 | return 'Selector'
141 | elif behaviour_type == py_trees_msgs.Behaviour.PARALLEL:
142 | return 'Parallel'
143 | elif behaviour_type == py_trees_msgs.Behaviour.CHOOSER:
144 | return 'Chooser'
145 | else:
146 | return None
147 |
148 | def status_to_string(self, behaviour_status):
149 | if behaviour_status == py_trees_msgs.Behaviour.INVALID:
150 | return 'Invalid'
151 | elif behaviour_status == py_trees_msgs.Behaviour.RUNNING:
152 | return 'Running'
153 | elif behaviour_status == py_trees_msgs.Behaviour.FAILURE:
154 | return 'Failure'
155 | elif behaviour_status == py_trees_msgs.Behaviour.SUCCESS:
156 | return 'Success'
157 | else:
158 | return None
159 |
160 | def behaviour_to_tooltip_string(self, behaviour):
161 | to_display = ['class_name', 'type', 'status', 'message'] # should be static
162 | string = ''
163 |
164 | for attr in to_display:
165 | if attr == 'type':
166 | value = self.type_to_string(getattr(behaviour, attr))
167 | elif attr == 'status':
168 | value = self.status_to_string(getattr(behaviour, attr))
169 | else:
170 | value = str(getattr(behaviour, attr))
171 |
172 | value = "empty" if not value else value
173 |
174 | string += '' + attr.replace('_', ' ').title() + ': ' + value + "
"
175 |
176 | return "\"" + string + "\""
177 |
178 | def generate(self, data, timestamp):
179 | """
180 | :param py_trees_msgs.Behaviour[] data:
181 | :param ??? timestamp:
182 | """
183 | graph = self.dotcode_factory.get_graph(rank=self.rank,
184 | rankdir=self.rankdir,
185 | ranksep=self.ranksep)
186 |
187 | if len(data) == 0:
188 | self.dotcode_factory.add_node_to_graph(graph, 'No behaviour data received')
189 | return graph
190 |
191 | # DJS - not actually used? In python3 this is a propblem since
192 | # unique_id.UniqueID is not hashable any longer
193 | # behaviour_dict_by_id = {}
194 | # for behaviour in data:
195 | # behaviour_dict_by_id[behaviour.own_id] = behaviour
196 |
197 | # first, add nodes to the graph, along with some cached information to
198 | # make it easy to create the coloured edges on the second pass
199 | states = {}
200 | for behaviour in data:
201 | self.dotcode_factory.add_node_to_graph(graph,
202 | str(behaviour.own_id),
203 | nodelabel=behaviour.name,
204 | shape=self.type_to_shape(behaviour.type),
205 | color=self.active_and_status_colour_hex_map[(behaviour.is_active, behaviour.status)],
206 | tooltip=self.behaviour_to_tooltip_string(behaviour))
207 | states[unique_id.fromMsg(behaviour.own_id)] = (behaviour.is_active, behaviour.status)
208 |
209 | for behaviour in data:
210 | for child_id in behaviour.child_ids:
211 | # edge colour is set using integer tuples, not hexes
212 | try:
213 | (is_active, status) = states[unique_id.fromMsg(child_id)]
214 | except KeyError:
215 | # the child isn't part of the 'visible' tree
216 | continue
217 | edge_colour = self.active_and_status_colour_tuple_map[(is_active, status)]
218 | self.dotcode_factory.add_edge_to_graph(
219 | graph=graph,
220 | nodename1=str(behaviour.own_id),
221 | nodename2=str(child_id),
222 | color=edge_colour
223 | )
224 |
225 | return graph
226 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/qt_dotgraph/node_item.py:
--------------------------------------------------------------------------------
1 | #
2 | # License: Yujin
3 | #
4 | ##############################################################################
5 | # Description
6 | ##############################################################################
7 |
8 | """
9 | .. module:: node_item
10 | :platform: Unix
11 | :synopsis: Repackaging of the limiting ROS qt_dotgraph.node_item module.
12 |
13 | Oh my spaghettified magnificence,
14 | Bless my noggin with a tickle from your noodly appendages!
15 |
16 | """
17 |
18 | ##############################################################################
19 | # Imports
20 | ##############################################################################
21 |
22 | from python_qt_binding.QtCore import QPointF, Qt
23 | from python_qt_binding.QtGui import QBrush, QPolygonF, QPainterPath, QPen
24 | try: # indigo
25 | from python_qt_binding.QtGui import QGraphicsEllipseItem, QGraphicsPolygonItem, QGraphicsRectItem, QGraphicsSimpleTextItem
26 | except ImportError: # kinetic+ (pyqt5)
27 | from python_qt_binding.QtWidgets import QGraphicsEllipseItem, QGraphicsPolygonItem, QGraphicsRectItem, QGraphicsSimpleTextItem
28 |
29 | from .graph_item import GraphItem
30 |
31 | ##############################################################################
32 | # Classes
33 | ##############################################################################
34 |
35 |
36 | class NodeItem(GraphItem):
37 |
38 | def __init__(self, highlight_level, bounding_box, label, shape, color=None, parent=None, label_pos=None, tooltip=None):
39 | super(NodeItem, self).__init__(highlight_level, parent)
40 |
41 | self._default_color = self._COLOR_BLACK if color is None else color
42 | self._brush = QBrush(self._default_color)
43 | self._label_pen = QPen()
44 | self._label_pen.setColor(self._default_color)
45 | self._label_pen.setJoinStyle(Qt.RoundJoin)
46 | self._ellipse_pen = QPen(self._label_pen)
47 | self._ellipse_pen.setWidth(1)
48 |
49 | self._incoming_edges = set()
50 | self._outgoing_edges = set()
51 |
52 | if shape == 'box':
53 | self._graphics_item = QGraphicsRectItem(bounding_box)
54 |
55 | # Since we don't have unique GraphicsItems other than Ellipse and Rect,
56 | # Using Polygon to draw the following using bounding_box
57 |
58 | elif shape == 'octagon':
59 | rect = bounding_box.getRect()
60 | octagon_polygon = QPolygonF([QPointF(rect[0], rect[1] + 3 * rect[3] / 10),
61 | QPointF(rect[0], rect[1] + 7 * rect[3] / 10),
62 | QPointF(rect[0] + 3 * rect[2] / 10, rect[1] + rect[3]),
63 | QPointF(rect[0] + 7 * rect[2] / 10, rect[1] + rect[3]),
64 | QPointF(rect[0] + rect[2], rect[1] + 7 * rect[3] / 10),
65 | QPointF(rect[0] + rect[2], rect[1] + 3 * rect[3] / 10),
66 | QPointF(rect[0] + 7 * rect[2] / 10, rect[1]),
67 | QPointF(rect[0] + 3 * rect[2] / 10, rect[1])])
68 | self._graphics_item = QGraphicsPolygonItem(octagon_polygon)
69 |
70 | elif shape == 'doubleoctagon':
71 | rect = bounding_box.getRect()
72 | inner_fold = 3.0
73 |
74 | octagon_polygon = QPolygonF([QPointF(rect[0], rect[1] + 3 * rect[3] / 10),
75 | QPointF(rect[0], rect[1] + 7 * rect[3] / 10),
76 | QPointF(rect[0] + 3 * rect[2] / 10, rect[1] + rect[3]),
77 | QPointF(rect[0] + 7 * rect[2] / 10, rect[1] + rect[3]),
78 | QPointF(rect[0] + rect[2], rect[1] + 7 * rect[3] / 10),
79 | QPointF(rect[0] + rect[2], rect[1] + 3 * rect[3] / 10),
80 | QPointF(rect[0] + 7 * rect[2] / 10, rect[1]),
81 | QPointF(rect[0] + 3 * rect[2] / 10, rect[1]),
82 | # inner
83 | QPointF(rect[0], rect[1] + 3 * rect[3] / 10),
84 | QPointF(rect[0] + inner_fold, rect[1] + 3 * rect[3] / 10 + inner_fold / 2),
85 | QPointF(rect[0] + inner_fold, rect[1] + 7 * rect[3] / 10 - inner_fold / 2),
86 | QPointF(rect[0] + 3 * rect[2] / 10, rect[1] + rect[3] - inner_fold),
87 | QPointF(rect[0] + 7 * rect[2] / 10, rect[1] + rect[3] - inner_fold),
88 | QPointF(rect[0] + rect[2] - inner_fold, rect[1] + 7 * rect[3] / 10 - inner_fold / 2),
89 | QPointF(rect[0] + rect[2] - inner_fold, rect[1] + 3 * rect[3] / 10 + inner_fold / 2),
90 | QPointF(rect[0] + 7 * rect[2] / 10, rect[1] + inner_fold),
91 | QPointF(rect[0] + 3 * rect[2] / 10, rect[1] + inner_fold),
92 | QPointF(rect[0] + inner_fold, rect[1] + 3 * rect[3] / 10 + inner_fold / 2)
93 | ])
94 |
95 | self._graphics_item = QGraphicsPolygonItem(octagon_polygon)
96 |
97 | elif shape == 'note':
98 | rect = bounding_box.getRect()
99 | note_polygon = QPolygonF([QPointF(rect[0] + 9 * rect[2] / 10, rect[1]),
100 | QPointF(rect[0], rect[1]),
101 | QPointF(rect[0], rect[1] + rect[3]),
102 | QPointF(rect[0] + rect[2], rect[1] + rect[3]),
103 | QPointF(rect[0] + rect[2], rect[1] + rect[3] / 5),
104 | QPointF(rect[0] + 9 * rect[2] / 10, rect[1] + rect[3] / 5),
105 | QPointF(rect[0] + 9 * rect[2] / 10, rect[1]),
106 | QPointF(rect[0] + rect[2], rect[1] + rect[3] / 5),
107 | QPointF(rect[0] + rect[2], rect[1] + rect[3] / 5)])
108 | self._graphics_item = QGraphicsPolygonItem(note_polygon)
109 |
110 | else:
111 | self._graphics_item = QGraphicsEllipseItem(bounding_box)
112 | self.addToGroup(self._graphics_item)
113 |
114 | self._label = QGraphicsSimpleTextItem(label)
115 | label_rect = self._label.boundingRect()
116 | if label_pos is None:
117 | label_rect.moveCenter(bounding_box.center())
118 | else:
119 | label_rect.moveCenter(label_pos)
120 | self._label.setPos(label_rect.x(), label_rect.y())
121 | self.addToGroup(self._label)
122 | if tooltip is not None:
123 | self.setToolTip(tooltip)
124 |
125 | self.set_node_color()
126 |
127 | self.setAcceptHoverEvents(True)
128 |
129 | self.hovershape = None
130 |
131 | def set_hovershape(self, newhovershape):
132 | self.hovershape = newhovershape
133 |
134 | def shape(self):
135 | if self.hovershape is not None:
136 | path = QPainterPath()
137 | path.addRect(self.hovershape)
138 | return path
139 | else:
140 | return super(self.__class__, self).shape()
141 |
142 | def add_incoming_edge(self, edge):
143 | self._incoming_edges.add(edge)
144 |
145 | def add_outgoing_edge(self, edge):
146 | self._outgoing_edges.add(edge)
147 |
148 | def set_node_color(self, color=None):
149 | if color is None:
150 | color = self._default_color
151 |
152 | self._brush.setColor(color)
153 | self._ellipse_pen.setColor(color)
154 | self._label_pen.setColor(color)
155 |
156 | self._graphics_item.setPen(self._ellipse_pen)
157 | self._label.setBrush(self._brush)
158 | self._label.setPen(self._label_pen)
159 |
160 | def hoverEnterEvent(self, event):
161 | # hovered node item in red
162 | self.set_node_color(self._COLOR_RED)
163 |
164 | if self._highlight_level > 1:
165 | cyclic_edges = self._incoming_edges.intersection(self._outgoing_edges)
166 | # incoming edges in blue
167 | incoming_nodes = set()
168 | for incoming_edge in self._incoming_edges.difference(cyclic_edges):
169 | incoming_edge.set_node_color(self._COLOR_BLUE)
170 | if incoming_edge.from_node != self:
171 | incoming_nodes.add(incoming_edge.from_node)
172 | # outgoing edges in green
173 | outgoing_nodes = set()
174 | for outgoing_edge in self._outgoing_edges.difference(cyclic_edges):
175 | outgoing_edge.set_node_color(self._COLOR_GREEN)
176 | if outgoing_edge.to_node != self:
177 | outgoing_nodes.add(outgoing_edge.to_node)
178 | # incoming/outgoing edges in teal
179 | for edge in cyclic_edges:
180 | edge.set_node_color(self._COLOR_TEAL)
181 |
182 | if self._highlight_level > 2:
183 | cyclic_nodes = incoming_nodes.intersection(outgoing_nodes)
184 | # incoming nodes in blue
185 | for incoming_node in incoming_nodes.difference(cyclic_nodes):
186 | incoming_node.set_node_color(self._COLOR_BLUE)
187 | # outgoing nodes in green
188 | for outgoing_node in outgoing_nodes.difference(cyclic_nodes):
189 | outgoing_node.set_node_color(self._COLOR_GREEN)
190 | # incoming/outgoing nodes in teal
191 | for node in cyclic_nodes:
192 | node.set_node_color(self._COLOR_TEAL)
193 |
194 | def hoverLeaveEvent(self, event):
195 | self.set_node_color()
196 | if self._highlight_level > 1:
197 | for incoming_edge in self._incoming_edges:
198 | incoming_edge.set_node_color()
199 | if self._highlight_level > 2 and incoming_edge.from_node != self:
200 | incoming_edge.from_node.set_node_color()
201 | for outgoing_edge in self._outgoing_edges:
202 | outgoing_edge.set_node_color()
203 | if self._highlight_level > 2 and outgoing_edge.to_node != self:
204 | outgoing_edge.to_node.set_node_color()
205 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/qt_dotgraph/dot_to_qt.py:
--------------------------------------------------------------------------------
1 | #
2 | # License: Yujin
3 | #
4 | ##############################################################################
5 | # Description
6 | ##############################################################################
7 |
8 | """
9 | .. module:: dot_to_qt
10 | :platform: Unix
11 | :synopsis: Repackaging of the limiting ROS qt_dotgraph.dot_to_qt module.
12 |
13 | Oh my spaghettified magnificence,
14 | Bless my noggin with a tickle from your noodly appendages!
15 |
16 | """
17 |
18 | ##############################################################################
19 | # Imports
20 | ##############################################################################
21 |
22 | import codecs
23 |
24 | # import pydot
25 | import pygraphviz
26 | import pyparsing
27 |
28 | # work around for https://bugs.launchpad.net/ubuntu/+source/pydot/+bug/1321135
29 | pyparsing._noncomma = "".join([c for c in pyparsing.printables if c != ","])
30 |
31 |
32 | from python_qt_binding.QtCore import QPointF, QRectF
33 | from python_qt_binding.QtGui import QColor
34 |
35 | from .edge_item import EdgeItem
36 | from .node_item import NodeItem
37 |
38 |
39 | POINTS_PER_INCH = 72
40 |
41 | ##############################################################################
42 | # Support
43 | ##############################################################################
44 |
45 |
46 | # hack required by pydot
47 | def get_unquoted(item, name):
48 | value = item.get(name)
49 | if value is None:
50 | return None
51 | try:
52 | return value.strip('"\n"')
53 | except AttributeError:
54 | # not part of the string family
55 | return value
56 |
57 | # approximately, for workarounds (TODO: get this from dotfile somehow)
58 | LABEL_HEIGHT = 30
59 |
60 | ##############################################################################
61 | # Classes
62 | ##############################################################################
63 |
64 |
65 | # Class generating Qt Elements from doctcode
66 | class DotToQtGenerator():
67 |
68 | def __init__(self):
69 | pass
70 |
71 | def getNodeItemForSubgraph(self, subgraph, highlight_level):
72 | # let pydot imitate pygraphviz api
73 | attr = {}
74 | for name in subgraph.get_attributes().iterkeys():
75 | value = get_unquoted(subgraph, name)
76 | attr[name] = value
77 | obj_dic = subgraph.__getattribute__("obj_dict")
78 | for name in obj_dic:
79 | if name not in ['nodes', 'attributes', 'parent_graph'] and obj_dic[name] is not None:
80 | attr[name] = get_unquoted(obj_dic, name)
81 | elif name == 'nodes':
82 | for key in obj_dic['nodes']['graph'][0]['attributes']:
83 | attr[key] = get_unquoted(obj_dic['nodes']['graph'][0]['attributes'], key)
84 | subgraph.attr = attr
85 |
86 | bb = subgraph.attr.get('bb', None)
87 | if bb is None:
88 | # no bounding box
89 | return None
90 | bb = bb.strip('"').split(',')
91 | if len(bb) < 4:
92 | # bounding box is empty
93 | return None
94 | bounding_box = QRectF(0, 0, float(bb[2]) - float(bb[0]), float(bb[3]) - float(bb[1]))
95 | if 'lp' in subgraph.attr:
96 | label_pos = subgraph.attr['lp'].strip('"').split(',')
97 | else:
98 | label_pos = (float(bb[0]) + (float(bb[2]) - float(bb[0])) / 2, float(bb[1]) + (float(bb[3]) - float(bb[1])) - LABEL_HEIGHT / 2)
99 | bounding_box.moveCenter(QPointF(float(bb[0]) + (float(bb[2]) - float(bb[0])) / 2, -float(bb[1]) - (float(bb[3]) - float(bb[1])) / 2))
100 | name = subgraph.attr.get('label', '')
101 | color = QColor(subgraph.attr['color']) if 'color' in subgraph.attr else None
102 | subgraph_nodeitem = NodeItem(highlight_level,
103 | bounding_box,
104 | label=name,
105 | shape='box',
106 | color=color,
107 | label_pos=QPointF(float(label_pos[0]), -float(label_pos[1])))
108 | bounding_box = QRectF(bounding_box)
109 | # With clusters we have the problem that mouse hovers cannot
110 | # decide whether to be over the cluster or a subnode. Using
111 | # just the "title area" solves this. TODO: Maybe using a
112 | # border region would be even better (multiple RectF)
113 | bounding_box.setHeight(LABEL_HEIGHT)
114 | subgraph_nodeitem.set_hovershape(bounding_box)
115 | return subgraph_nodeitem
116 |
117 | def getNodeItemForNode(self, node, highlight_level):
118 | """
119 | returns a pyqt NodeItem object, or None in case of error or invisible style
120 | """
121 | # let pydot imitate pygraphviz api
122 | # attr = {}
123 | # for name in node.get_attributes().iterkeys():
124 | # value = get_unquoted(node, name)
125 | # attr[name] = value
126 | # obj_dic = node.__getattribute__("obj_dict")
127 | # for name in obj_dic:
128 | # if name not in ['attributes', 'parent_graph'] and obj_dic[name] is not None:
129 | # attr[name] = get_unquoted(obj_dic, name)
130 | # node.attr = attr
131 |
132 | if 'style' in node.attr:
133 | if node.attr['style'] == 'invis':
134 | return None
135 |
136 | color = QColor(node.attr['color']) if 'color' in node.attr else None
137 | name = None
138 | if 'label' in node.attr:
139 | name = node.attr['label']
140 | elif 'name' in node.attr:
141 | name = node.attr['name']
142 | else:
143 | print("Error, no label defined for node with attr: %s" % node.attr)
144 | return None
145 | if name is None:
146 | # happens on Lucid pygraphviz version
147 | print("Error, label is None for node %s, pygraphviz version may be too old." % node)
148 | else:
149 | # python2
150 | # name = name.decode('string_escape')
151 | # python3
152 | name = codecs.escape_decode(name)[0].decode('utf-8')
153 |
154 | # decrease rect by one so that edges do not reach inside
155 | bb_width = len(name) / 5
156 | if 'width' in node.attr:
157 | bb_width = node.attr['width']
158 | bb_height = 1.0
159 | if 'width' in node.attr:
160 | bb_height = node.attr['height']
161 | bounding_box = QRectF(0, 0, POINTS_PER_INCH * float(bb_width) - 1.0, POINTS_PER_INCH * float(bb_height) - 1.0)
162 | pos = (0, 0)
163 | if 'pos' in node.attr:
164 | pos = node.attr['pos'].split(',')
165 | bounding_box.moveCenter(QPointF(float(pos[0]), -float(pos[1])))
166 |
167 | node_item = NodeItem(highlight_level=highlight_level,
168 | bounding_box=bounding_box,
169 | label=name,
170 | shape=node.attr.get('shape', 'ellipse'),
171 | color=color,
172 | tooltip=node.attr.get('tooltip', None)
173 | # parent=None,
174 | # label_pos=None
175 | )
176 | # node_item.setToolTip(self._generate_tool_tip(node.attr.get('URL', None)))
177 | return node_item
178 |
179 | def addEdgeItem(self, edge, nodes, edges, highlight_level, same_label_siblings=False):
180 | """
181 | adds EdgeItem by data in edge to edges
182 | :param same_label_siblings: if true, edges with same label will be considered siblings (collective highlighting)
183 | """
184 | # let pydot imitate pygraphviz api
185 | # attr = {}
186 | # for name in edge.get_attributes().iterkeys():
187 | # value = get_unquoted(edge, name)
188 | # attr[name] = value
189 | # edge.attr = attr
190 |
191 | if 'style' in edge.attr:
192 | if edge.attr['style'] == 'invis':
193 | return
194 | style = edge.attr.get('style', None)
195 |
196 | label = edge.attr.get('label', None)
197 | label_pos = edge.attr.get('lp', None)
198 | label_center = None
199 | if label_pos is not None:
200 | label_pos = label_pos.split(',')
201 | label_center = QPointF(float(label_pos[0]), -float(label_pos[1]))
202 |
203 | # try pydot, fallback for pygraphviz
204 | source_node = edge.get_source() if hasattr(edge, 'get_source') else edge[0]
205 | destination_node = edge.get_destination() if hasattr(edge, 'get_destination') else edge[1]
206 |
207 | # create edge with from-node and to-node
208 | edge_pos = "0,0"
209 | if 'pos' in edge.attr:
210 | edge_pos = edge.attr['pos']
211 | if label is not None:
212 | label = label.decode('string_escape')
213 |
214 | color = None
215 | if 'colorR' in edge.attr and 'colorG' in edge.attr and 'colorB' in edge.attr:
216 | r = edge.attr['colorR']
217 | g = edge.attr['colorG']
218 | b = edge.attr['colorB']
219 | color = QColor(float(r), float(g), float(b))
220 |
221 | edge_item = EdgeItem(highlight_level=highlight_level,
222 | spline=edge_pos,
223 | label_center=label_center,
224 | label=label,
225 | from_node=nodes[source_node],
226 | to_node=nodes[destination_node],
227 | penwidth=int(edge.attr['penwidth']),
228 | edge_color=color,
229 | style=style)
230 |
231 | if same_label_siblings:
232 | if label is None:
233 | # for sibling detection
234 | label = "%s_%s" % (source_node, destination_node)
235 | # symmetrically add all sibling edges with same label
236 | if label in edges:
237 | for sibling in edges[label]:
238 | edge_item.add_sibling_edge(sibling)
239 | sibling.add_sibling_edge(edge_item)
240 |
241 | if label not in edges:
242 | edges[label] = []
243 | edges[label].append(edge_item)
244 |
245 | def dotcode_to_qt_items(self, dotcode, highlight_level, same_label_siblings=False):
246 | """
247 | takes dotcode, runs layout, and creates qt items based on the dot layout.
248 | returns two dicts, one mapping node names to Node_Item, one mapping edge names to lists of Edge_Item
249 | :param same_label_siblings: if true, edges with same label will be considered siblings (collective highlighting)
250 | """
251 | # layout graph
252 | if dotcode is None:
253 | return {}, {}
254 |
255 | # pydot - this function is very buggy and expensive, quickly > 1s!
256 | # graph = pydot.graph_from_dot_data(dotcode.encode("ascii", "ignore"))
257 | # let pydot imitate pygraphviz api
258 | # graph.nodes_iter = graph.get_node_list
259 | # graph.edges_iter = graph.get_edge_list
260 | # graph.subgraphs_iter = graph.get_subgraph_list
261 |
262 | # pygraphviz
263 | graph = pygraphviz.AGraph(
264 | string=dotcode, # .encode("ascii", "ignore"),
265 | strict=False,
266 | directed=True
267 | )
268 | graph.layout(prog='dot')
269 |
270 | nodes = {}
271 | for subgraph in graph.subgraphs_iter():
272 | subgraph_nodeitem = self.getNodeItemForSubgraph(subgraph, highlight_level)
273 | # skip subgraphs with empty bounding boxes
274 | if subgraph_nodeitem is None:
275 | continue
276 |
277 | subgraph.nodes_iter = subgraph.get_node_list
278 | nodes[subgraph.get_name()] = subgraph_nodeitem
279 | for node in subgraph.nodes_iter():
280 | # hack required by pydot
281 | if node.get_name() in ('graph', 'node', 'empty'):
282 | continue
283 | nodes[node.get_name()] = self.getNodeItemForNode(node, highlight_level)
284 | for node in graph.nodes_iter():
285 | # hack required by pydot
286 | if node.get_name() in ('graph', 'node', 'empty'):
287 | continue
288 | nodes[node.get_name()] = self.getNodeItemForNode(node, highlight_level)
289 |
290 | edges = {}
291 |
292 | for subgraph in graph.subgraphs_iter():
293 | subgraph.edges_iter = subgraph.get_edge_list
294 | for edge in subgraph.edges_iter():
295 | self.addEdgeItem(edge, nodes, edges,
296 | highlight_level=highlight_level,
297 | same_label_siblings=same_label_siblings)
298 |
299 | for edge in graph.edges_iter():
300 | self.addEdgeItem(edge, nodes, edges,
301 | highlight_level=highlight_level,
302 | same_label_siblings=same_label_siblings)
303 |
304 | return nodes, edges
305 |
306 | def graph_to_qt_items(self, graph, highlight_level, same_label_siblings=False):
307 | """
308 | takes a pydot/pygraphviz graph and creates qt items based on the dot layout.
309 | returns two dicts, one mapping node names to Node_Item, one mapping edge names to lists of Edge_Item
310 | :param same_label_siblings: if true, edges with same label will be considered siblings (collective highlighting)
311 | """
312 | if graph is None:
313 | return {}, {}
314 |
315 | # if pydot, let pydot imitate pygraphviz api
316 | # graph.nodes_iter = graph.get_node_list
317 | # graph.edges_iter = graph.get_edge_list
318 | # graph.subgraphs_iter = graph.get_subgraph_list
319 |
320 | nodes = {}
321 | for subgraph in graph.subgraphs_iter():
322 | subgraph_nodeitem = self.getNodeItemForSubgraph(subgraph, highlight_level)
323 | # skip subgraphs with empty bounding boxes
324 | if subgraph_nodeitem is None:
325 | continue
326 |
327 | subgraph.nodes_iter = subgraph.get_node_list
328 | nodes[subgraph.get_name()] = subgraph_nodeitem
329 | for node in subgraph.nodes_iter():
330 | # hack required by pydot
331 | if node.get_name() in ('graph', 'node', 'empty'):
332 | continue
333 | nodes[node.get_name()] = self.getNodeItemForNode(node, highlight_level)
334 | for node in graph.nodes_iter():
335 | # hack required by pydot
336 | if node.get_name() in ('graph', 'node', 'empty'):
337 | continue
338 | nodes[node.get_name()] = self.getNodeItemForNode(node, highlight_level)
339 |
340 | edges = {}
341 |
342 | for subgraph in graph.subgraphs_iter():
343 | subgraph.edges_iter = subgraph.get_edge_list
344 | for edge in subgraph.edges_iter():
345 | self.addEdgeItem(edge, nodes, edges,
346 | highlight_level=highlight_level,
347 | same_label_siblings=same_label_siblings)
348 |
349 | for edge in graph.edges_iter():
350 | self.addEdgeItem(edge, nodes, edges,
351 | highlight_level=highlight_level,
352 | same_label_siblings=same_label_siblings)
353 |
354 | return nodes, edges
355 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/dynamic_timeline.py:
--------------------------------------------------------------------------------
1 | # Software License Agreement (BSD License)
2 | #
3 | # Copyright (c) 2012, Willow Garage, Inc.
4 | # All rights reserved.
5 | #
6 | # Redistribution and use in source and binary forms, with or without
7 | # modification, are permitted provided that the following conditions
8 | # are met:
9 | #
10 | # * Redistributions of source code must retain the above copyright
11 | # notice, this list of conditions and the following disclaimer.
12 | # * Redistributions in binary form must reproduce the above
13 | # copyright notice, this list of conditions and the following
14 | # disclaimer in the documentation and/or other materials provided
15 | # with the distribution.
16 | # * Neither the name of Willow Garage, Inc. nor the names of its
17 | # contributors may be used to endorse or promote products derived
18 | # from this software without specific prior written permission.
19 | #
20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
23 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
24 | # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
25 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
26 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
29 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
30 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31 | # POSSIBILITY OF SUCH DAMAGE.
32 |
33 | import rospy
34 | import time
35 | import threading
36 | import collections
37 | import itertools
38 | import bisect
39 |
40 | from python_qt_binding.QtCore import Qt, QTimer, qWarning, Signal
41 | try: # indigo
42 | from python_qt_binding.QtGui import QGraphicsScene, QMessageBox
43 | except ImportError: # kinetic+ (pyqt5)
44 | from python_qt_binding.QtWidgets import QGraphicsScene, QMessageBox
45 |
46 | from .dynamic_timeline_frame import DynamicTimelineFrame
47 | from rqt_bag.message_listener_thread import MessageListenerThread
48 | from .message_loader_thread import MessageLoaderThread
49 | from rqt_bag.player import Player
50 | from rqt_bag.recorder import Recorder
51 | from rqt_bag.timeline_menu import TimelinePopupMenu
52 |
53 | from . import topic_helper
54 |
55 |
56 | class DynamicTimeline(QGraphicsScene):
57 | """
58 | BagTimeline contains bag files, all information required to display the bag data visualization on the screen
59 | Also handles events
60 | """
61 | status_bar_changed_signal = Signal()
62 | selected_region_changed = Signal(rospy.Time, rospy.Time)
63 | timeline_updated = Signal()
64 | Topic = collections.namedtuple('Topic', ['subscriber', 'queue'])
65 | Message = collections.namedtuple('Message', ['stamp', 'message'])
66 |
67 | def __init__(self, context, publish_clock):
68 | """
69 | :param context: plugin context hook to enable adding rqt_bag plugin widgets as ROS_GUI snapin panes, ''PluginContext''
70 | """
71 | super(DynamicTimeline, self).__init__()
72 | # key is topic name, value is a named tuple of type Topic. The deque
73 | # contains named tuples of type Message
74 | self._topics = {}
75 | # key is the data type, value is a list of topics with that type
76 | self._datatypes = {}
77 | self._topic_lock = threading.RLock()
78 |
79 | self.background_task = None # Display string
80 | self.background_task_cancel = False
81 |
82 | # Playing / Recording
83 | self._playhead_lock = threading.RLock()
84 | self._max_play_speed = 1024.0 # fastest X play speed
85 | self._min_play_speed = 1.0 / 1024.0 # slowest X play speed
86 | self._play_speed = 0.0
87 | self._play_all = False
88 | self._playhead_positions_cvs = {}
89 | self._playhead_positions = {} # topic -> position
90 | self._message_loaders = {}
91 | self._messages_cvs = {}
92 | self._messages = {} # topic -> msg_data
93 | self._message_listener_threads = {} # listener -> MessageListenerThread
94 | self._player = False
95 | self._publish_clock = publish_clock
96 | self._recorder = None
97 | self.last_frame = None
98 | self.last_playhead = None
99 | self.desired_playhead = None
100 | self.wrap = True # should the playhead wrap when it reaches the end?
101 | self.stick_to_end = False # should the playhead stick to the end?
102 | self._play_timer = QTimer()
103 | self._play_timer.timeout.connect(self.on_idle)
104 | self._play_timer.setInterval(3)
105 | self._redraw_timer = None # timer which can be used to periodically redraw the timeline
106 |
107 | # Plugin popup management
108 | self._context = context
109 | self.popups = {}
110 | self._views = []
111 | self._listeners = {}
112 |
113 | # Initialize scene
114 | # the timeline renderer fixes use of black pens and fills, so ensure we fix white here for contrast.
115 | # otherwise a dark qt theme will default it to black and the frame render pen will be unreadable
116 | self.setBackgroundBrush(Qt.white)
117 | self._timeline_frame = DynamicTimelineFrame(self)
118 | self._timeline_frame.setPos(0, 0)
119 | self.addItem(self._timeline_frame)
120 |
121 | self.background_progress = 0
122 | self.__closed = False
123 |
124 | # timer to periodically redraw the timeline every so often
125 | self._start_redraw_timer()
126 |
127 | def get_context(self):
128 | """
129 | :returns: the ROS_GUI context, 'PluginContext'
130 | """
131 | return self._context
132 |
133 | def _start_redraw_timer(self):
134 | if not self._redraw_timer:
135 | self._redraw_timer = rospy.Timer(rospy.Duration(0.5), self._redraw_timeline)
136 |
137 | def _stop_redraw_timer(self):
138 | if self._redraw_timer:
139 | self._redraw_timer.shutdown()
140 | self._redraw_timer = None
141 |
142 | def handle_close(self):
143 | """
144 | Cleans up the timeline, subscribers and any threads
145 | """
146 | if self.__closed:
147 | return
148 | else:
149 | self.__closed = True
150 | self._play_timer.stop()
151 | for topic in self._get_topics():
152 | self.stop_publishing(topic)
153 | self._message_loaders[topic].stop()
154 | if self._player:
155 | self._player.stop()
156 | if self._recorder:
157 | self._recorder.stop()
158 | if self.background_task is not None:
159 | self.background_task_cancel = True
160 | self._timeline_frame.handle_close()
161 | for topic in self._topics:
162 | self._topics[topic][0].unregister() # unregister the subscriber
163 | for frame in self._views:
164 | if frame.parent():
165 | self._context.remove_widget(frame)
166 |
167 | def _redraw_timeline(self, timer):
168 | # save the playhead so that the redraw doesn't move it
169 | playhead = self._timeline_frame._playhead
170 | if playhead is None:
171 | start = self._timeline_frame.play_region[0] is None
172 | end = self._timeline_frame.play_region[1] is None
173 | else:
174 | start = True if playhead <= self._timeline_frame.play_region[0] else False
175 | end = True if playhead >= self._timeline_frame.play_region[1] else False
176 |
177 | # do not keep setting this if you want the timeline to just grow.
178 | self._timeline_frame._start_stamp = self._get_start_stamp()
179 | self._timeline_frame._end_stamp = self._get_end_stamp()
180 |
181 | self._timeline_frame.reset_zoom()
182 |
183 | if end:
184 | # use play region instead of time.now to stop playhead going past
185 | # the end of the region, which causes problems with
186 | # navigate_previous
187 | self._timeline_frame._set_playhead(self._timeline_frame.play_region[1])
188 | elif start:
189 | self._timeline_frame._set_playhead(self._timeline_frame.play_region[0])
190 | else:
191 | if playhead:
192 | self._timeline_frame._set_playhead(playhead)
193 |
194 | self.timeline_updated.emit()
195 |
196 | def topic_callback(self, msg, topic):
197 | """
198 | Called whenever a message is received on any of the subscribed topics
199 |
200 | :param topic: the topic on which the message was received
201 | :param msg: the message received
202 |
203 | """
204 | message = self.Message(stamp=rospy.Time.now(), message=msg)
205 | with self._topic_lock:
206 | self._topics[topic].queue.append(message)
207 |
208 | # Invalidate entire cache for this topic
209 | with self._timeline_frame.index_cache_cv:
210 | self._timeline_frame.invalidated_caches.add(topic)
211 | # if topic in self._timeline_frame.index_cache:
212 | # del self._timeline_frame.index_cache[topic]
213 | self._timeline_frame.index_cache_cv.notify()
214 |
215 | def add_topic(self, topic, type, num_msgs=20):
216 | """Creates an indexing thread for the new topic. Fixes the borders and notifies
217 | the indexing thread to index the new items bags
218 |
219 | :param topic: a topic to listen to
220 | :param type: type of the topic to listen to
221 | :param num_msgs: number of messages to retain on this topic. If this
222 | value is exceeded, the oldest messages will be dropped
223 |
224 | :return: false if topic already in the timeline, true otherwise
225 |
226 | """
227 | # first item in each sub-list is the name, second is type
228 | if topic not in self._topics:
229 | self._topics[topic] = self.Topic(subscriber=rospy.Subscriber(topic, type, queue_size=1, callback=self.topic_callback, callback_args=topic),
230 | queue=collections.deque(maxlen=num_msgs))
231 | self._datatypes.setdefault(type, []).append(topic)
232 | else:
233 | return False
234 |
235 | self._playhead_positions_cvs[topic] = threading.Condition()
236 | self._messages_cvs[topic] = threading.Condition()
237 | self._message_loaders[topic] = MessageLoaderThread(self, topic)
238 |
239 | self._timeline_frame._start_stamp = self._get_start_stamp()
240 | self._timeline_frame._end_stamp = self._get_end_stamp()
241 | self._timeline_frame.topics = self._get_topics()
242 | self._timeline_frame._topics_by_datatype = self._get_topics_by_datatype()
243 | # If this is the first bag, reset the timeline
244 | self._timeline_frame.reset_zoom()
245 |
246 | # Invalidate entire cache for this topic
247 | with self._timeline_frame.index_cache_cv:
248 | self._timeline_frame.invalidated_caches.add(topic)
249 | if topic in self._timeline_frame.index_cache:
250 | del self._timeline_frame.index_cache[topic]
251 | self._timeline_frame.index_cache_cv.notify()
252 |
253 | return True
254 |
255 | # TODO Rethink API and if these need to be visible
256 | def _get_start_stamp(self):
257 | """
258 |
259 | :return: stamp of the first message received on any of the topics, or none if no messages have been received, ''rospy.Time''
260 | """
261 | with self._topic_lock:
262 | start_stamp = None
263 | for unused_topic_name, topic_tuple in self._topics.items():
264 | topic_start_stamp = topic_helper.get_start_stamp(topic_tuple)
265 | if topic_start_stamp is not None and (start_stamp is None or topic_start_stamp < start_stamp):
266 | start_stamp = topic_start_stamp
267 | return start_stamp
268 |
269 | def _get_end_stamp(self):
270 | """
271 |
272 | :return: stamp of the last message received on any of the topics, or none if no messages have been received, ''rospy.Time''
273 | """
274 | with self._topic_lock:
275 | end_stamp = None
276 | for unused_topic_name, topic_tuple in self._topics.items():
277 | topic_end_stamp = topic_helper.get_end_stamp(topic_tuple)
278 | if topic_end_stamp is not None and (end_stamp is None or topic_end_stamp > end_stamp):
279 | end_stamp = topic_end_stamp
280 | return end_stamp
281 |
282 | def _get_topics(self):
283 | """
284 | :return: sorted list of topic names, ''list(str)''
285 | """
286 | with self._topic_lock:
287 | topics = []
288 | for topic in self._topics:
289 | topics.append(topic)
290 | return sorted(topics)
291 |
292 | def _get_topics_by_datatype(self):
293 | """
294 | :return: dict of list of topics for each datatype, ''dict(datatype:list(topic))''
295 | """
296 | with self._topic_lock:
297 | return self._datatypes
298 |
299 | def get_datatype(self, topic):
300 | """
301 | :return: datatype associated with a topic, ''str''
302 | :raises: if there are multiple datatypes assigned to a single topic, ''Exception''
303 | """
304 | with self._topic_lock:
305 | topic_types = []
306 | for datatype in self._datatypes:
307 | if topic in self._datatypes[datatype]:
308 | if len(topic_types) == 1:
309 | raise Exception("Topic {0} had multiple datatypes ({1}) associated with it".format(topic, str(topic_types)))
310 | topic_types.append(datatype._type)
311 |
312 | if not topic_types:
313 | return None
314 | else:
315 | return topic_types[0]
316 |
317 | def get_entries(self, topics, start_stamp, end_stamp):
318 | """
319 | generator function for topic entries
320 | :param topics: list of topics to query, ''list(str)''
321 | :param start_stamp: stamp to start at, ''rospy.Time''
322 | :param end_stamp: stamp to end at, ''rospy,Time''
323 | :returns: messages on the given topics in chronological order, ''msg''
324 | """
325 | with self._topic_lock:
326 | topic_entries = []
327 | # make sure that we can handle a single topic as well
328 | for topic in topics:
329 | if not topic in self._topics:
330 | rospy.logwarn("Dynamic Timeline : Topic {0} was not in the topic list. Skipping.".format(topic))
331 | continue
332 |
333 | # don't bother with topics if they don't overlap the requested time range
334 | topic_start_time = topic_helper.get_start_stamp(self._topics[topic])
335 | if topic_start_time is not None and topic_start_time > end_stamp:
336 | continue
337 |
338 | topic_end_time = topic_helper.get_end_stamp(self._topics[topic])
339 | if topic_end_time is not None and topic_end_time < start_stamp:
340 | continue
341 |
342 | topic_queue = self._topics[topic].queue
343 | start_ind, first_entry = self._entry_at(start_stamp, topic_queue)
344 | # entry returned might be the latest one before the start stamp
345 | # if there isn't one exactly on the stamp - if so, start from
346 | # the next entry
347 | if first_entry.stamp < start_stamp:
348 | start_ind += 1
349 |
350 | # entry at always returns entry at or before the given stamp, so
351 | # no manipulation needed.
352 | end_ind, last_entry = self._entry_at(end_stamp, topic_queue)
353 |
354 | topic_entries.extend(list(itertools.islice(topic_queue, start_ind, end_ind + 1)))
355 |
356 | for entry in sorted(topic_entries, key=lambda x: x.stamp):
357 | yield entry
358 |
359 | def get_entries_with_bags(self, topic, start_stamp, end_stamp):
360 | """
361 | generator function for bag entries
362 | :param topics: list of topics to query, ''list(str)''
363 | :param start_stamp: stamp to start at, ''rospy.Time''
364 | :param end_stamp: stamp to end at, ''rospy,Time''
365 | :returns: tuple of (bag, entry) for the entries in the bag file, ''(rosbag.bag, msg)''
366 | """
367 | with self._bag_lock:
368 | from rosbag import bag # for _mergesort
369 |
370 | bag_entries = []
371 | bag_by_iter = {}
372 | for b in self._bags:
373 | bag_start_time = bag_helper.get_start_stamp(b)
374 | if bag_start_time is not None and bag_start_time > end_stamp:
375 | continue
376 |
377 | bag_end_time = bag_helper.get_end_stamp(b)
378 | if bag_end_time is not None and bag_end_time < start_stamp:
379 | continue
380 |
381 | connections = list(b._get_connections(topic))
382 | it = iter(b._get_entries(connections, start_stamp, end_stamp))
383 | bag_by_iter[it] = b
384 | bag_entries.append(it)
385 |
386 | for entry, it in bag._mergesort(bag_entries, key=lambda entry: entry.time):
387 | yield bag_by_iter[it], entry
388 |
389 | def _entry_at(self, t, queue):
390 | """Get the entry and index in the queue at the given time.
391 |
392 | :param ``rospy.Time`` t: time to check
393 | :param ``collections.deque`` queue: deque to look at
394 |
395 | :return: (index, Message) tuple. If there is no message in the queue at
396 | the exact time, the previous index is returned. If the time is
397 | before or after the first and last message times, the first or last
398 | index and message are returned
399 |
400 | """
401 | # Gives the index to insert into to retain a sorted queue. The topic queues
402 | # should always be sorted due to time passing.
403 |
404 | # ind = bisect.bisect(queue, self.Message(stamp=t, message=''))
405 | # Can't use bisect here in python3 unless the correct operators
406 | # are defined for sorting, so do it manually
407 | try:
408 | ind = next(i for i, msg in enumerate(queue) if t < msg.stamp)
409 | except StopIteration:
410 | ind = len(queue)
411 |
412 | # first or last indices
413 | if ind == len(queue):
414 | return (ind - 1, queue[-1])
415 | elif ind == 0:
416 | return (0, queue[0])
417 |
418 | # non-end indices
419 | cur = queue[ind]
420 | if cur.stamp == t:
421 | return (ind, cur)
422 | else:
423 | return (ind - 1, queue[ind - 1])
424 |
425 | def get_entry(self, t, topic):
426 | """Get a message in the queues for a specific topic
427 | :param ``rospy.Time`` t: time of the message to retrieve
428 | :param str topic: the topic to be accessed
429 | :return: message corresponding to time t and topic. If there is no
430 | corresponding entry at exactly the given time, the latest entry
431 | before the given time is returned. If the topic does not exist, or
432 | there is no entry, None.
433 |
434 | """
435 | with self._topic_lock:
436 | entry = None
437 | if topic in self._topics:
438 | _, entry = self._entry_at(t, self._topics[topic].queue)
439 |
440 | return entry
441 |
442 | def get_entry_before(self, t):
443 | """
444 | Get the latest message before the given time on any of the topics
445 | :param t: time, ``rospy.Time``
446 | :return: tuple of (topic, entry) corresponding to time t, ``(str, msg)``
447 | """
448 | with self._topic_lock:
449 | entry_topic, entry = None, None
450 | for topic in self._topics:
451 | _, topic_entry = self._entry_at(t - rospy.Duration(0,1), self._topics[topic].queue)
452 | if topic_entry and (not entry or topic_entry.stamp > entry.stamp):
453 | entry_topic, entry = topic, topic_entry
454 |
455 | return entry_topic, entry
456 |
457 | def get_entry_after(self, t):
458 | """
459 | Get the earliest message on any topic after the given time
460 | :param t: time, ''rospy.Time''
461 | :return: tuple of (bag, entry) corresponding to time t, ''(rosbag.bag, msg)''
462 | """
463 | with self._topic_lock:
464 | entry_topic, entry = None, None
465 | for topic in self._topics:
466 | ind, _ = self._entry_at(t, self._topics[topic].queue)
467 | # ind is the index of the entry at (if it exists) or before time
468 | # t - we want the one after this. Make sure that the given index
469 | # isn't out of bounds
470 | ind = ind + 1 if ind + 1 < len(self._topics[topic].queue) else ind
471 | topic_entry = self._topics[topic].queue[ind]
472 | if topic_entry and (not entry or topic_entry.stamp < entry.stamp):
473 | entry_topic, entry = topic, topic_entry
474 |
475 | return entry_topic, entry
476 |
477 | def get_next_message_time(self):
478 | """
479 | :return: time of the message after the current playhead position,''rospy.Time''
480 | """
481 | if self._timeline_frame.playhead is None:
482 | return None
483 |
484 | _, entry = self.get_entry_after(self._timeline_frame.playhead)
485 | if entry is None:
486 | return self._timeline_frame._end_stamp
487 |
488 | return entry.stamp
489 |
490 | def get_previous_message_time(self):
491 | """
492 | :return: time of the message before the current playhead position,''rospy.Time''
493 | """
494 | if self._timeline_frame.playhead is None:
495 | return None
496 |
497 | _, entry = self.get_entry_before(self._timeline_frame.playhead)
498 | if entry is None:
499 | return self._timeline_frame._start_stamp
500 |
501 | return entry.stamp
502 |
503 | def resume(self):
504 | if (self._player):
505 | self._player.resume()
506 |
507 | ### Copy messages to...
508 |
509 | def start_background_task(self, background_task):
510 | """
511 | Verify that a background task is not currently running before starting a new one
512 | :param background_task: name of the background task, ''str''
513 | """
514 | if self.background_task is not None:
515 | QMessageBox(QMessageBox.Warning, 'Exclamation', 'Background operation already running:\n\n%s' % self.background_task, QMessageBox.Ok).exec_()
516 | return False
517 |
518 | self.background_task = background_task
519 | self.background_task_cancel = False
520 | return True
521 |
522 | def stop_background_task(self):
523 | self.background_task = None
524 |
525 | def copy_region_to_bag(self, filename):
526 | if len(self._bags) > 0:
527 | self._export_region(filename, self._timeline_frame.topics, self._timeline_frame.play_region[0], self._timeline_frame.play_region[1])
528 |
529 | def _export_region(self, path, topics, start_stamp, end_stamp):
530 | """
531 | Starts a thread to save the current selection to a new bag file
532 | :param path: filesystem path to write to, ''str''
533 | :param topics: topics to write to the file, ''list(str)''
534 | :param start_stamp: start of area to save, ''rospy.Time''
535 | :param end_stamp: end of area to save, ''rospy.Time''
536 | """
537 | if not self.start_background_task('Copying messages to "%s"' % path):
538 | return
539 | # TODO implement a status bar area with information on the current save status
540 | bag_entries = list(self.get_entries_with_bags(topics, start_stamp, end_stamp))
541 |
542 | if self.background_task_cancel:
543 | return
544 |
545 | # Get the total number of messages to copy
546 | total_messages = len(bag_entries)
547 |
548 | # If no messages, prompt the user and return
549 | if total_messages == 0:
550 | QMessageBox(QMessageBox.Warning, 'rqt_bag', 'No messages found', QMessageBox.Ok).exec_()
551 | self.stop_background_task()
552 | return
553 |
554 | # Open the path for writing
555 | try:
556 | export_bag = rosbag.Bag(path, 'w')
557 | except Exception:
558 | QMessageBox(QMessageBox.Warning, 'rqt_bag', 'Error opening bag file [%s] for writing' % path, QMessageBox.Ok).exec_()
559 | self.stop_background_task()
560 | return
561 |
562 | # Run copying in a background thread
563 | self._export_thread = threading.Thread(target=self._run_export_region, args=(export_bag, topics, start_stamp, end_stamp, bag_entries))
564 | self._export_thread.start()
565 |
566 | def _run_export_region(self, export_bag, topics, start_stamp, end_stamp, bag_entries):
567 | """
568 | Threaded function that saves the current selection to a new bag file
569 | :param export_bag: bagfile to write to, ''rosbag.bag''
570 | :param topics: topics to write to the file, ''list(str)''
571 | :param start_stamp: start of area to save, ''rospy.Time''
572 | :param end_stamp: end of area to save, ''rospy.Time''
573 | """
574 | total_messages = len(bag_entries)
575 | update_step = max(1, total_messages / 100)
576 | message_num = 1
577 | progress = 0
578 | # Write out the messages
579 | for bag, entry in bag_entries:
580 | if self.background_task_cancel:
581 | break
582 | try:
583 | topic, msg, t = self.read_message(bag, entry.position)
584 | export_bag.write(topic, msg, t)
585 | except Exception as ex:
586 | qWarning('Error exporting message at position %s: %s' % (str(entry.position), str(ex)))
587 | export_bag.close()
588 | self.stop_background_task()
589 | return
590 |
591 | if message_num % update_step == 0 or message_num == total_messages:
592 | new_progress = int(100.0 * (float(message_num) / total_messages))
593 | if new_progress != progress:
594 | progress = new_progress
595 | if not self.background_task_cancel:
596 | self.background_progress = progress
597 | self.status_bar_changed_signal.emit()
598 |
599 | message_num += 1
600 |
601 | # Close the bag
602 | try:
603 | self.background_progress = 0
604 | self.status_bar_changed_signal.emit()
605 | export_bag.close()
606 | except Exception as ex:
607 | QMessageBox(QMessageBox.Warning, 'rqt_bag', 'Error closing bag file [%s]: %s' % (export_bag.filename, str(ex)), QMessageBox.Ok).exec_()
608 | self.stop_background_task()
609 |
610 | def read_message(self, topic, position):
611 | with self._topic_lock:
612 | return self.get_entry(position, topic).message
613 |
614 | def on_mouse_down(self, event):
615 | """
616 | When the user clicks down in the timeline.
617 | """
618 | if event.buttons() == Qt.LeftButton:
619 | self._timeline_frame.on_left_down(event)
620 | elif event.buttons() == Qt.MidButton:
621 | self._timeline_frame.on_middle_down(event)
622 | elif event.buttons() == Qt.RightButton:
623 | topic = self._timeline_frame.map_y_to_topic(event.y())
624 | TimelinePopupMenu(self, event, topic)
625 |
626 | def on_mouse_up(self, event):
627 | self._timeline_frame.on_mouse_up(event)
628 |
629 | def on_mouse_move(self, event):
630 | self._timeline_frame.on_mouse_move(event)
631 |
632 | def on_mousewheel(self, event):
633 | self._timeline_frame.on_mousewheel(event)
634 |
635 | # Zooming
636 |
637 | def zoom_in(self):
638 | self._timeline_frame.zoom_in()
639 |
640 | def zoom_out(self):
641 | self._timeline_frame.zoom_out()
642 |
643 | def reset_zoom(self):
644 | self._timeline_frame.reset_zoom()
645 |
646 | def translate_timeline_left(self):
647 | self._timeline_frame.translate_timeline_left()
648 |
649 | def translate_timeline_right(self):
650 | self._timeline_frame.translate_timeline_right()
651 |
652 | ### Publishing
653 | def is_publishing(self, topic):
654 | return self._player and self._player.is_publishing(topic)
655 |
656 | def start_publishing(self, topic):
657 | if not self._player and not self._create_player():
658 | return False
659 |
660 | self._player.start_publishing(topic)
661 | return True
662 |
663 | def stop_publishing(self, topic):
664 | if not self._player:
665 | return False
666 |
667 | self._player.stop_publishing(topic)
668 | return True
669 |
670 | def _create_player(self):
671 | if not self._player:
672 | try:
673 | self._player = Player(self)
674 | if self._publish_clock:
675 | self._player.start_clock_publishing()
676 | except Exception as ex:
677 | qWarning('Error starting player; aborting publish: %s' % str(ex))
678 | return False
679 |
680 | return True
681 |
682 | def set_publishing_state(self, start_publishing):
683 | if start_publishing:
684 | for topic in self._timeline_frame.topics:
685 | if not self.start_publishing(topic):
686 | break
687 | else:
688 | for topic in self._timeline_frame.topics:
689 | self.stop_publishing(topic)
690 |
691 | # property: play_all
692 | def _get_play_all(self):
693 | return self._play_all
694 |
695 | def _set_play_all(self, play_all):
696 | if play_all == self._play_all:
697 | return
698 |
699 | self._play_all = not self._play_all
700 |
701 | self.last_frame = None
702 | self.last_playhead = None
703 | self.desired_playhead = None
704 |
705 | play_all = property(_get_play_all, _set_play_all)
706 |
707 | def toggle_play_all(self):
708 | self.play_all = not self.play_all
709 |
710 | ### Playing
711 | def on_idle(self):
712 | self._step_playhead()
713 |
714 | def _step_playhead(self):
715 | """
716 | moves the playhead to the next position based on the desired position
717 | """
718 | # Reset when the playing mode switchs
719 | if self._timeline_frame.playhead != self.last_playhead:
720 | self.last_frame = None
721 | self.last_playhead = None
722 | self.desired_playhead = None
723 |
724 | if self._play_all:
725 | self.step_next_message()
726 | else:
727 | self.step_fixed()
728 |
729 | def step_fixed(self):
730 | """
731 | Moves the playhead a fixed distance into the future based on the current play speed
732 | """
733 | if self.play_speed == 0.0 or not self._timeline_frame.playhead:
734 | self.last_frame = None
735 | self.last_playhead = None
736 | return
737 |
738 | now = rospy.Time.from_sec(time.time())
739 | if self.last_frame:
740 | # Get new playhead
741 | if self.stick_to_end:
742 | new_playhead = self.end_stamp
743 | else:
744 | new_playhead = self._timeline_frame.playhead + rospy.Duration.from_sec((now - self.last_frame).to_sec() * self.play_speed)
745 |
746 | start_stamp, end_stamp = self._timeline_frame.play_region
747 |
748 | if new_playhead > end_stamp:
749 | if self.wrap:
750 | if self.play_speed > 0.0:
751 | new_playhead = start_stamp
752 | else:
753 | new_playhead = end_stamp
754 | else:
755 | new_playhead = end_stamp
756 |
757 | if self.play_speed > 0.0:
758 | self.stick_to_end = True
759 |
760 | elif new_playhead < start_stamp:
761 | if self.wrap:
762 | if self.play_speed < 0.0:
763 | new_playhead = end_stamp
764 | else:
765 | new_playhead = start_stamp
766 | else:
767 | new_playhead = start_stamp
768 |
769 | # Update the playhead
770 | self._timeline_frame.playhead = new_playhead
771 |
772 | self.last_frame = now
773 | self.last_playhead = self._timeline_frame.playhead
774 |
775 | def step_next_message(self):
776 | """
777 | Move the playhead to the next message
778 | """
779 | if self.play_speed <= 0.0 or not self._timeline_frame.playhead:
780 | self.last_frame = None
781 | self.last_playhead = None
782 | return
783 |
784 | if self.last_frame:
785 | if not self.desired_playhead:
786 | self.desired_playhead = self._timeline_frame.playhead
787 | else:
788 | delta = rospy.Time.from_sec(time.time()) - self.last_frame
789 | if delta > rospy.Duration.from_sec(0.1):
790 | delta = rospy.Duration.from_sec(0.1)
791 | self.desired_playhead += delta
792 |
793 | # Get the occurrence of the next message
794 | next_message_time = self.get_next_message_time()
795 |
796 | if next_message_time < self.desired_playhead:
797 | self._timeline_frame.playhead = next_message_time
798 | else:
799 | self._timeline_frame.playhead = self.desired_playhead
800 |
801 | self.last_frame = rospy.Time.from_sec(time.time())
802 | self.last_playhead = self._timeline_frame.playhead
803 |
804 | # Recording
805 | def record_bag(self, filename, all=True, topics=[], regex=False, limit=0):
806 | try:
807 | self._recorder = Recorder(filename, bag_lock=self._bag_lock, all=all, topics=topics, regex=regex, limit=limit)
808 | except Exception as ex:
809 | qWarning('Error opening bag for recording [%s]: %s' % (filename, str(ex)))
810 | return
811 |
812 | self._recorder.add_listener(self._message_recorded)
813 |
814 | self.add_bag(self._recorder.bag)
815 |
816 | self._recorder.start()
817 |
818 | self.wrap = False
819 | self._timeline_frame._index_cache_thread.period = 0.1
820 |
821 | self.update()
822 |
823 | def toggle_recording(self):
824 | if self._recorder:
825 | self._recorder.toggle_paused()
826 | self.update()
827 |
828 | def _message_recorded(self, topic, msg, t):
829 | if self._timeline_frame._start_stamp is None:
830 | self._timeline_frame._start_stamp = t
831 | self._timeline_frame._end_stamp = t
832 | self._timeline_frame._playhead = t
833 | elif self._timeline_frame._end_stamp is None or t > self._timeline_frame._end_stamp:
834 | self._timeline_frame._end_stamp = t
835 |
836 | if not self._timeline_frame.topics or topic not in self._timeline_frame.topics:
837 | self._timeline_frame.topics = self._get_topics()
838 | self._timeline_frame._topics_by_datatype = self._get_topics_by_datatype()
839 |
840 | self._playhead_positions_cvs[topic] = threading.Condition()
841 | self._messages_cvs[topic] = threading.Condition()
842 | self._message_loaders[topic] = MessageLoaderThread(self, topic)
843 |
844 | if self._timeline_frame._stamp_left is None:
845 | self.reset_zoom()
846 |
847 | # Notify the index caching thread that it has work to do
848 | with self._timeline_frame.index_cache_cv:
849 | self._timeline_frame.invalidated_caches.add(topic)
850 | self._timeline_frame.index_cache_cv.notify()
851 |
852 | if topic in self._listeners:
853 | for listener in self._listeners[topic]:
854 | try:
855 | listener.timeline_changed()
856 | except Exception as ex:
857 | qWarning('Error calling timeline_changed on %s: %s' % (type(listener), str(ex)))
858 |
859 | # Views / listeners
860 | def add_view(self, topic, frame):
861 | self._views.append(frame)
862 |
863 | def has_listeners(self, topic):
864 | return topic in self._listeners
865 |
866 | def add_listener(self, topic, listener):
867 | self._listeners.setdefault(topic, []).append(listener)
868 |
869 | self._message_listener_threads[(topic, listener)] = MessageListenerThread(self, topic, listener)
870 | # Notify the message listeners
871 | self._message_loaders[topic].reset()
872 | with self._playhead_positions_cvs[topic]:
873 | self._playhead_positions_cvs[topic].notify_all()
874 |
875 | self.update()
876 |
877 | def remove_listener(self, topic, listener):
878 | topic_listeners = self._listeners.get(topic)
879 | if topic_listeners is not None and listener in topic_listeners:
880 | topic_listeners.remove(listener)
881 |
882 | if len(topic_listeners) == 0:
883 | del self._listeners[topic]
884 |
885 | # Stop the message listener thread
886 | if (topic, listener) in self._message_listener_threads:
887 | self._message_listener_threads[(topic, listener)].stop()
888 | del self._message_listener_threads[(topic, listener)]
889 | self.update()
890 |
891 | ### Playhead
892 |
893 | # property: play_speed
894 | def _get_play_speed(self):
895 | if self._timeline_frame._paused:
896 | return 0.0
897 | return self._play_speed
898 |
899 | def _set_play_speed(self, play_speed):
900 | if play_speed == self._play_speed:
901 | return
902 |
903 | if play_speed > 0.0:
904 | self._play_speed = min(self._max_play_speed, max(self._min_play_speed, play_speed))
905 | elif play_speed < 0.0:
906 | self._play_speed = max(-self._max_play_speed, min(-self._min_play_speed, play_speed))
907 | else:
908 | self._play_speed = play_speed
909 |
910 | if self._play_speed < 1.0:
911 | self.stick_to_end = False
912 |
913 | self.update()
914 | play_speed = property(_get_play_speed, _set_play_speed)
915 |
916 | def toggle_play(self):
917 | if self._play_speed != 0.0:
918 | self.play_speed = 0.0
919 | else:
920 | self.play_speed = 1.0
921 |
922 | def navigate_play(self):
923 | self.play_speed = 1.0
924 | self.last_frame = rospy.Time.from_sec(time.time())
925 | self.last_playhead = self._timeline_frame.playhead
926 | self._play_timer.start()
927 |
928 | def navigate_stop(self):
929 | self.play_speed = 0.0
930 | self._play_timer.stop()
931 |
932 | def navigate_previous(self):
933 | self.navigate_stop()
934 | self._timeline_frame.playhead = self.get_previous_message_time()
935 | self.last_playhead = self._timeline_frame.playhead
936 |
937 | def navigate_next(self):
938 | self.navigate_stop()
939 | self._timeline_frame.playhead = self.get_next_message_time()
940 | self.last_playhead = self._timeline_frame.playhead
941 |
942 | def navigate_rewind(self):
943 | if self._play_speed < 0.0:
944 | new_play_speed = self._play_speed * 2.0
945 | elif self._play_speed == 0.0:
946 | new_play_speed = -1.0
947 | else:
948 | new_play_speed = self._play_speed * 0.5
949 |
950 | self.play_speed = new_play_speed
951 |
952 | def navigate_fastforward(self):
953 | if self._play_speed > 0.0:
954 | new_play_speed = self._play_speed * 2.0
955 | elif self._play_speed == 0.0:
956 | new_play_speed = 2.0
957 | else:
958 | new_play_speed = self._play_speed * 0.5
959 |
960 | self.play_speed = new_play_speed
961 |
962 | def navigate_start(self):
963 | self._timeline_frame.playhead = self._timeline_frame.play_region[0]
964 |
965 | def navigate_end(self):
966 | self._timeline_frame.playhead = self._timeline_frame.play_region[1]
967 |
--------------------------------------------------------------------------------
/src/rqt_py_trees/behaviour_tree.py:
--------------------------------------------------------------------------------
1 | # Software License Agreement (BSD License)
2 | #
3 | # Copyright (c) 2011, Willow Garage, Inc.
4 | # All rights reserved.
5 | #
6 | # Redistribution and use in source and binary forms, with or without
7 | # modification, are permitted provided that the following conditions
8 | # are met:
9 | #
10 | # * Redistributions of source code must retain the above copyright
11 | # notice, this list of conditions and the following disclaimer.
12 | # * Redistributions in binary form must reproduce the above
13 | # copyright notice, this list of conditions and the following
14 | # disclaimer in the documentation and/or other materials provided
15 | # with the distribution.
16 | # * Neither the name of Willow Garage, Inc. nor the names of its
17 | # contributors may be used to endorse or promote products derived
18 | # from this software without specific prior written permission.
19 | #
20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
23 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
24 | # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
25 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
26 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
29 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
30 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31 | # POSSIBILITY OF SUCH DAMAGE.
32 |
33 | from __future__ import division
34 |
35 | import argparse
36 | import fnmatch
37 | import functools
38 | import py_trees
39 | import py_trees_msgs.msg as py_trees_msgs
40 | import os
41 | import re
42 | import rosbag
43 | import rospkg
44 | import rospy
45 | import sys
46 | import termcolor
47 | import uuid_msgs.msg as uuid_msgs
48 |
49 | from . import visibility
50 |
51 | from .dotcode_behaviour import RosBehaviourTreeDotcodeGenerator
52 | from .dynamic_timeline import DynamicTimeline
53 | from .dynamic_timeline_listener import DynamicTimelineListener
54 | from .timeline_listener import TimelineListener
55 | from .qt_dotgraph.dot_to_qt import DotToQtGenerator
56 | from .qt_dotgraph.pydotfactory import PydotFactory
57 | from .qt_dotgraph.pygraphvizfactory import PygraphvizFactory
58 | from rqt_bag.bag_timeline import BagTimeline
59 | # from rqt_bag.bag_widget import BagGraphicsView
60 | from rqt_graph.interactive_graphics_view import InteractiveGraphicsView
61 |
62 | from python_qt_binding import loadUi
63 | from python_qt_binding.QtCore import QFile, QIODevice, QObject, Qt, Signal, QEvent, Slot
64 | from python_qt_binding.QtGui import QIcon, QImage, QPainter, QKeySequence
65 | from python_qt_binding.QtSvg import QSvgGenerator
66 | try: # indigo
67 | from python_qt_binding.QtGui import QFileDialog, QGraphicsView, QGraphicsScene, QWidget, QShortcut
68 | except ImportError: # kinetic+ (pyqt5)
69 | from python_qt_binding.QtWidgets import QFileDialog, QGraphicsView, QGraphicsScene, QWidget, QShortcut
70 |
71 |
72 | class RosBehaviourTree(QObject):
73 |
74 | _deferred_fit_in_view = Signal()
75 | _refresh_view = Signal()
76 | _refresh_combo = Signal()
77 | _message_changed = Signal()
78 | _message_cleared = Signal()
79 | _expected_type = py_trees_msgs.BehaviourTree()._type
80 | _empty_topic = "No valid topics available"
81 | _unselected_topic = "Not subscribing"
82 | no_roscore_switch = "--no-roscore"
83 |
84 | class ComboBoxEventFilter(QObject):
85 | """Event filter for the combo box. Will filter left mouse button presses,
86 | calling a signal when they happen
87 |
88 | """
89 | def __init__(self, signal):
90 | """
91 |
92 | :param Signal signal: signal that is emitted when a left mouse button press happens
93 | """
94 | super(RosBehaviourTree.ComboBoxEventFilter, self).__init__()
95 | self.signal = signal
96 |
97 | def eventFilter(self, obj, event):
98 | if event.type() == QEvent.MouseButtonPress and event.button() == Qt.LeftButton:
99 | self.signal.emit()
100 | return False
101 |
102 | def __init__(self, context):
103 | super(RosBehaviourTree, self).__init__(context)
104 | self.setObjectName('RosBehaviourTree')
105 |
106 | parser = argparse.ArgumentParser()
107 | RosBehaviourTree.add_arguments(parser, False)
108 | # if the context doesn't have an argv attribute then assume we're running with --no-roscore
109 | if not hasattr(context, 'argv'):
110 | args = sys.argv[1:]
111 | # Can run the viewer with or without live updating. Running without is
112 | # intended for viewing of bags only
113 | self.live_update = False
114 | else:
115 | args = context.argv()
116 | self.live_update = True
117 |
118 | parsed_args = parser.parse_args(args)
119 |
120 | self.context = context
121 | self.initialized = False
122 | self._current_dotcode = None # dotcode for the tree that is currently displayed
123 | self._viewing_bag = False # true if a bag file is loaded
124 | # True if next or previous buttons are pressed. Reset if the tree being
125 | # viewed is the last one in the list.
126 | self._browsing_timeline = False
127 |
128 | self._widget = QWidget()
129 |
130 | # factory builds generic dotcode items
131 | self.dotcode_factory = PygraphvizFactory() # PydotFactory()
132 | # self.dotcode_factory = PygraphvizFactory()
133 | # generator builds rosgraph
134 | self.dotcode_generator = RosBehaviourTreeDotcodeGenerator()
135 | self.current_topic = None
136 | self.behaviour_sub = None
137 | self._tip_message = None # message of the tip of the tree
138 | self._default_topic = parsed_args.topic # topic parsed on the command line
139 | self._saved_settings_topic = None # topic subscribed to by previous instance
140 | self.visibility_level = py_trees.common.VisibilityLevel.DETAIL
141 |
142 | # dot_to_qt transforms into Qt elements using dot layout
143 | self.dot_to_qt = DotToQtGenerator()
144 |
145 | rp = rospkg.RosPack()
146 | ui_file = os.path.join(rp.get_path('rqt_py_trees'), 'resource', 'RosBehaviourTree.ui')
147 | loadUi(ui_file, self._widget, {'InteractiveGraphicsView': InteractiveGraphicsView})
148 | self._widget.setObjectName('RosBehaviourTreeUi')
149 | if hasattr(context, 'serial_number') and context.serial_number() > 1:
150 | self._widget.setWindowTitle(self._widget.windowTitle() + (' (%d)' % context.serial_number()))
151 |
152 | self._scene = QGraphicsScene()
153 | self._scene.setBackgroundBrush(Qt.white)
154 | self._widget.graphics_view.setScene(self._scene)
155 |
156 | self._widget.highlight_connections_check_box.toggled.connect(self._redraw_graph_view)
157 | self._widget.auto_fit_graph_check_box.toggled.connect(self._redraw_graph_view)
158 | self._widget.fit_in_view_push_button.setIcon(QIcon.fromTheme('zoom-original'))
159 | self._widget.fit_in_view_push_button.pressed.connect(self._fit_in_view)
160 |
161 | self._widget.load_bag_push_button.setIcon(QIcon.fromTheme('document-open'))
162 | self._widget.load_bag_push_button.pressed.connect(self._load_bag)
163 | self._widget.load_dot_push_button.setIcon(QIcon.fromTheme('document-open'))
164 | self._widget.load_dot_push_button.pressed.connect(self._load_dot)
165 | self._widget.save_dot_push_button.setIcon(QIcon.fromTheme('document-save-as'))
166 | self._widget.save_dot_push_button.pressed.connect(self._save_dot)
167 | self._widget.save_as_svg_push_button.setIcon(QIcon.fromTheme('document-save-as'))
168 | self._widget.save_as_svg_push_button.pressed.connect(self._save_svg)
169 | self._widget.save_as_image_push_button.setIcon(QIcon.fromTheme('image'))
170 | self._widget.save_as_image_push_button.pressed.connect(self._save_image)
171 |
172 | for text in visibility.combo_to_py_trees:
173 | self._widget.visibility_level_combo_box.addItem(text)
174 | self._widget.visibility_level_combo_box.setCurrentIndex(self.visibility_level)
175 | self._widget.visibility_level_combo_box.currentIndexChanged['QString'].connect(self._update_visibility_level)
176 |
177 | # set up the function that is called whenever the box is resized -
178 | # ensures that the timeline is correctly drawn.
179 | self._widget.resizeEvent = self._resize_event
180 |
181 | self._timeline = None
182 | self._timeline_listener = None
183 |
184 | # Connect the message changed function of this object to a corresponding
185 | # signal. This signal will be activated whenever the message being
186 | # viewed changes.
187 | self._message_changed.connect(self.message_changed)
188 | self._message_cleared.connect(self.message_cleared)
189 |
190 | # Set up combo box for topic selection
191 | # when the refresh_combo signal happens, update the combo topics available
192 | self._refresh_combo.connect(self._update_combo_topics)
193 | # filter events to catch the event which opens the combo box
194 | self._combo_event_filter = RosBehaviourTree.ComboBoxEventFilter(self._refresh_combo)
195 | self._widget.topic_combo_box.installEventFilter(self._combo_event_filter)
196 | self._widget.topic_combo_box.activated.connect(self._choose_topic)
197 | # Delay populating the combobox, let restore_settings() handle it
198 | # self._update_combo_topics()
199 |
200 | # Set up navigation buttons
201 | self._widget.previous_tool_button.pressed.connect(self._previous)
202 | self._widget.previous_tool_button.setIcon(QIcon.fromTheme('go-previous'))
203 | self._widget.next_tool_button.pressed.connect(self._next)
204 | self._widget.next_tool_button.setIcon(QIcon.fromTheme('go-next'))
205 | self._widget.first_tool_button.pressed.connect(self._first)
206 | self._widget.first_tool_button.setIcon(QIcon.fromTheme('go-first'))
207 | self._widget.last_tool_button.pressed.connect(self._last)
208 | self._widget.last_tool_button.setIcon(QIcon.fromTheme('go-last'))
209 |
210 | # play, pause and stop buttons
211 | self._widget.play_tool_button.pressed.connect(self._play)
212 | self._widget.play_tool_button.setIcon(QIcon.fromTheme('media-playback-start'))
213 | self._widget.stop_tool_button.pressed.connect(self._stop)
214 | self._widget.stop_tool_button.setIcon(QIcon.fromTheme('media-playback-stop'))
215 | # also connect the navigation buttons so that they stop the timer when
216 | # pressed while the tree is playing.
217 | self._widget.first_tool_button.pressed.connect(self._stop)
218 | self._widget.previous_tool_button.pressed.connect(self._stop)
219 | self._widget.last_tool_button.pressed.connect(self._stop)
220 | self._widget.next_tool_button.pressed.connect(self._stop)
221 |
222 | # set up shortcuts for navigation (vim)
223 | next_shortcut_vi = QShortcut(QKeySequence("l"), self._widget)
224 | next_shortcut_vi.activated.connect(self._widget.next_tool_button.pressed)
225 | previous_shortcut_vi = QShortcut(QKeySequence("h"), self._widget)
226 | previous_shortcut_vi.activated.connect(self._widget.previous_tool_button.pressed)
227 | first_shortcut_vi = QShortcut(QKeySequence("^"), self._widget)
228 | first_shortcut_vi.activated.connect(self._widget.first_tool_button.pressed)
229 | last_shortcut_vi = QShortcut(QKeySequence("$"), self._widget)
230 | last_shortcut_vi.activated.connect(self._widget.last_tool_button.pressed)
231 |
232 | # shortcuts for emacs
233 | next_shortcut_emacs = QShortcut(QKeySequence("Ctrl+f"), self._widget)
234 | next_shortcut_emacs.activated.connect(self._widget.next_tool_button.pressed)
235 | previous_shortcut_emacs = QShortcut(QKeySequence("Ctrl+b"), self._widget)
236 | previous_shortcut_emacs.activated.connect(self._widget.previous_tool_button.pressed)
237 | first_shortcut_emacs = QShortcut(QKeySequence("Ctrl+a"), self._widget)
238 | first_shortcut_emacs.activated.connect(self._widget.first_tool_button.pressed)
239 | last_shortcut_emacs = QShortcut(QKeySequence("Ctrl+e"), self._widget)
240 | last_shortcut_emacs.activated.connect(self._widget.last_tool_button.pressed)
241 |
242 | # set up stuff for dotcode cache
243 | self._dotcode_cache_capacity = 50
244 | self._dotcode_cache = {}
245 | # cache is ordered on timestamps from messages, but earliest timestamp
246 | # isn't necessarily the message that was viewed the longest time ago, so
247 | # need to store keys
248 | self._dotcode_cache_keys = []
249 |
250 | # set up stuff for scene cache (dotcode cache doesn't seem to make much difference)
251 | self._scene_cache_capacity = 50
252 | self._scene_cache = {}
253 | self._scene_cache_keys = []
254 |
255 | # Update the timeline buttons to correspond with a completely
256 | # uninitialised state.
257 | self._set_timeline_buttons(first_snapshot=False, previous_snapshot=False, next_snapshot=False, last_snapshot=False)
258 |
259 | self._deferred_fit_in_view.connect(self._fit_in_view,
260 | Qt.QueuedConnection)
261 | self._deferred_fit_in_view.emit()
262 |
263 | # This is used to store a timer which controls how fast updates happen when the play button is pressed.
264 | self._play_timer = None
265 |
266 | # updates the view
267 | self._refresh_view.connect(self._refresh_tree_graph)
268 |
269 | self._force_refresh = False
270 |
271 | if self.live_update:
272 | context.add_widget(self._widget)
273 | else:
274 | self.initialized = True # this needs to be set for trees to be displayed
275 | context.setCentralWidget(self._widget)
276 |
277 | if parsed_args.bag:
278 | self._load_bag(parsed_args.bag)
279 | elif parsed_args.latest_bag:
280 | # if the latest bag is requested, load it from the default directory, or
281 | # the one specified in the args
282 | bag_dir = parsed_args.bag_dir or os.getenv('ROS_HOME', os.path.expanduser('~/.ros')) + '/behaviour_trees'
283 | self.open_latest_bag(bag_dir, parsed_args.by_time)
284 |
285 | @Slot(str)
286 | def _update_visibility_level(self, visibility_level):
287 | """
288 | We match the combobox index to the visibility levels defined in py_trees.common.VisibilityLevel.
289 | """
290 | self.visibility_level = visibility.combo_to_py_trees[visibility_level]
291 | self._refresh_tree_graph()
292 |
293 | @staticmethod
294 | def add_arguments(parser, group=True):
295 | """Allows for the addition of arguments to the rqt_gui loading method
296 |
297 | :param bool group: If set to false, this indicates that the function is
298 | being called from the rqt_py_trees script as opposed to the inside
299 | of rqt_gui.main. We use this to ensure that the same arguments can
300 | be passed with and without the --no-roscore argument set. If it is
301 | set, the rqt_gui code is bypassed. We need to make sure that all the
302 | arguments are displayed with -h.
303 |
304 | """
305 | operate_object = parser
306 | if group:
307 | operate_object = parser.add_argument_group('Options for the rqt_py_trees viewer')
308 |
309 | operate_object.add_argument('bag', action='store', nargs='?', help='Load this bag when the viewer starts')
310 | operate_object.add_argument('-l', '--latest-bag', action='store_true', help='Load the latest bag available in the bag directory. Bag files are expected to be under the bag directory in the following structure: year-month-day/behaviour_tree_hour-minute-second.bag. If this structure is not followed, the bag file which was most recently modified is used.')
311 | operate_object.add_argument('--bag-dir', action='store', help='Specify the directory in which to look for bag files. The default is $ROS_HOME/behaviour_trees, if $ROS_HOME is set, or ~/.ros/behaviour_trees otherwise.')
312 | operate_object.add_argument('-m', '--by-time', action='store_true', help='The latest bag is defined by the time at which the file was last modified, rather than the date and time specified in the filename.')
313 | operate_object.add_argument(RosBehaviourTree.no_roscore_switch, action='store_true', help='Run the viewer without roscore. It is only possible to view bag files if this is set.')
314 | operate_object.add_argument('--topic', action='store', type=str, default=None, help='Default topic to defer to [default:None]')
315 |
316 | def open_latest_bag(self, bag_dir, by_time=False):
317 | """Open the latest bag in the given directory
318 |
319 | :param str bag_dir: the directory in which to look for bags
320 | :param bool by_time: if true, the latest bag is the one with the latest
321 | modification time, not the latest date-time specified by its filename
322 |
323 | """
324 | if not os.path.isdir(bag_dir):
325 | rospy.logwarn("Requested bag directory {0} is invalid. Latest bag will not be loaded.".format(bag_dir))
326 | return
327 |
328 | files = []
329 | for root, unused_dirnames, filenames in os.walk(bag_dir, topdown=True):
330 | files.extend(fnmatch.filter(map(lambda p: os.path.join(root, p), filenames), '*.bag'))
331 |
332 | if not files:
333 | rospy.logwarn("No files with extension .bag found in directory {0}".format(bag_dir))
334 | return
335 |
336 | if not by_time:
337 | # parse the file list with a regex to get only those which have the
338 | # format year-month-day/behaviour_tree_hour-minute-second.bag
339 | re_str = '.*\/\d{4}-\d{2}-\d{2}\/behaviour_tree_\d{2}-\d{2}-\d{2}.bag'
340 | expr = re.compile(re_str)
341 | valid = filter(lambda f: expr.match(f), files)
342 |
343 | # if no files match the regex, use modification time instead
344 | if not valid:
345 | by_time = True
346 | else:
347 | # dates are monotonically increasing, so the last one is the latest
348 | latest_bag = sorted(valid)[-1]
349 |
350 | if by_time:
351 | latest_bag = sorted(files, cmp=lambda x, y: cmp(os.path.getctime(x), os.path.getctime(y)))[-1]
352 |
353 | self._load_bag(latest_bag)
354 |
355 | def get_current_message(self):
356 | """
357 | Get the message in the list or bag that is being viewed that should be
358 | displayed.
359 | """
360 | msg = None
361 | if self._timeline_listener:
362 | try:
363 | msg = self._timeline_listener.msg
364 | except KeyError:
365 | pass
366 |
367 | return py_trees_msgs.BehaviourTree() if msg is None else msg
368 |
369 | def _choose_topic(self, index):
370 | """Updates the topic that is subscribed to based on changes to the combo box
371 | text. If the topic is unchanged, nothing will happnen. Otherwise, the
372 | old subscriber will be unregistered, and a new one initialised for the
373 | updated topic. If the selected topic corresponds to the unselected
374 | topic, the subscriber will be unregistered and a new one will not be
375 | created.
376 |
377 | """
378 | selected_topic = self._widget.topic_combo_box.currentText()
379 | if selected_topic != self._empty_topic and self.current_topic != selected_topic:
380 | self.current_topic = selected_topic
381 | # destroy the old timeline and clear the scene
382 | if self._timeline:
383 | self._timeline.handle_close()
384 | self._widget.timeline_graphics_view.setScene(None)
385 |
386 | if selected_topic != self._unselected_topic:
387 | # set up a timeline to track the messages coming from the subscriber
388 | self._set_dynamic_timeline()
389 |
390 | def _update_combo_topics(self):
391 | """
392 | Update the topics displayed in the combo box that the user can use to select
393 | which topic they want to listen on for trees, filtered so that only
394 | topics with the correct message type are shown.
395 |
396 | This method is triggered on startup and on user combobox interactions
397 | """
398 | # Only update topics if we're running with live update (i.e. with roscore)
399 | if not self.live_update:
400 | self._widget.topic_combo_box.setEnabled(False)
401 | return
402 |
403 | self._widget.topic_combo_box.clear()
404 | topic_list = rospy.get_published_topics()
405 |
406 | valid_topics = []
407 | for topic_path, topic_type in topic_list:
408 | if topic_type == RosBehaviourTree._expected_type:
409 | valid_topics.append(topic_path)
410 |
411 | # Populate the combo-box
412 | if self._default_topic is not None and self._default_topic not in valid_topics:
413 | self._widget.topic_combo_box.addItem(self._default_topic)
414 | self._widget.topic_combo_box.addItems(valid_topics)
415 | if self._default_topic is None and self._saved_settings_topic is not None:
416 | self._widget.topic_combo_box.addItem(self._saved_settings_topic)
417 | if self._default_topic is None and not valid_topics and self._saved_settings_topic is None:
418 | self._widget.topic_combo_box.addItem(RosBehaviourTree._empty_topic)
419 |
420 | # Initialise it with a topic, priority given to:
421 | # 1) command line choice, i.e. self._default_topic whether
422 | # regardless of whether it exists or not
423 | # 2) the valid topic if only one exists
424 | # 3) the valid topic that is also in saved settings if more than one exists
425 | # 4) the first valid topic in the list
426 | # 5) the saved topic if no valid topics exist
427 | # 6) the empty topic if no valid topics exist and no saved topic exists
428 | # For each choice except 6), set as current with a subscriber.
429 | if self.current_topic is None:
430 | if self._default_topic is not None:
431 | topic = self._default_topic
432 | elif len(valid_topics) == 1:
433 | topic = valid_topics[0]
434 | elif valid_topics:
435 | if self._saved_settings_topic in valid_topics:
436 | topic = self._saved_settings_topic
437 | else:
438 | topic = valid_topics[0]
439 | elif self._saved_settings_topic is not None:
440 | topic = self._saved_settings_topic
441 | else:
442 | topic = None
443 | if topic is not None:
444 | for index in range(self._widget.topic_combo_box.count()):
445 | if topic == self._widget.topic_combo_box.itemText(index):
446 | self._widget.topic_combo_box.setCurrentIndex(index)
447 | self._choose_topic(topic)
448 | break
449 | return
450 |
451 | def _set_timeline_buttons(self, first_snapshot=None, previous_snapshot=None, next_snapshot=None, last_snapshot=None):
452 | """
453 | Allows timeline buttons to be enabled and disabled.
454 | """
455 | if first_snapshot is not None:
456 | self._widget.first_tool_button.setEnabled(first_snapshot)
457 | if previous_snapshot is not None:
458 | self._widget.previous_tool_button.setEnabled(previous_snapshot)
459 | if next_snapshot is not None:
460 | self._widget.next_tool_button.setEnabled(next_snapshot)
461 | if last_snapshot is not None:
462 | self._widget.last_tool_button.setEnabled(last_snapshot)
463 |
464 | def _play(self):
465 | """
466 | Start a timer which will automatically call the next function every time its
467 | duration is up. Only works if the current message is not the final one.
468 | """
469 | if not self._play_timer:
470 | self._play_timer = rospy.Timer(rospy.Duration(1), self._timer_next)
471 |
472 | def _timer_next(self, timer):
473 | """
474 | Helper function for the timer so that it can call the next function without
475 | breaking.
476 | """
477 | self._next()
478 |
479 | def _stop(self):
480 | """Stop the play timer, if it exists.
481 | """
482 | if self._play_timer:
483 | self._play_timer.shutdown()
484 | self._play_timer = None
485 |
486 | def _first(self):
487 | """Navigate to the first message. Activates the next and last buttons, disables
488 | first and previous, and refreshes the view. Also changes the state to be
489 | browsing the timeline.
490 |
491 | """
492 | self._timeline.navigate_start()
493 |
494 | self._set_timeline_buttons(first_snapshot=False, previous_snapshot=False, next_snapshot=True, last_snapshot=True)
495 | self._refresh_view.emit()
496 | self._browsing_timeline = True
497 |
498 | def _previous(self):
499 | """Navigate to the previous message. Activates the next and last buttons, and
500 | refreshes the view. If the current message is the second message, then
501 | the first and previous buttons are disabled. Changes the state to be
502 | browsing the timeline.
503 | """
504 | # if already at the beginning, do nothing
505 | if self._timeline._timeline_frame.playhead == self._timeline._get_start_stamp():
506 | return
507 |
508 | # otherwise, go to the previous message
509 | self._timeline.navigate_previous()
510 | # now browsing the timeline
511 | self._browsing_timeline = True
512 | self._set_timeline_buttons(last_snapshot=True, next_snapshot=True)
513 | # if now at the beginning, disable timeline buttons.
514 | if self._timeline._timeline_frame.playhead == self._timeline._get_end_stamp():
515 | self._set_timeline_buttons(next_snapshot=False, last_snapshot=False)
516 |
517 | self._refresh_view.emit()
518 |
519 | def _last(self):
520 | """Navigate to the last message. Activates the first and previous buttons,
521 | disables next and last, and refreshes the view. The user is no longer
522 | browsing the timeline after this is called.
523 |
524 | """
525 | self._timeline.navigate_end()
526 |
527 | self._set_timeline_buttons(first_snapshot=True, previous_snapshot=True, next_snapshot=False, last_snapshot=False)
528 | self._refresh_view.emit()
529 | self._browsing_timeline = False
530 | self._new_messages = 0
531 |
532 | def _next(self):
533 | """Navigate to the next message. Activates the first and previous buttons. If
534 | the current message is the second from last, disables the next and last
535 | buttons, and stops browsing the timeline.
536 |
537 | """
538 | # if already at the end, do nothing
539 | if self._timeline._timeline_frame.playhead == self._timeline._get_end_stamp():
540 | return
541 |
542 | # otherwise, go to the next message
543 | self._timeline.navigate_next()
544 | self._set_timeline_buttons(first_snapshot=True, previous_snapshot=True)
545 | # if now at the end, disable timeline buttons and shutdown the play timer if active
546 | if self._timeline._timeline_frame.playhead == self._timeline._get_end_stamp():
547 | self._set_timeline_buttons(next_snapshot=False, last_snapshot=False)
548 | self._browsing_timeline = False
549 | if self._play_timer:
550 | self._play_timer.shutdown()
551 |
552 | self._refresh_view.emit()
553 |
554 | def save_settings(self, plugin_settings, instance_settings):
555 | instance_settings.set_value('visibility_level', self.visibility_level)
556 | instance_settings.set_value('auto_fit_graph_check_box_state',
557 | self._widget.auto_fit_graph_check_box.isChecked())
558 | instance_settings.set_value('highlight_connections_check_box_state',
559 | self._widget.highlight_connections_check_box.isChecked())
560 | combo_text = self._widget.topic_combo_box.currentText()
561 | if combo_text not in [self._empty_topic, self._unselected_topic]:
562 | instance_settings.set_value('combo_box_subscribed_topic', combo_text)
563 |
564 | def restore_settings(self, plugin_settings, instance_settings):
565 | try:
566 | self._widget.auto_fit_graph_check_box.setChecked(
567 | instance_settings.value('auto_fit_graph_check_box_state', True) in [True, 'true'])
568 | self._widget.highlight_connections_check_box.setChecked(
569 | instance_settings.value('highlight_connections_check_box_state', True) in [True, 'true'])
570 | self._saved_settings_topic = instance_settings.value('combo_box_subscribed_topic', None)
571 | saved_visibility_level = instance_settings.value('visibility_level', 1)
572 | except TypeError as e:
573 | self._widget.auto_fit_graph_check_box.setChecked(True)
574 | self._widget.highlight_connections_check_box.setChecked(True)
575 | self._saved_settings_topic = None
576 | saved_visibility_level = 1
577 | rospy.logerr("Rqt PyTrees: incompatible qt app configuration found, try removing ~/.config/ros.org/rqt_gui.ini")
578 | rospy.logerr("Rqt PyTrees: %s" % e)
579 | self._widget.visibility_level_combo_box.setCurrentIndex(visibility.saved_setting_to_combo_index[saved_visibility_level])
580 | self.initialized = True
581 | self._update_combo_topics()
582 | self._refresh_tree_graph()
583 |
584 | def _refresh_tree_graph(self):
585 | """Refresh the graph view by regenerating the dotcode from the current message.
586 |
587 | """
588 | if not self.initialized:
589 | return
590 | self._update_graph_view(self._generate_dotcode(self.get_current_message()))
591 |
592 | def _generate_dotcode(self, message):
593 | """
594 | Get the dotcode for the given message, checking the cache for dotcode that
595 | was previously generated, and adding to the cache if it wasn't there.
596 | Cache replaces LRU.
597 |
598 | Mostly stolen from rqt_bag.MessageLoaderThread
599 |
600 | :param py_trees_msgs.BehavoiurTree message
601 | """
602 | if message is None:
603 | return ""
604 |
605 | #######################################################
606 | # Get the tip, from the perspective of the root
607 | #######################################################
608 | # this is pretty inefficient, and ignores caching
609 | tip_id = None
610 | self._tip_message = None
611 | # reverse behaviour list - construction puts the root at the end (with
612 | # visitor, at least)
613 | for behaviour in reversed(message.behaviours):
614 | # root has empty parent ID
615 | if str(behaviour.parent_id) == str(uuid_msgs.UniqueID()):
616 | # parent is the root behaviour, so
617 | tip_id = behaviour.tip_id
618 |
619 | # Run through the behaviours and do a couple of things:
620 | # - get the tip
621 | # - protect against feedback messages with quotes (https://bitbucket.org/yujinrobot/gopher_crazy_hospital/issues/72/rqt_py_trees-fails-to-display-tree)
622 | if self._tip_message is None:
623 | for behaviour in message.behaviours:
624 | if str(behaviour.own_id) == str(tip_id):
625 | self._tip_message = behaviour.message
626 | if '"' in behaviour.message:
627 | print("%s" % termcolor.colored('[ERROR] found double quotes in the feedback message [%s]' % behaviour.message, 'red'))
628 | behaviour.message = behaviour.message.replace('"', '')
629 | print("%s" % termcolor.colored('[ERROR] stripped to stop from crashing, but do catch the culprit! [%s]' % behaviour.message, 'red'))
630 |
631 | key = str(message.header.stamp) # stamps are unique
632 | if key in self._dotcode_cache:
633 | return self._dotcode_cache[key]
634 |
635 | force_refresh = self._force_refresh
636 | self._force_refresh = False
637 |
638 | visible_behaviours = visibility.filter_behaviours_by_visibility_level(message.behaviours, self.visibility_level)
639 |
640 | # cache miss
641 | dotcode = self.dotcode_generator.generate_dotcode(dotcode_factory=self.dotcode_factory,
642 | behaviours=visible_behaviours,
643 | timestamp=message.header.stamp,
644 | force_refresh=force_refresh
645 | )
646 | self._dotcode_cache[key] = dotcode
647 | self._dotcode_cache_keys.append(key)
648 |
649 | if len(self._dotcode_cache) > self._dotcode_cache_capacity:
650 | oldest = self._dotcode_cache_keys[0]
651 | del self._dotcode_cache[oldest]
652 | self._dotcode_cache_keys.remove(oldest)
653 |
654 | return dotcode
655 |
656 | def _update_graph_view(self, dotcode):
657 | if dotcode == self._current_dotcode:
658 | return
659 | self._current_dotcode = dotcode
660 | self._redraw_graph_view()
661 |
662 | def _redraw_graph_view(self):
663 | key = str(self.get_current_message().header.stamp)
664 | if key in self._scene_cache:
665 | new_scene = self._scene_cache[key]
666 | else: # cache miss
667 | new_scene = QGraphicsScene()
668 | new_scene.setBackgroundBrush(Qt.white)
669 |
670 | if self._widget.highlight_connections_check_box.isChecked():
671 | highlight_level = 3
672 | else:
673 | highlight_level = 1
674 |
675 | # (nodes, edges) = self.dot_to_qt.graph_to_qt_items(self.dotcode_generator.graph,
676 | # highlight_level)
677 | # this function is very expensive
678 | (nodes, edges) = self.dot_to_qt.dotcode_to_qt_items(self._current_dotcode,
679 | highlight_level)
680 |
681 | for node_item in iter(nodes.values()):
682 | new_scene.addItem(node_item)
683 | for edge_items in iter(edges.values()):
684 | for edge_item in edge_items:
685 | edge_item.add_to_scene(new_scene)
686 |
687 | new_scene.setSceneRect(new_scene.itemsBoundingRect())
688 |
689 | # put the scene in the cache
690 | self._scene_cache[key] = new_scene
691 | self._scene_cache_keys.append(key)
692 |
693 | if len(self._scene_cache) > self._scene_cache_capacity:
694 | oldest = self._scene_cache_keys[0]
695 | del self._scene_cache[oldest]
696 | self._scene_cache_keys.remove(oldest)
697 |
698 | # after construction, set the scene and fit to the view
699 | self._scene = new_scene
700 |
701 | self._widget.graphics_view.setScene(self._scene)
702 | self._widget.message_label.setText(self._tip_message)
703 |
704 | if self._widget.auto_fit_graph_check_box.isChecked():
705 | self._fit_in_view()
706 |
707 | def _resize_event(self, event):
708 | """Activated when the window is resized. Will re-fit the behaviour tree in the
709 | window, and update the size of the timeline scene rectangle so that it
710 | is correctly drawn.
711 |
712 | """
713 | self._fit_in_view()
714 | if self._timeline:
715 | self._timeline.setSceneRect(0, 0, self._widget.timeline_graphics_view.width() - 2, max(self._widget.timeline_graphics_view.height() - 2, self._timeline._timeline_frame._history_bottom))
716 |
717 | def timeline_changed(self):
718 | """Should be called whenever the timeline changes. At the moment this is only
719 | used to ensure that the first and previous buttons are correctly
720 | disabled when a new message coming in on the timeline pushes the
721 | playhead to be at the first message
722 |
723 | """
724 | if self._timeline._timeline_frame.playhead == self._timeline._get_start_stamp():
725 | self._set_timeline_buttons(first_snapshot=False, previous_snapshot=False)
726 | else:
727 | self._set_timeline_buttons(first_snapshot=True, previous_snapshot=True)
728 |
729 | def message_changed(self):
730 | """
731 | This function should be called when the message being viewed changes. Will
732 | change the current message and update the view. Also ensures that the
733 | timeline buttons are correctly set for the current position of the
734 | playhead on the timeline.
735 | """
736 | if self._timeline._timeline_frame.playhead == self._timeline._get_end_stamp():
737 | self._set_timeline_buttons(last_snapshot=False, next_snapshot=False)
738 | else:
739 | self._set_timeline_buttons(last_snapshot=True, next_snapshot=True)
740 |
741 | if self._timeline._timeline_frame.playhead == self._timeline._get_start_stamp():
742 | self._set_timeline_buttons(first_snapshot=False, previous_snapshot=False)
743 | else:
744 | self._set_timeline_buttons(first_snapshot=True, previous_snapshot=True)
745 |
746 | self._refresh_view.emit()
747 |
748 | def message_cleared(self):
749 | """
750 | This function should be called when the message being viewed was cleared.
751 | Currently no situation where this happens?
752 | """
753 | pass
754 |
755 | def no_right_click_press_event(self, func):
756 | """Decorator for ignoring right click events on mouse press
757 | """
758 | @functools.wraps(func)
759 | def wrapper(event):
760 | if event.type() == QEvent.MouseButtonPress and event.button() == Qt.RightButton:
761 | event.ignore()
762 | else:
763 | func(event)
764 | return wrapper
765 |
766 | def _set_dynamic_timeline(self):
767 | """
768 | Set the timeline to a dynamic timeline, listening to messages on the topic
769 | selected in the combo box.
770 | """
771 | self._timeline = DynamicTimeline(self, publish_clock=False)
772 | # connect timeline events so that the timeline will update when events happen
773 | self._widget.timeline_graphics_view.mousePressEvent = self.no_right_click_press_event(self._timeline.on_mouse_down)
774 | self._widget.timeline_graphics_view.mouseReleaseEvent = self._timeline.on_mouse_up
775 | self._widget.timeline_graphics_view.mouseMoveEvent = self._timeline.on_mouse_move
776 | self._widget.timeline_graphics_view.wheelEvent = self._timeline.on_mousewheel
777 | self._widget.timeline_graphics_view.setScene(self._timeline)
778 |
779 | # Don't show scrollbars - the timeline adjusts to the size of the view
780 | self._widget.timeline_graphics_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
781 | self._widget.timeline_graphics_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
782 | # Send a resize event so that the timeline knows the size of the view it's in
783 | self._resize_event(None)
784 |
785 | self._timeline.add_topic(self.current_topic, py_trees_msgs.BehaviourTree)
786 |
787 | # Create a listener for the timeline which will call the emit function
788 | # on the given signals when the message being viewed changes or is
789 | # cleared. The message being viewed changing generally happens when the
790 | # user moves the slider around.
791 | self._timeline_listener = DynamicTimelineListener(self._timeline, self.current_topic, self._message_changed, self._message_cleared)
792 | # Need to add a listener to make sure that we can get information about
793 | # messages that are on the topic that we're interested in.
794 | self._timeline.add_listener(self.current_topic, self._timeline_listener)
795 |
796 | self._timeline.navigate_end()
797 | self._timeline._redraw_timeline(None)
798 | self._timeline.timeline_updated.connect(self.timeline_changed)
799 |
800 | def _set_bag_timeline(self, bag):
801 | """Set the timeline of this object to a bag timeline, hooking the graphics view
802 | into mouse and wheel functions of the timeline.
803 |
804 | """
805 | self._timeline = BagTimeline(self, publish_clock=False)
806 | # connect timeline events so that the timeline will update when events happen
807 | self._widget.timeline_graphics_view.mousePressEvent = self.no_right_click_press_event(self._timeline.on_mouse_down)
808 | self._widget.timeline_graphics_view.mouseReleaseEvent = self._timeline.on_mouse_up
809 | self._widget.timeline_graphics_view.mouseMoveEvent = self._timeline.on_mouse_move
810 | self._widget.timeline_graphics_view.wheelEvent = self._timeline.on_mousewheel
811 | self._widget.timeline_graphics_view.setScene(self._timeline)
812 |
813 | # Don't show scrollbars - the timeline adjusts to the size of the view
814 | self._widget.timeline_graphics_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
815 | self._widget.timeline_graphics_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
816 | # Send a resize event so that the timeline knows the size of the view it's in
817 | self._resize_event(None)
818 |
819 | self._timeline.add_bag(bag)
820 | # Create a listener for the timeline which will call the emit function
821 | # on the given signals when the message being viewed changes or is
822 | # cleared. The message being viewed changing generally happens when the
823 | # user moves the slider around.
824 | self._timeline_listener = TimelineListener(self._timeline, self.current_topic, self._message_changed, self._message_cleared)
825 | # Need to add a listener to make sure that we can get information about
826 | # messages that are on the topic that we're interested in.
827 | self._timeline.add_listener(self.current_topic, self._timeline_listener)
828 | # Go to the first message in the timeline of the bag.
829 | self._timeline.navigate_start()
830 |
831 | def _load_bag(self, file_name=None):
832 | """Load a bag from file. If no file name is given, a dialogue will pop up and
833 | the user will be asked to select a file. If the bag file selected
834 | doesn't have any valid topic, nothing will happen. If there are valid
835 | topics, we load the bag and add a timeline for managing it.
836 |
837 | """
838 | if file_name is None:
839 | file_name, _ = QFileDialog.getOpenFileName(
840 | self._widget,
841 | self.tr('Open trees from bag file'),
842 | None,
843 | self.tr('ROS bag (*.bag)'))
844 | if file_name is None or file_name == "":
845 | return
846 |
847 | rospy.loginfo("Reading bag from {0}".format(file_name))
848 | bag = rosbag.Bag(file_name, 'r')
849 | # ugh...
850 | topics = list(bag.get_type_and_topic_info()[1].keys())
851 | types = []
852 | for i in range(0, len(bag.get_type_and_topic_info()[1].values())):
853 | types.append(list(bag.get_type_and_topic_info()[1].values())[i][0])
854 |
855 | tree_topics = [] # only look at the first matching topic
856 | for ind, tp in enumerate(types):
857 | if tp == 'py_trees_msgs/BehaviourTree':
858 | tree_topics.append(topics[ind])
859 |
860 | if len(tree_topics) == 0:
861 | rospy.logerr('Requested bag did not contain any valid topics.')
862 | return
863 |
864 | self.message_list = []
865 | self._viewing_bag = True
866 | rospy.loginfo('Reading behaviour trees from topic {0}'.format(tree_topics[0]))
867 | for unused_topic, msg, unused_t in bag.read_messages(topics=[tree_topics[0]]):
868 | self.message_list.append(msg)
869 |
870 | self.current_topic = tree_topics[0]
871 | self._set_timeline_buttons(first_snapshot=True, previous_snapshot=True, next_snapshot=False, last_snapshot=False)
872 | self._set_bag_timeline(bag)
873 | self._refresh_view.emit()
874 |
875 | def _load_dot(self, file_name=None):
876 | if file_name is None:
877 | file_name, _ = QFileDialog.getOpenFileName(
878 | self._widget,
879 | self.tr('Open tree from DOT file'),
880 | None,
881 | self.tr('DOT graph (*.dot)'))
882 | if file_name is None or file_name == '':
883 | return
884 |
885 | try:
886 | fhandle = open(file_name, 'rb')
887 | dotcode = fhandle.read()
888 | fhandle.close()
889 | except IOError:
890 | return
891 | self._update_graph_view(dotcode)
892 |
893 | def _fit_in_view(self):
894 | self._widget.graphics_view.fitInView(self._scene.itemsBoundingRect(),
895 | Qt.KeepAspectRatio)
896 |
897 | def _save_dot(self):
898 | file_name, _ = QFileDialog.getSaveFileName(self._widget,
899 | self.tr('Save as DOT'),
900 | 'frames.dot',
901 | self.tr('DOT graph (*.dot)'))
902 | if file_name is None or file_name == '':
903 | return
904 |
905 | dot_file = QFile(file_name)
906 | if not dot_file.open(QIODevice.WriteOnly | QIODevice.Text):
907 | return
908 |
909 | dot_file.write(self._current_dotcode)
910 | dot_file.close()
911 |
912 | def _save_svg(self):
913 | file_name, _ = QFileDialog.getSaveFileName(
914 | self._widget,
915 | self.tr('Save as SVG'),
916 | 'frames.svg',
917 | self.tr('Scalable Vector Graphic (*.svg)'))
918 | if file_name is None or file_name == '':
919 | return
920 |
921 | generator = QSvgGenerator()
922 | generator.setFileName(file_name)
923 | generator.setSize((self._scene.sceneRect().size() * 2.0).toSize())
924 |
925 | painter = QPainter(generator)
926 | painter.setRenderHint(QPainter.Antialiasing)
927 | self._scene.render(painter)
928 | painter.end()
929 |
930 | def _save_image(self):
931 | file_name, _ = QFileDialog.getSaveFileName(
932 | self._widget,
933 | self.tr('Save as image'),
934 | 'frames.png',
935 | self.tr('Image (*.bmp *.jpg *.png *.tiff)'))
936 | if file_name is None or file_name == '':
937 | return
938 |
939 | img = QImage((self._scene.sceneRect().size() * 2.0).toSize(),
940 | QImage.Format_ARGB32_Premultiplied)
941 | painter = QPainter(img)
942 | painter.setRenderHint(QPainter.Antialiasing)
943 | self._scene.render(painter)
944 | painter.end()
945 | img.save(file_name)
946 |
--------------------------------------------------------------------------------