Skip to content

Commit 7d83ca9

Browse files
authored
Patch GLGraphicsItem and GLViewMixin to support nested GLLayers (UBC-Thunderbots#3400)
* Use pyqtdarktheme-fork * Patch bug in GLGraphicsItem.setParentItem * Patch GLGraphicsItem and GLViewMixin * Update docs
1 parent 10bded3 commit 7d83ca9

16 files changed

+127
-69
lines changed

docs/software-architecture-and-design.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,7 @@ Thunderscope has a field visualizer that uses [PyQtGraph's 3D graphics system](h
534534
### Layers
535535
We organize our graphics into "layers" so that we can toggle the visibility of different parts of our visualization. Each layer is responsible for visualizing a specific portion of our AI (e.g. vision data, path planning, passing, etc.). A layer can also handle layer-specific functionality; for instance, `GLWorldLayer` lets the user place or kick the ball using the mouse. The base class for a layer is [`GLLayer`](../src/software/thunderscope/gl/layers/gl_layer.py).
536536

537-
A `GLLayer` is in fact a `GLGraphicsItem` that is added to the scenegraph. When we add or remove `GLGraphicsItem`s to a `GLLayer`, we're actually setting the `GLLayer` as the parent of the `GLGraphicsItem`; this is because the scenegraph has a tree-like structure. In theory, `GLLayer`s could also be nested within one another.
537+
A `GLLayer` is in fact a `GLGraphicsItem` that is added to the scenegraph. When we add or remove `GLGraphicsItem`s to a `GLLayer`, we're actually setting the `GLLayer` as the parent of the `GLGraphicsItem`; this is because the scenegraph has a hierarchical tree-like structure. `GLLayer`s can also be nested within one another, i.e. a `GLLayer` can be added as a child of another `GLLayer`.
538538

539539
# Simulator
540540
The `Simulator` is what we use for physics simulation to do testing when we don't have access to real field. In terms of the architecture, the `Simulator` "simulates" the following components' functionalities:

environment_setup/ubuntu20_requirements.txt

-1
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,4 @@ iterfzf==0.5.0.20.0
66
python-Levenshtein==0.25.1
77
psutil==5.9.0
88
PyOpenGL==3.1.6
9-
qt-material==2.12
109
ruff==0.5.5

environment_setup/ubuntu22_requirements.txt

-1
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,4 @@ python-Levenshtein==0.25.1
77
psutil==5.9.0
88
PyOpenGL==3.1.6
99
numpy==1.26.4
10-
qt-material==2.12
1110
ruff==0.5.5

environment_setup/ubuntu24_requirements.txt

-1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,4 @@ iterfzf==0.5.0.20.0
44
python-Levenshtein==0.25.1
55
psutil==5.9.0
66
PyOpenGL==3.1.6
7-
qt-material==2.12
87
ruff==0.5.5

src/software/thunderscope/BUILD

+1
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ py_library(
126126
":proto_unix_io",
127127
":thunderscope_types",
128128
":widget_names_to_setup",
129+
requirement("pyqtdarktheme-fork"),
129130
],
130131
)
131132

src/software/thunderscope/gl/gl_widget.py

+12-25
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from pyqtgraph.Qt.QtWidgets import *
55
from pyqtgraph.opengl import *
66

7-
import functools
87
import numpy as np
98
from typing import Optional
109
from software.thunderscope.common.frametime_counter import FrameTimeCounter
@@ -217,27 +216,19 @@ def add_layer(self, layer: GLLayer, visible: bool = True) -> None:
217216
"""
218217
self.layers.append(layer)
219218

219+
# Add the layer to the scene
220+
self.gl_view_widget.addItem(layer)
221+
layer.setVisible(visible)
222+
220223
# Add the layer to the Layer menu
221224
(layer_checkbox, layer_action) = self.__setup_menu_checkbox(
222225
layer.name, self.layers_menu, visible
223226
)
224227
self.layers_menu_actions[layer.name] = layer_action
225228
self.layers_menu.addAction(layer_action)
226-
227-
# Add layer and its related layers to the scene
228-
while layer:
229-
self.gl_view_widget.addItem(layer)
230-
layer.setVisible(visible)
231-
232-
# Connect visibility of all related layers to the same item
233-
# in the layer menu
234-
layer_checkbox.stateChanged.connect(
235-
functools.partial(
236-
lambda l: l.setVisible(layer_checkbox.isChecked()), layer
237-
)
238-
)
239-
240-
layer = layer.related_layer
229+
layer_checkbox.stateChanged.connect(
230+
lambda: layer.setVisible(layer_checkbox.isChecked())
231+
)
241232

242233
def remove_layer(self, layer: GLLayer) -> None:
243234
"""Remove a layer from this GLWidget
@@ -246,15 +237,13 @@ def remove_layer(self, layer: GLLayer) -> None:
246237
"""
247238
self.layers.remove(layer)
248239

240+
# Remove the layer from the scene
241+
self.gl_view_widget.removeItem(layer)
242+
249243
# Remove the layer from the Layer menu
250244
layer_action = self.layers_menu_actions[layer.name]
251245
self.layers_menu.removeAction(layer_action)
252246

253-
# Remove layer its related layers from the scene
254-
while layer:
255-
self.gl_view_widget.removeItem(layer)
256-
layer = layer.related_layer
257-
258247
def refresh(self) -> None:
259248
"""Trigger an update on all the layers"""
260249
if self.player:
@@ -273,10 +262,8 @@ def refresh(self) -> None:
273262
# Don't refresh the layers if the simulation is paused
274263
if simulation_state.is_playing:
275264
for layer in self.layers:
276-
while layer:
277-
if layer.visible():
278-
layer.refresh_graphics()
279-
layer = layer.related_layer
265+
if layer.visible():
266+
layer.refresh_graphics()
280267

281268
def set_camera_view(self, camera_view: CameraView) -> None:
282269
"""Set the camera position to a preset camera view

src/software/thunderscope/gl/graphics/gl_goal.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ def __init__(
3333

3434
# The 3D mesh isn't visible from the orthographic view, so
3535
# we need to draw an outline of the goal on the ground
36-
self.goal_outline = GLLinePlotItem(color=color, width=LINE_WIDTH)
37-
self.goal_outline.setParentItem(self)
36+
self.goal_outline = GLLinePlotItem(
37+
parentItem=self, color=color, width=LINE_WIDTH
38+
)
3839

3940
# Need to give goal some default meshdata; otherwise, pyqtgraph
4041
# tries calculating some stuff using invalid vertices/faces and

src/software/thunderscope/gl/helpers/BUILD

+8
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ py_library(
1010
],
1111
)
1212

13+
py_library(
14+
name = "gl_patches",
15+
srcs = ["gl_patches.py"],
16+
deps = [
17+
requirement("pyqtgraph"),
18+
],
19+
)
20+
1321
py_library(
1422
name = "observable_list",
1523
srcs = ["observable_list.py"],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem
2+
from pyqtgraph.opengl.GLViewWidget import GLViewMixin
3+
4+
5+
def GLGraphicsItem_setParentItem_patched(self, parent: GLGraphicsItem) -> None:
6+
"""Patched version of GLGraphicsItem.setParentItem that properly
7+
removes this item from the scenegraph when the parent param is None.
8+
9+
From the original pyqtgraph documentation:
10+
Sets this item's parent in the scenegraph hierarchy.
11+
12+
:param parent: the GLGraphicsItem to set as the parent of this item.
13+
If None, this item is removed from the scenegraph.
14+
"""
15+
if self._GLGraphicsItem__parent:
16+
self._GLGraphicsItem__parent._GLGraphicsItem__children.remove(self)
17+
self._GLGraphicsItem__parent = None
18+
if self.view():
19+
self.view().removeItem(self)
20+
21+
if parent:
22+
parent._GLGraphicsItem__children.add(self)
23+
self._GLGraphicsItem__parent = parent
24+
if parent.view():
25+
parent.view().addItem(self)
26+
27+
28+
def GLViewMixin_addItem_patched(self, item: GLGraphicsItem) -> None:
29+
"""Patched version of GLViewMixin.addItem that properly adds all
30+
the item and all its descendants to the scene.
31+
32+
:param item: the item to add to the scene
33+
"""
34+
items_to_add = [item]
35+
36+
while items_to_add:
37+
item_to_add = items_to_add.pop()
38+
self.items.append(item_to_add)
39+
item_to_add._setView(self)
40+
41+
if self.isValid():
42+
item_to_add.initialize()
43+
44+
for child in item_to_add.childItems():
45+
items_to_add.append(child)
46+
47+
self.update()
48+
49+
50+
def GLViewMixin_removeItem_patched(self, item: GLGraphicsItem) -> None:
51+
"""Patched version of GLViewMixin.removeItem that properly removes
52+
the given item and its descendants from the scene.
53+
54+
:param item: the item to remove from the scene
55+
"""
56+
items_to_remove = [item]
57+
58+
while items_to_remove:
59+
item_to_remove = items_to_remove.pop()
60+
self.items.remove(item_to_remove)
61+
item_to_remove._setView(None)
62+
63+
for child in item_to_remove.childItems():
64+
items_to_remove.append(child)
65+
66+
self.update()
67+
68+
69+
GLGraphicsItem.setParentItem = GLGraphicsItem_setParentItem_patched
70+
GLViewMixin.addItem = GLViewMixin_addItem_patched
71+
GLViewMixin.removeItem = GLViewMixin_removeItem_patched

src/software/thunderscope/gl/layers/BUILD

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ py_library(
88
deps = [
99
"//software/thunderscope:thread_safe_buffer",
1010
"//software/thunderscope/gl/helpers:extended_gl_view_widget",
11+
"//software/thunderscope/gl/helpers:gl_patches",
1112
"//software/thunderscope/gl/helpers:observable_list",
1213
requirement("pyqtgraph"),
1314
],

src/software/thunderscope/gl/layers/gl_cost_vis_layer.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ class GLCostVisOverlayLayer(GLLayer):
2424
def __init__(self, cost_vis_layer: GLCostVisLayer) -> None:
2525
"""Initialize the GLCostVisOverlayLayer
2626
27-
:param cost_vis_layer: The GLCostVisLayer this overlay layer is related to
27+
:param cost_vis_layer: The GLCostVisLayer to set as the parent of this layer
2828
"""
29-
super().__init__("GLCostVisOverlayLayer")
29+
super().__init__("GLCostVisOverlayLayer", parent_item=cost_vis_layer)
3030
self.setDepthValue(DepthValues.OVERLAY_DEPTH)
3131

3232
self.cost_vis_layer = cost_vis_layer
@@ -82,7 +82,7 @@ def __init__(self, name: str, buffer_size: int = 5) -> None:
8282
"""
8383
super().__init__(name)
8484
self.setDepthValue(DepthValues.BENEATH_BACKGROUND_DEPTH)
85-
self.related_layer = GLCostVisOverlayLayer(self)
85+
self.cost_vis_overlay_layer = GLCostVisOverlayLayer(self)
8686

8787
self.world_buffer = ThreadSafeBuffer(buffer_size, World)
8888
self.cost_visualization_buffer = ThreadSafeBuffer(
@@ -107,6 +107,8 @@ def refresh_graphics(self) -> None:
107107
except queue.Empty:
108108
cost_vis = None
109109

110+
self.cost_vis_overlay_layer.refresh_graphics()
111+
110112
if not cost_vis:
111113
cost_vis = self.cached_cost_vis
112114

src/software/thunderscope/gl/layers/gl_layer.py

+9-26
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from pyqtgraph.Qt import QtGui
22
from pyqtgraph.opengl.GLGraphicsItem import GLGraphicsItem
33

4-
54
from software.thunderscope.gl.helpers.observable_list import Change, ChangeAction
65
from software.thunderscope.gl.helpers.extended_gl_view_widget import MouseInSceneEvent
6+
from software.thunderscope.gl.helpers.gl_patches import *
77

88

99
class GLLayer(GLGraphicsItem):
@@ -12,27 +12,20 @@ class GLLayer(GLGraphicsItem):
1212
A layer is added to the 3D scenegraph and represents a collection of
1313
GLGraphicsItems to be displayed together. GLGraphicsItems should be
1414
added as children of a GLLayer.
15+
16+
GLLayers themselves can also be added as a children of a GLLayer,
17+
enabling us to group together related layers.
1518
"""
1619

17-
def __init__(self, name: str) -> None:
20+
def __init__(self, name: str, parent_item: GLGraphicsItem | None = None) -> None:
1821
"""Initialize the GLLayer
1922
2023
:param name: The displayed name of the layer
24+
:param parent_item: The parent GLGraphicsItem of the GLLayer
2125
"""
22-
super().__init__()
26+
super().__init__(parent_item)
2327
self.name = name
2428

25-
# GLLayers can point to one another with this field, forming a
26-
# linked list of "related" GLLayers.
27-
#
28-
# Related layers are grouped together and treated as a single layer
29-
# in the layer menu. This lets us treat multiple layers rendered at
30-
# different depths as one unit and toggle their visibility together
31-
# as a whole.
32-
#
33-
# WARNING: Related GLLayers should not be parents/children of each other
34-
self.related_layer: GLLayer = None
35-
3629
def refresh_graphics(self) -> None:
3730
"""Updates the GLGraphicsItems in this layer"""
3831

@@ -87,18 +80,8 @@ def _graphics_changed(self, change: Change) -> None:
8780
"""
8881
if change.action == ChangeAction.ADD:
8982
for graphic in change.elements:
90-
# Manually setting the parent of the GLGraphicsItem to this
91-
# layer since GLGraphicsItem.setParentItem does not work
92-
# correctly
93-
self._GLGraphicsItem__children.add(graphic)
94-
graphic._GLGraphicsItem__parent = self
95-
self.view().addItem(graphic)
83+
graphic.setParentItem(self)
9684

9785
elif change.action == ChangeAction.REMOVE:
9886
for graphic in change.elements:
99-
# Manually removing the GLGraphicsItem as a child of this
100-
# layer since GLGraphicsItem.setParentItem does not work
101-
# correctly
102-
self._GLGraphicsItem__children.remove(graphic)
103-
graphic._GLGraphicsItem__parent = None
104-
self.view().removeItem(graphic)
87+
graphic.setParentItem(None)

src/software/thunderscope/gl/layers/gl_validation_layer.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ class GLValidationOverlayLayer(GLLayer):
2727
def __init__(self, validation_layer: GLValidationLayer) -> None:
2828
"""Initialize the GLValidationOverlayLayer
2929
30-
:param validation_layer: The GLValidationLayer this overlay layer is related to
30+
:param validation_layer: The GLValidationLayer to set as the parent of this layer
3131
"""
32-
super().__init__("GLValidationOverlayLayer")
32+
super().__init__("GLValidationOverlayLayer", parent_item=validation_layer)
3333
self.setDepthValue(DepthValues.OVERLAY_DEPTH)
3434

3535
self.validation_layer = validation_layer
@@ -84,7 +84,7 @@ def __init__(
8484
"""
8585
super().__init__(name)
8686
self.setDepthValue(DepthValues.BACKGROUND_DEPTH)
87-
self.related_layer = GLValidationOverlayLayer(self)
87+
self.validation_overlay_layer = GLValidationOverlayLayer(self)
8888

8989
# Validation protobufs are generated by simulated tests
9090
self.validation_set_buffer = ThreadSafeBuffer(buffer_size, ValidationProtoSet)
@@ -129,6 +129,8 @@ def refresh_graphics(self) -> None:
129129

130130
self.__update_validation_graphics(self.get_validations())
131131

132+
self.validation_overlay_layer.refresh_graphics()
133+
132134
def get_validations(self) -> list[ValidationProto]:
133135
"""Get a list of the cached validations
134136
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
numpy==1.26.4
22
protobuf==5.27.3
33
pyqtgraph==0.13.7
4+
pyqtdarktheme-fork==2.3.2

src/software/thunderscope/requirements_lock.txt

+8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
#
55
# bazel run //software/thunderscope:requirements.update
66
#
7+
darkdetect==0.7.1 \
8+
--hash=sha256:3efe69f8ecd5f1b7f4fbb0d1d93f656b0e493c45cc49222380ffe2a529cbc866 \
9+
--hash=sha256:47be3cf5134432ddb616bbffc927237718407914993c82809983e7ccebf49013
10+
# via pyqtdarktheme-fork
711
numpy==1.26.4 \
812
--hash=sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b \
913
--hash=sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818 \
@@ -57,6 +61,10 @@ protobuf==5.27.3 \
5761
--hash=sha256:c84eee2c71ed83704f1afbf1a85c3171eab0fd1ade3b399b3fad0884cbcca8bf \
5862
--hash=sha256:dcb307cd4ef8fec0cf52cb9105a03d06fbb5275ce6d84a6ae33bc6cf84e0a07b
5963
# via -r software/thunderscope/requirements.in
64+
pyqtdarktheme-fork==2.3.2 \
65+
--hash=sha256:3ea94fed5df262d960378409357c63032639f749794d766f41a45ad8558b2523 \
66+
--hash=sha256:d96ee64f0884678fad9b6bc352d5e37d84ca786fa60ed32ffaa7e6c6bc67e964
67+
# via -r software/thunderscope/requirements.in
6068
pyqtgraph==0.13.7 \
6169
--hash=sha256:64f84f1935c6996d0e09b1ee66fe478a7771e3ca6f3aaa05f00f6e068321d9e3 \
6270
--hash=sha256:7754edbefb6c367fa0dfb176e2d0610da3ada20aa7a5318516c74af5fb72bf7a

src/software/thunderscope/thunderscope_config.py

+2-6
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
WidgetStretchData,
1212
)
1313
import pyqtgraph
14-
from qt_material import apply_stylesheet
14+
import qdarktheme
1515
import signal
1616
import os
1717

@@ -39,11 +39,7 @@ def initialize_application() -> None:
3939
app = pyqtgraph.mkQApp("Thunderscope")
4040

4141
# Setup stylesheet
42-
extra = {
43-
# Make thunderscope more dense
44-
"density_scale": "-2",
45-
}
46-
apply_stylesheet(app, theme="dark_blue.xml", extra=extra)
42+
qdarktheme.setup_theme()
4743

4844

4945
def configure_robot_view_fullsystem(

0 commit comments

Comments
 (0)